diff --git a/.formatter.exs b/.formatter.exs index 4a888c15e..2fa4a3110 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,7 +1,7 @@ [ import_deps: [:ecto, :ecto_sql, :phoenix, :open_api_spex], subdirectories: ["priv/*/migrations"], - plugins: [Phoenix.LiveView.HTMLFormatter], + plugins: [], inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/*seeds*.exs"], line_length: 120 ] diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 000000000..e3002c9bd --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +self-hosted-runner: + labels: + - blacksmith-2vcpu-ubuntu-2404 + - blacksmith-4vcpu-ubuntu-2404 + - blacksmith-8vcpu-ubuntu-2404 + - blacksmith-4vcpu-ubuntu-2404-arm diff --git a/.github/workflows/dispatch_deploy.yml b/.github/workflows/dispatch_deploy.yml new file mode 100644 index 000000000..bca1def8a --- /dev/null +++ b/.github/workflows/dispatch_deploy.yml @@ -0,0 +1,52 @@ +name: Dispatch Deploy + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to deploy (no leading v). Leave blank to let the downstream pick the latest release. Example: 2.94.1' + required: false + type: string + workflow_call: + inputs: + version: + description: 'Version to deploy (no leading v).' + required: true + type: string + +permissions: {} + +jobs: + dispatch: + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Fetch target repo + id: target + env: + TARGET_REPO: ${{ secrets.DEPLOY_TARGET_REPO }} + run: | + name="${TARGET_REPO##*/}" + echo "::add-mask::${name}" + echo "name=${name}" >> "$GITHUB_OUTPUT" + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.GH_APP_MANAGER_ID }} + private-key: ${{ secrets.GH_APP_MANAGER_PRIVATE_KEY }} + owner: supabase + repositories: ${{ steps.target.outputs.name }} + + - name: Dispatch version update + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + TARGET_REPO: ${{ secrets.DEPLOY_TARGET_REPO }} + VERSION: ${{ inputs.version }} + SOURCE: ${{ github.event_name == 'workflow_dispatch' && 'manual-dispatch' || 'realtime-release' }} + run: | + gh api "repos/${TARGET_REPO}/dispatches" \ + --method POST \ + --field event_type=realtime-release \ + --field "client_payload[version]=${VERSION}" \ + --field "client_payload[source]=${SOURCE}" diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 000000000..22f80b41d --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,44 @@ +name: Docker Build + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + docker_x86_build: + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 120 + env: + arch: amd64 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1.4.0 + + - uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2 + with: + context: . + push: false + platforms: linux/${{ env.arch }} + + docker_arm_build: + runs-on: blacksmith-4vcpu-ubuntu-2404-arm + timeout-minutes: 120 + env: + arch: arm64 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1.4.0 + + - uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2 + with: + context: . + push: false + platforms: linux/${{ env.arch }} diff --git a/.github/workflows/fix-nix-hash.yml b/.github/workflows/fix-nix-hash.yml new file mode 100644 index 000000000..db013341d --- /dev/null +++ b/.github/workflows/fix-nix-hash.yml @@ -0,0 +1,99 @@ +name: Fix Nix Hash + +on: + pull_request: + paths: + - "test/e2e/flake.nix" + - "test/e2e/flake.lock" + - "test/e2e/bun.lock" + - "test/e2e/package.json" + - "test/e2e/nix-build.sh" + - ".github/workflows/fix-nix-hash.yml" + +permissions: + contents: read # push uses GH_AUTOFIX app token, not GITHUB_TOKEN + +concurrency: + group: fix-nix-hash-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + fix-nix-hash: + name: Validate and Fix Nix Hash + runs-on: ubuntu-latest + timeout-minutes: 20 + if: github.event.pull_request.head.repo.full_name == github.repository + outputs: + changed: ${{ steps.check-changes.outputs.changed }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@da36cb69b1c3247ad7a1f931ebfd954a1105ef14 # v14 + + - name: Fix Nix hash + working-directory: test/e2e + run: bash nix-build.sh + + - name: Check if hash was updated + id: check-changes + run: | + if git diff --quiet test/e2e/flake.nix; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Upload updated hash + if: steps.check-changes.outputs.changed == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: updated-nix-hash + path: test/e2e/flake.nix + retention-days: 1 + + push-nix-hash: + name: Commit Fixed Nix Hash + needs: fix-nix-hash + runs-on: ubuntu-latest + timeout-minutes: 10 + if: needs.fix-nix-hash.outputs.changed == 'true' + + steps: + - name: Generate token + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + with: + app-id: ${{ secrets.GH_AUTOFIX_APP_ID }} + private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.head_ref }} + persist-credentials: false + + - name: Download updated hash + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: updated-nix-hash + path: test/e2e + + - name: Commit and push updated hash + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + TARGET_BRANCH: ${{ github.head_ref }} + run: | + /usr/bin/git config --local user.name "supabase-autofix-bot" + /usr/bin/git config --local user.email "noreply@supabase.com" + /usr/bin/git add test/e2e/flake.nix + + if /usr/bin/git diff --cached --quiet; then + exit 0 + fi + + /usr/bin/git commit -m "chore: update nix node_modules hash" + /usr/bin/git -c credential.helper= push "https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${TARGET_BRANCH}" diff --git a/.github/workflows/forum_tests.yml b/.github/workflows/forum_tests.yml new file mode 100644 index 000000000..447588fbb --- /dev/null +++ b/.github/workflows/forum_tests.yml @@ -0,0 +1,56 @@ +name: Forum Tests +defaults: + run: + shell: bash + working-directory: ./forum +on: + pull_request: + paths: + - "forum/**" + - ".github/workflows/forum_tests.yml" + + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + MIX_ENV: test + +jobs: + tests: + name: Tests & Lint + runs-on: blacksmith-4vcpu-ubuntu-2404 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup elixir + id: beam + uses: erlef/setup-beam@ee09b1e59bb240681c382eb1f0abc6a04af72764 # v1.23.0 + with: + otp-version: 27.x # Define the OTP version [required] + elixir-version: 1.18.x # Define the elixir version [required] + - name: Cache Mix + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + forum/deps + forum/_build + key: ${{ github.workflow }}-${{ runner.os }}-mix-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-${{ hashFiles('forum/mix.lock') }} + restore-keys: | + ${{ github.workflow }}-${{ runner.os }}-mix-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}- + - name: Install dependencies + run: mix deps.get + - name: Start epmd + run: epmd -daemon + - name: Run tests + run: MIX_ENV='test' mix test + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Check for warnings + run: mix compile --force --warnings-as-errors + - name: Run format check + run: mix format --check-formatted diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml new file mode 100644 index 000000000..1d46febe7 --- /dev/null +++ b/.github/workflows/integration_tests.yml @@ -0,0 +1,88 @@ +name: Integration Tests +on: + pull_request: + paths: + - "lib/**" + - "test/**" + - "config/**" + - "priv/**" + - "assets/**" + - "rel/**" + - "mix.exs" + - "mix.lock" + - "Dockerfile" + - "run.sh" + - "docker-compose.test.yml" + - ".github/workflows/integration_tests.yml" + + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + DENO_IMAGE: denoland/deno:alpine-2.5.6 + +jobs: + tests: + name: Tests (${{ matrix.postgres }}) + runs-on: blacksmith-8vcpu-ubuntu-2404 + env: + POSTGRES_IMAGE: ${{ matrix.postgres_image }} + DB_USER_REALTIME: ${{ matrix.db_user_realtime }} + strategy: + fail-fast: false + matrix: + postgres: [pg14, pg15, pg15_latest, pg17] + include: + - postgres: pg14 + postgres_image: supabase/postgres:14.1.0.82 + db_user_realtime: supabase_admin + # test before supautils.policy_grants added all necessary grants + - postgres: pg15 + postgres_image: supabase/postgres:15.1.0.1 + db_user_realtime: supabase_admin + - postgres: pg15_latest + postgres_image: supabase/postgres:15.14.1.129 + db_user_realtime: supabase_realtime_admin + - postgres: pg17 + postgres_image: supabase/postgres:17.6.1.127 + db_user_realtime: supabase_realtime_admin + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Cache Docker images + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + id: docker-cache + with: + path: /tmp/docker-images + key: docker-images-integration-zstd-${{ env.POSTGRES_IMAGE }}-${{ env.DENO_IMAGE }} + - name: Load Docker images from cache + if: steps.docker-cache.outputs.cache-hit == 'true' + run: | + zstd -d --stdout /tmp/docker-images/postgres.tar.zst | docker image load & + PID1=$! + zstd -d --stdout /tmp/docker-images/deno.tar.zst | docker image load & + PID2=$! + wait $PID1 || exit $? + wait $PID2 || exit $? + - name: Pull and save Docker images + if: steps.docker-cache.outputs.cache-hit != 'true' + run: | + docker pull ${{ env.POSTGRES_IMAGE }} & + PID1=$! + docker pull ${{ env.DENO_IMAGE }} & + PID2=$! + wait $PID1 || exit $? + wait $PID2 || exit $? + mkdir -p /tmp/docker-images + docker image save ${{ env.POSTGRES_IMAGE }} | zstd -T0 -o /tmp/docker-images/postgres.tar.zst + docker image save ${{ env.DENO_IMAGE }} | zstd -T0 -o /tmp/docker-images/deno.tar.zst + - name: Run integration test + run: docker compose -f compose.tests.yml up --abort-on-container-exit --exit-code-from test-runner diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..746325ace --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,95 @@ +name: Lint +on: + pull_request: + paths: + - "lib/**" + - "test/**" + - "config/**" + - "priv/**" + - "assets/**" + - "rel/**" + - "mix.exs" + - "mix.lock" + - "Dockerfile" + - "run.sh" + - "mise.toml" + - "test/e2e/nix-build.sh" + - ".github/workflows/**" + - ".github/actionlint.yaml" + + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: blacksmith-4vcpu-ubuntu-2404 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup elixir + id: beam + uses: erlef/setup-beam@ee09b1e59bb240681c382eb1f0abc6a04af72764 # v1.23.0 + with: + otp-version: 27.x # Define the OTP version [required] + elixir-version: 1.18.x # Define the elixir version [required] + - name: Cache Mix + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + deps + _build + key: ${{ github.workflow }}-${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ github.workflow }}-${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}- + + - name: Install dependencies + run: mix deps.get + - name: Check for warnings + run: mix compile --force --warnings-as-errors + - name: Run format check + run: mix format --check-formatted + - name: Credo checks + run: mix credo + - name: Run hex audit + run: mix hex.audit + - name: Run mix_audit + # GHSA-g2wm-735q-3f56: low severity blocking CI + # remove once a fixed cowlib is published + run: mix deps.audit --ignore-advisory-ids "GHSA-g2wm-735q-3f56" + - name: Run sobelow + run: mix sobelow --config .sobelow-conf + - name: Retrieve PLT Cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + id: plt-cache + with: + path: priv/plts + key: ${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-plts-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} + - name: Create PLTs + if: steps.plt-cache.outputs.cache-hit != 'true' + run: | + mkdir -p priv/plts + mix dialyzer.build + - name: Run dialyzer + run: mix dialyzer + + actionlint: + name: Actionlint + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Run actionlint + uses: rhysd/actionlint@v1.7.7 + + shellcheck: + name: Shellcheck + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Run shellcheck + run: shellcheck run.sh test/e2e/nix-build.sh diff --git a/.github/workflows/manual_prod_build.yml b/.github/workflows/manual_prod_build.yml index f5014dd24..62ace61bf 100644 --- a/.github/workflows/manual_prod_build.yml +++ b/.github/workflows/manual_prod_build.yml @@ -10,120 +10,119 @@ on: required: true jobs: docker_x86_release: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 120 env: arch: amd64 outputs: image_digest: ${{ steps.build.outputs.digest }} steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: | supabase/realtime tags: | type=raw,value=v${{ github.event.inputs.docker_tag }}_${{ env.arch }} - - uses: docker/setup-buildx-action@v2 + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1.4.0 - - uses: docker/login-action@v2 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@v3 + uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2 with: push: true tags: ${{ steps.meta.outputs.tags }} platforms: linux/${{ env.arch }} - cache-from: type=gha - cache-to: type=gha,mode=max docker_arm_release: - runs-on: arm-runner + runs-on: blacksmith-4vcpu-ubuntu-2404-arm timeout-minutes: 120 env: arch: arm64 outputs: image_digest: ${{ steps.build.outputs.digest }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: | supabase/realtime tags: | type=raw,value=v${{ github.event.inputs.docker_tag }}_${{ env.arch }} - - uses: docker/login-action@v2 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - uses: docker/setup-buildx-action@v2 - with: - driver: docker - driver-opts: | - image=moby/buildkit:master - network=host + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1.4.0 - id: build - uses: docker/build-push-action@v3 + uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} platforms: linux/${{ env.arch }} - no-cache: true merge_manifest: needs: [docker_x86_release, docker_arm_release] - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read packages: write id-token: write steps: - - uses: docker/setup-buildx-action@v2 + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1.4.0 - - uses: docker/login-action@v2 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Merge multi-arch manifests for custom output + env: + DOCKER_TAG: ${{ github.event.inputs.docker_tag }} + IMAGE_DIGEST: ${{ needs.docker_arm_release.outputs.image_digest }} run: | - docker buildx imagetools create -t supabase/realtime:v${{ github.event.inputs.docker_tag }} \ - supabase/realtime@${{ needs.docker_x86_release.outputs.image_digest }} \ - supabase/realtime@${{ needs.docker_arm_release.outputs.image_digest }} + docker buildx imagetools create -t "supabase/realtime:v${DOCKER_TAG}" \ + "supabase/realtime@${IMAGE_DIGEST}" \ + "supabase/realtime@${IMAGE_DIGEST}" - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v1 + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1 with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 - name: Login to ECR - uses: docker/login-action@v2 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: public.ecr.aws - name: Login to GHCR - uses: docker/login-action@v2 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Mirror to ECR - uses: akhilerm/tag-push-action@v2.0.0 + uses: akhilerm/tag-push-action@f35ff2cb99d407368b5c727adbcc14a2ed81d509 # v2.2.0 with: src: docker.io/supabase/realtime:v${{ github.event.inputs.docker_tag }} dst: | public.ecr.aws/supabase/realtime:v${{ github.event.inputs.docker_tag }} ghcr.io/supabase/realtime:v${{ github.event.inputs.docker_tag }} - diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 8fc83fe45..a90c21b52 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -10,26 +10,26 @@ on: jobs: mirror: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read packages: write id-token: write steps: - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v1 + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1 with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 - - uses: docker/login-action@v2 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: public.ecr.aws - - uses: docker/login-action@v2 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: akhilerm/tag-push-action@v2.1.0 + - uses: akhilerm/tag-push-action@f35ff2cb99d407368b5c727adbcc14a2ed81d509 # v2.2.0 with: src: docker.io/supabase/realtime:${{ inputs.version }} dst: | diff --git a/.github/workflows/prod_build.yml b/.github/workflows/prod_build.yml index 9926c1c03..dc8e08fc5 100644 --- a/.github/workflows/prod_build.yml +++ b/.github/workflows/prod_build.yml @@ -10,27 +10,42 @@ on: - "assets/**" - "rel/**" - "mix.exs" + - "mix.lock" - "Dockerfile" - "run.sh" + - ".github/workflows/prod_build.yml" jobs: release: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 outputs: published: ${{ steps.semantic.outputs.new_release_published }} version: ${{ steps.semantic.outputs.new_release_version }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Generate token + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + - id: semantic - uses: cycjimmy/semantic-release-action@v3 + uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6.0.0 with: - semantic_version: 18 + semantic_version: 24 + extra_plugins: | + @semantic-release/exec + @semantic-release/git env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN_PROJECT_ACTION }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} docker_x86_release: needs: release - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 if: needs.release.outputs.published == 'true' timeout-minutes: 120 env: @@ -38,8 +53,12 @@ jobs: outputs: image_digest: ${{ steps.build.outputs.digest }} steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: v${{ needs.release.outputs.version }} + - id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: | supabase/realtime @@ -47,25 +66,25 @@ jobs: type=raw,value=v${{ needs.release.outputs.version }}_${{ env.arch }} type=raw,value=latest_${{ env.arch }} - - uses: docker/setup-buildx-action@v2 + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1.4.0 - - uses: docker/login-action@v2 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@v3 + uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2 with: + context: . push: true tags: ${{ steps.meta.outputs.tags }} platforms: linux/${{ env.arch }} - cache-from: type=gha - cache-to: type=gha,mode=max docker_arm_release: needs: release - runs-on: arm-runner + runs-on: blacksmith-4vcpu-ubuntu-2404-arm if: needs.release.outputs.published == 'true' timeout-minutes: 120 env: @@ -73,10 +92,12 @@ jobs: outputs: image_digest: ${{ steps.build.outputs.digest }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: v${{ needs.release.outputs.version }} - id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: | supabase/realtime @@ -84,38 +105,34 @@ jobs: type=raw,value=v${{ needs.release.outputs.version }}_${{ env.arch }} type=raw,value=latest_${{ env.arch }} - - uses: docker/login-action@v2 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - uses: docker/setup-buildx-action@v2 - with: - driver: docker - driver-opts: | - image=moby/buildkit:master - network=host + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1.4.0 - id: build - uses: docker/build-push-action@v3 + uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} platforms: linux/${{ env.arch }} - no-cache: true merge_manifest: needs: [release, docker_x86_release, docker_arm_release] - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read packages: write id-token: write steps: - - uses: docker/setup-buildx-action@v2 + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1.4.0 - - uses: docker/login-action@v2 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -133,37 +150,45 @@ jobs: supabase/realtime@${{ needs.docker_arm_release.outputs.image_digest }} - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v1 + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1 with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 - name: Login to ECR - uses: docker/login-action@v2 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: public.ecr.aws - name: Login to GHCR - uses: docker/login-action@v2 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Mirror to ECR - uses: akhilerm/tag-push-action@v2.0.0 + uses: akhilerm/tag-push-action@f35ff2cb99d407368b5c727adbcc14a2ed81d509 # v2.2.0 with: src: docker.io/supabase/realtime:v${{ needs.release.outputs.version }} dst: | public.ecr.aws/supabase/realtime:v${{ needs.release.outputs.version }} ghcr.io/supabase/realtime:v${{ needs.release.outputs.version }} + dispatch-deploy: + needs: [release, merge_manifest] + if: needs.release.outputs.published == 'true' + uses: ./.github/workflows/dispatch_deploy.yml + secrets: inherit + with: + version: ${{ needs.release.outputs.version }} + update-branch-name: needs: [release, docker_x86_release, docker_arm_release, merge_manifest] - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout branch - uses: actions/checkout@v2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: refs/heads/main diff --git a/.github/workflows/prod_linter.yml b/.github/workflows/prod_linter.yml index 6af6b5ed8..e793d9bb6 100644 --- a/.github/workflows/prod_linter.yml +++ b/.github/workflows/prod_linter.yml @@ -7,18 +7,18 @@ on: jobs: format: name: Formatting Checks - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup elixir id: beam - uses: erlef/setup-beam@v1 + uses: erlef/setup-beam@ee09b1e59bb240681c382eb1f0abc6a04af72764 # v1.23.0 with: - otp-version: 26.x # Define the OTP version [required] - elixir-version: 1.16.x # Define the elixir version [required] + otp-version: 27.x # Define the OTP version [required] + elixir-version: 1.18.x # Define the elixir version [required] - name: Cache Mix - uses: actions/cache@v4 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: deps key: ${{ runner.os }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} @@ -28,7 +28,7 @@ jobs: - name: Install dependencies run: mix deps.get - name: Set up Postgres - run: docker compose -f docker-compose.dbs.yml up -d + run: docker compose -f compose.dbs.yml up -d - name: Run database migrations run: mix ecto.migrate - name: Run format check @@ -36,7 +36,7 @@ jobs: - name: Credo checks run: mix credo --strict --mute-exit-status - name: Retrieve PLT Cache - uses: actions/cache@v4 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 id: plt-cache with: path: priv/plts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5d3818814..9fd393c2b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,71 +8,120 @@ on: - "priv/**" - "assets/**" - "rel/**" + - "native/**" + - "dev/**" - "mix.exs" + - "compose.dbs.yml" + - "mix.lock" - "Dockerfile" - "run.sh" + - ".github/workflows/tests.yml" push: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + MIX_ENV: test + jobs: tests: - name: Tests - runs-on: ubuntu-latest + name: Tests (${{ matrix.postgres }} - Partition ${{ matrix.partition }}) + runs-on: blacksmith-8vcpu-ubuntu-2404 + env: + POSTGRES_IMAGE: ${{ matrix.postgres_image }} + DB_USER_REALTIME: ${{ matrix.db_user_realtime }} + strategy: + fail-fast: false + matrix: + partition: [1, 2, 3, 4] + postgres: [pg14, pg15, pg15_latest, pg17] + include: + - postgres: pg14 + postgres_image: supabase/postgres:14.1.0.82 + db_user_realtime: supabase_admin + # test before supautils.policy_grants added all necessary grants + - postgres: pg15 + postgres_image: supabase/postgres:15.1.0.1 + db_user_realtime: supabase_admin + - postgres: pg15_latest + postgres_image: supabase/postgres:15.14.1.129 + db_user_realtime: supabase_realtime_admin + - postgres: pg17 + postgres_image: supabase/postgres:17.6.1.127 + db_user_realtime: supabase_realtime_admin steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup elixir id: beam - uses: erlef/setup-beam@v1 + uses: erlef/setup-beam@ee09b1e59bb240681c382eb1f0abc6a04af72764 # v1.23.0 with: otp-version: 27.x # Define the OTP version [required] - elixir-version: 1.17.x # Define the elixir version [required] + elixir-version: 1.18.x # Define the elixir version [required] - name: Cache Mix - uses: actions/cache@v4 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: - path: deps - key: ${{ runner.os }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} + path: | + deps + _build + priv/native + key: ${{ github.workflow }}-${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }} restore-keys: | - ${{ runner.os }}-mix- + ${{ github.workflow }}-${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}- + + - name: Cache Docker images + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + id: docker-cache + with: + path: /tmp/docker-images + key: docker-images-zstd-${{ env.POSTGRES_IMAGE }} + - name: Load Docker images from cache + if: steps.docker-cache.outputs.cache-hit == 'true' + run: zstd -d --stdout /tmp/docker-images/postgres.tar.zst | docker image load + - name: Pull and save Docker images + if: steps.docker-cache.outputs.cache-hit != 'true' + run: | + docker pull ${{ env.POSTGRES_IMAGE }} + mkdir -p /tmp/docker-images + docker image save ${{ env.POSTGRES_IMAGE }} | zstd -T0 -o /tmp/docker-images/postgres.tar.zst - name: Install dependencies run: mix deps.get - name: Set up Postgres - run: docker compose -f docker-compose.dbs.yml up -d - - name: Run main database migrations - run: mix ecto.migrate --log-migrator-sql - - name: Run database tenant migrations - run: mix ecto.migrate --migrations-path lib/realtime/tenants/repo/migrations - - name: Run format check - run: mix format --check-formatted - - name: Credo checks - run: mix credo - - name: Run hex audit - run: mix hex.audit - - name: Run mix_audit - run: mix deps.audit - - name: Run sobelow - run: mix sobelow --config .sobelow-conf - - name: Retrieve PLT Cache - uses: actions/cache@v4 - id: plt-cache - with: - path: priv/plts - key: ${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-plts-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} - - name: Create PLTs - if: steps.plt-cache.outputs.cache-hit != 'true' - run: | - mkdir -p priv/plts - mix dialyzer.build - - name: Run dialyzer - run: mix dialyzer - - name: Run dev seeds - run: DB_ENC_KEY="1234567890123456" mix ecto.setup + run: docker compose -f compose.dbs.yml up -d --wait - name: Start epmd run: epmd -daemon - name: Run tests - run: MIX_ENV=test MAX_CASES=3 mix coveralls.github - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: MIX_TEST_PARTITION=${{ matrix.partition }} mix coveralls.lcov --partitions 4 + - name: Upload coverage artifact + if: matrix.postgres == 'pg17' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage-partition-${{ matrix.partition }} + path: cover/lcov.info + + coverage: + name: Merge Coverage + needs: tests + if: ${{ needs.tests.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Download all coverage artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: coverage-partition-* + path: coverage + - name: Upload merged coverage to Coveralls + uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + files: coverage/coverage-partition-1/lcov.info coverage/coverage-partition-2/lcov.info coverage/coverage-partition-3/lcov.info coverage/coverage-partition-4/lcov.info diff --git a/.github/workflows/update-supabase-js.yml b/.github/workflows/update-supabase-js.yml new file mode 100644 index 000000000..d64354243 --- /dev/null +++ b/.github/workflows/update-supabase-js.yml @@ -0,0 +1,119 @@ +name: Update @supabase/supabase-js + +on: + workflow_dispatch: + inputs: + version: + description: "Version to update to" + required: true + type: string + source: + description: "Source of the update" + required: false + type: string + default: "manual" + +permissions: + pull-requests: read + contents: read + +jobs: + update-supabase-js: + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-supabase-update-${{ inputs.version }} + cancel-in-progress: false + + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: assets/package-lock.json + + - name: Update @supabase/supabase-js in assets + working-directory: assets + env: + VERSION: ${{ inputs.version }} + run: | + npm pkg set "dependencies.@supabase/supabase-js=${VERSION}" + npm install --package-lock-only --ignore-scripts + + - name: Update @supabase/supabase-js in test/e2e + working-directory: test/e2e + env: + VERSION: ${{ inputs.version }} + run: npm pkg set "dependencies.@supabase/supabase-js=${VERSION}" + + - name: Setup Bun + if: ${{ hashFiles('test/e2e/bun.lock') != '' }} + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.x" + + - name: Update test/e2e lockfile + if: ${{ hashFiles('test/e2e/bun.lock') != '' }} + working-directory: test/e2e + run: bun install --lockfile-only + + - name: Fetch release notes + env: + VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ github.token }} + run: | + CURRENT_FULL=$(git show HEAD:assets/package.json | jq -r '.dependencies["@supabase/supabase-js"] // ""' | grep -oP '[\d]+\.[\d]+\.[\d]+(-[\w.]+)?') + CURRENT_BASE=$(echo "$CURRENT_FULL" | grep -oP '[\d]+\.[\d]+\.[\d]+') + [[ "$CURRENT_FULL" == *"-"* ]] && INCLUDE_CURRENT=true || INCLUDE_CURRENT=false + [[ "$VERSION" == *"-"* ]] && STABLE_ONLY=false || STABLE_ONLY=true + RELEASES=$(gh api "repos/supabase/supabase-js/releases?per_page=100") + RELEASE_NOTES=$(echo "$RELEASES" | jq -r \ + --arg current "v${CURRENT_BASE}" \ + --arg new "v${VERSION}" \ + --argjson stable_only "$STABLE_ONLY" \ + --argjson include_current "$INCLUDE_CURRENT" \ + '[.[] | select(.draft == false) | select(if $stable_only then .prerelease == false else true end)] | + (map(.tag_name) | index($new)) as $start | + (map(.tag_name) | index($current)) as $end | + ($end | if . != null and $include_current then . + 1 else . end) as $end_adj | + if $start == null then ["Target version not found in last 100 releases."] + elif $end_adj != null and $start >= $end_adj then ["Downgrade — no release notes available."] + else [.[$start:$end_adj][] | "## " + .tag_name + "\n\n" + (.body // "No release notes.")] + end | .[]') + { + echo "RELEASE_NOTES<> "$GITHUB_ENV" + + - name: Generate token + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + with: + app-id: ${{ secrets.GH_AUTOFIX_APP_ID }} + private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }} + + - name: Create pull request + uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0 + with: + token: ${{ steps.app-token.outputs.token }} + commit-message: "chore: update @supabase/supabase-js to v${{ inputs.version }}" + title: "chore: update @supabase/supabase-js to v${{ inputs.version }}" + body: | + This PR updates `@supabase/supabase-js` to v${{ inputs.version }}. + + **Source**: ${{ inputs.source }} + + --- + + ## Release Notes + + ${{ env.RELEASE_NOTES }} + + This PR was created automatically. + branch: "gha/auto-update-supabase-js-v${{ inputs.version }}" + base: ${{ github.event.repository.default_branch }} diff --git a/.github/workflows/update-tenant-db-catalog.yml b/.github/workflows/update-tenant-db-catalog.yml new file mode 100644 index 000000000..a7a882e06 --- /dev/null +++ b/.github/workflows/update-tenant-db-catalog.yml @@ -0,0 +1,107 @@ +name: Update Tenant DB Catalog + +on: + pull_request: + paths: + - "lib/realtime/tenants/repo/migrations/**" + - "lib/realtime/tenants/migrations.ex" + - "lib/mix/tasks/realtime.export_tenant_db_catalog.ex" + - "priv/repo/tenant_db_catalog_*.json" + - "mix.lock" + - "mise.toml" + - "compose.dbs.yml" + - ".github/workflows/update-tenant-db-catalog.yml" + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + POSTGRES_IMAGE: supabase/postgres:17.6.1.127 + +jobs: + update-tenant-db-catalog: + name: Maybe regenerate tenant catalog + runs-on: blacksmith-2vcpu-ubuntu-2404 + if: github.event.pull_request.head.repo.full_name == github.repository + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.GH_APP_MANAGER_ID }} + private-key: ${{ secrets.GH_APP_MANAGER_PRIVATE_KEY }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Setup mise + uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 + + - name: Cache Mix + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + deps + _build + key: ${{ github.workflow }}-${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ github.workflow }}-${{ runner.os }}-mix- + + - name: Install dependencies + run: mix deps.get + + - name: Cache Docker images + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + id: docker-cache + with: + path: /tmp/docker-images + key: docker-images-zstd-${{ env.POSTGRES_IMAGE }} + + - name: Load Docker images from cache + if: steps.docker-cache.outputs.cache-hit == 'true' + run: zstd -d --stdout /tmp/docker-images/postgres.tar.zst | docker image load + + - name: Pull and save Docker images + if: steps.docker-cache.outputs.cache-hit != 'true' + run: | + docker pull ${{ env.POSTGRES_IMAGE }} + mkdir -p /tmp/docker-images + docker image save ${{ env.POSTGRES_IMAGE }} | zstd -T0 -o /tmp/docker-images/postgres.tar.zst + + - name: Start Postgres + run: docker compose -f compose.dbs.yml up -d --wait + + - name: Set up realtime DB and migrate tenant DB + run: mix ecto.setup + + - name: Export catalog snapshot + run: mix realtime.export_tenant_db_catalog + + - name: Check if catalog changed + id: check-changes + run: | + git add priv/repo/tenant_db_catalog_17.json + if git diff --cached --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit and push catalog + if: steps.check-changes.outputs.changed == 'true' + env: + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "chore: update tenant_db_catalog_17.json" + git push origin "HEAD:$PR_HEAD_REF" diff --git a/.github/workflows/version_updated.yml b/.github/workflows/version_updated.yml deleted file mode 100644 index 6125f1ff7..000000000 --- a/.github/workflows/version_updated.yml +++ /dev/null @@ -1,40 +0,0 @@ -on: - pull_request: - branches: - - "main" - paths: - - "lib/**" - - "config/**" - - "priv/**" - - "assets/**" - - "rel/**" - - "mix.exs" - - "Dockerfile" - - "run.sh" - -permissions: - contents: read - -name: Default Checks - -jobs: - versions_updated: - name: Versions Updated - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Verify Versions Updated - uses: step-security/changed-files@v45 - id: verify_changed_files - with: - files: | - mix.exs - - - name: Fail Unless Versions Updated - id: fail_unless_changed - if: steps.verify_changed_files.outputs.any_changed == 'false' - run: | - echo "::error ::Please update the mix.exs version" - exit 1 diff --git a/.gitignore b/.gitignore index fec4b85ab..fb82191cd 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ node_modules config/prod.secret.exs demo/.env .lexical -.vscode \ No newline at end of file +.vscode + +# Local +mise.local.toml diff --git a/.releaserc b/.releaserc index 15f87c23c..b031def8f 100644 --- a/.releaserc +++ b/.releaserc @@ -1,10 +1,21 @@ { - "branches": [ - "main" - ], + "branches": ["main"], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", + [ + "@semantic-release/exec", + { + "prepareCmd": "sed -i 's/version: \"[^\"]*\"/version: \"${nextRelease.version}\"/' mix.exs" + } + ], + [ + "@semantic-release/git", + { + "assets": ["mix.exs"], + "message": "chore(release): ${nextRelease.version} [skip ci]" + } + ], "@semantic-release/github" ] -} \ No newline at end of file +} diff --git a/.tool-versions b/.tool-versions index 35b41200e..70a472465 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -elixir 1.17.3 -nodejs 18.13.0 -erlang 27.1 +elixir 1.18.4-otp-27 +nodejs 24 +erlang 27 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..e140b1012 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing + +Thanks for contributing to Realtime! We'd love to have you contribute and here’s some resources and guidance to help you get started: + +## Getting started + +Before you open a pull request, read the [code of conduct](https://github.com/supabase/.github/blob/main/CODE_OF_CONDUCT.md) and +use the [DEVELOPERS.md](DEVELOPERS.md) for local setup and day-to-day development. + +## Issues + +If you found a bug, open an issue using the [bug report template](https://github.com/supabase/realtime/issues/new?template=1.Bug_report.md). + +Before opening a new issue: + +- Search [existing issues](https://github.com/supabase/realtime/issues) first. +- Please use one of the issue templates and provide as much detailed context as possible. + +## Feature requests + +If you want to propose a new feature, please open a [GitHub Discussion](https://github.com/orgs/supabase/discussions/new?category=feature-requests) first. +That gives maintainers and other contributors a chance to discuss the shape of the change before you spend time building it. + +## Pull requests + +All changes go through GitHub pull requests and require review. + +Before opening a PR: + +- Make sure there is an issue or discussion covering the work. +- Check whether someone has already opened a PR for the same problem. +- Link the related issue or discussion in your PR description. + +We recommend the following practices to streamline the review process: + +- Use [Conventional Commits](https://www.conventionalcommits.org) for your commit messages. +- Add or update tests to prove the change is correct, and make sure those tests and related tests pass. +- Review the code before requesting reviews, especially for AI-assisted changes. + +Examples: + +- Good: `fix: properly clean up subscriptions when oids changed` +- Good: `feat: split gen rpc pools for calls vs casts` +- Bad: `fix stuff` +- Bad: `updates` + +## License + +By contributing to Realtime, you agree that your contributions will be licensed under this repository's license. diff --git a/DEVELOPERS.md b/DEVELOPERS.md new file mode 100644 index 000000000..68094fcd5 --- /dev/null +++ b/DEVELOPERS.md @@ -0,0 +1,176 @@ +# Developing Supabase Realtime + +## Table of contents + +- [Client](#client) + - [Client libraries](#client-libraries) +- [Server](#server) + - [Server Setup](#server-setup) + - [Tenants](#tenants) + - [WebSocket](#websocket) + - [WebSocket URL](#websocket-url) + - [WebSocket Connection Authorization](#websocket-connection-authorization) + - [Telemetry events](#telemetry-events) + +## Client + +### Client libraries + +| Language | Source | Package | +| ------------ | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| JavaScript | [supabase-js/realtime-js](https://github.com/supabase/supabase-js/tree/master/packages/core/realtime-js) | [@supabase/realtime-js](https://www.npmjs.com/package/@supabase/realtime-js) | +| Flutter/Dart | [supabase-flutter/realtime_client](https://github.com/supabase/supabase-flutter/tree/main/packages/realtime_client) | [realtime_client](https://pub.dev/packages/realtime_client) | +| Python | [supabase-py/realtime](https://github.com/supabase/supabase-py/tree/main/src/realtime) | [realtime](https://pypi.org/project/realtime) | +| Swift | [supabase-swift/Realtime](https://github.com/supabase/supabase-swift/tree/main/Sources/Realtime) | [supabase-swift](https://swiftpackageindex.com/supabase/supabase-swift) | + +## Server + +### Server Setup + +Pre-requisites: + +- [mise](https://mise.jdx.dev) installed and [activated](https://mise.jdx.dev/cli/activate.html) so it can load env vars in your shell. + +Optional but recommended: + +- [Docker](https://www.docker.com/get-started) +- [Docker Compose](https://docs.docker.com/compose/install) 2.20.0 or later + +To run the server locally, start the Postgres databases based on [supabase/postgres](https://github.com/supabase/postgres) that contains all plugins and config required by Realtime: + +```bash +mise run db-start +``` + +With the database running, setup deps and start the server: + +```bash +mix setup +mise run dev +``` + +To start another node in the local cluster (optional): + +```bash +mise run dev-orange +``` + +Once the server is up, open [http://localhost:4000/status](http://localhost:4000/status) to check the services are running. + +> **Note** +> To run the whole stack in containers instead of installing Elixir locally: + +```bash +mise run realtime-start +``` + +Useful cleanup commands: + +```bash +mise run db-rm +mise run realtime-rm +``` + +To see all available tasks: + +```bash +mise task ls +``` + +### Tenants + +A tenant has already been added on your behalf. You can confirm this by checking the `_realtime.tenants` and `_realtime.extensions` tables inside the database. + +> **Note** +> Supabase runs Realtime in production with a separate database that keeps track of all tenants. For local development, the compose setup creates the `_realtime` schema for you. + +You can add your own by making a `POST` request to the server. You must change both `name` and `external_id` while you may update other values as you see fit: + +```bash + curl -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiIiLCJpYXQiOjE2NzEyMzc4NzMsImV4cCI6MTcwMjc3Mzk5MywiYXVkIjoiIiwic3ViIjoiIn0._ARixa2KFUVsKBf3UGR90qKLCpGjxhKcXY4akVbmeNQ' \ + -d $'{ + "tenant" : { + "name": "realtime-dev", + "external_id": "realtime-dev", + "jwt_secret": "a1d99c8b-91b6-47b2-8f3c-aa7d9a9ad20f", + "extensions": [ + { + "type": "postgres_cdc_rls", + "settings": { + "db_name": "postgres", + "db_host": "host.docker.internal", + "db_user": "postgres", + "db_password": "postgres", + "db_port": "5432", + "region": "us-west-1", + "poll_interval_ms": 100, + "poll_max_record_bytes": 1048576, + "ssl_enforced": false + } + } + ] + } + }' \ + http://localhost:4000/api/tenants +``` + +> **Note** +> The `Authorization` token is signed with the secret set by `API_JWT_SECRET` in the local compose environment. + +If you want to listen to Postgres changes, you can create a table and then add the table to the `supabase_realtime` publication: + +```sql +create table test ( + id serial primary key +); + +alter publication supabase_realtime add table test; +``` + +You can start playing around with Broadcast, Presence, and Postgres Changes features either with the client libs (e.g. `@supabase/realtime-js`), or use the built in Realtime Inspector on localhost, `http://localhost:4000/inspector/new` (make sure the port is correct for your development environment). + +The WebSocket URL must contain the subdomain, `external_id` of the tenant on the `_realtime.tenants` table, and the token must be signed with the `jwt_secret` that was inserted along with the tenant. + +If you're using the default tenant, the URL is `ws://realtime-dev.localhost:4000/socket` (make sure the port is correct for your development environment), and you can use `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDMwMjgwODcsInJvbGUiOiJwb3N0Z3JlcyJ9.tz_XJ89gd6bN8MBpCl7afvPrZiBH6RB65iA1FadPT3Y` for the token. The token must have `exp` and `role` (database role) keys. + +### WebSocket + +#### WebSocket URL + +The WebSocket URL is in the following format for local development: `ws://[external_id].localhost:4000/socket/websocket` + +If you're using Supabase's hosted Realtime in production the URL is `wss://[project-ref].supabase.co/realtime/v1/websocket?apikey=[anon-token]&log_level=info&vsn=1.0.0"` + +#### WebSocket Connection Authorization + +WebSocket connections are authorized via symmetric JWT verification. Only supports JWTs signed with the following algorithms: + +- HS256 +- HS384 +- HS512 + +Verify JWT claims by setting JWT_CLAIM_VALIDATORS: + +> e.g. {'iss': 'Issuer', 'nbf': 1610078130} +> +> Then JWT's "iss" value must equal "Issuer" and "nbf" value must equal 1610078130. + +**Note:** + +> JWT expiration is checked automatically. `exp` and `role` (database role) keys are mandatory. + +**Authorizing Client Connection**: You can pass in the JWT by following the instructions under the Realtime client lib. For example, refer to the **Usage** section in the [@supabase/realtime-js](https://github.com/supabase/realtime-js) client library. + +### Telemetry events + +Realtime emits events through `:telemetry`. Event names follow a few rules so they map cleanly onto metrics and stay consistent: + +- Prefix every event with `:realtime` and group preferably by concern, otherwise by module. Tenant migrations use `[:realtime, :tenants, :migrations, ...]`, channels use `[:realtime, :channel, ...]`, and the Postgres CDC workers use `[:realtime, :replication, :poller, ...]` and `[:realtime, :subscriptions, :manager, ...]`. +- Give anything with a lifetime a span: `:start`, then `:stop` or `:exception`. Put the duration in measurements and the cause in metadata. Tenant migrations emit `[:realtime, :tenants, :migrations, :start | :stop | :exception]`, and the replication poller does the same for its run and for its `:query` and `:prepare` operations. +- When outcomes share a cause, emit one event and tell them apart with a `reason` in metadata instead of adding an event name per outcome. For example, skipped Postgres changes use `[:realtime, :replication, :poller, :changes, :skip]` with `reason: :rate_limited`. +- Put `tenant` in metadata for per-tenant events; connections, authorization checks, and migrations all do. Extra context such as `reason` or `db_pid` also goes in metadata and stays out of metrics unless a metric opts into it as a tag. +- A metric name is the event path joined with `_`, so pick segments that read well as one: `[:realtime, :tenants, :payload, :size]` becomes `realtime_tenants_payload_size`. + +The metrics built on these events are listed in [OBSERVABILITY_METRICS.md](./OBSERVABILITY_METRICS.md). diff --git a/Dockerfile b/Dockerfile index 33da5983f..ebe3ab959 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,45 @@ -ARG ELIXIR_VERSION=1.17.3 -ARG OTP_VERSION=27.1.2 -ARG DEBIAN_VERSION=bookworm-20241111-slim +ARG ELIXIR_VERSION=1.18 +ARG OTP_VERSION=27.3 +ARG DEBIAN_VERSION=bookworm-20250929-slim ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" - -FROM ${BUILDER_IMAGE} as builder +# @supabase/pg-delta@1.0.0-alpha.30 +ARG PG_DELTA_COMMIT=d706336a6772318e92db419eda5a5ea51123510e + +FROM debian:${DEBIAN_VERSION} AS pgdelta-builder +ARG PG_DELTA_COMMIT +ARG BUN_VERSION=1.3.14 + +RUN set -eux; \ + apt-get update -y; \ + apt-get install -y --no-install-recommends curl ca-certificates unzip xz-utils; \ + curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"; \ + export PATH="/root/.bun/bin:${PATH}"; \ + mkdir -p /build && cd /build; \ + curl -fsSL "https://github.com/supabase/pg-toolbelt/archive/${PG_DELTA_COMMIT}.tar.gz" \ + | tar xz --strip-components=1; \ + bun install --frozen-lockfile --ignore-scripts; \ + cd /build/packages/pg-delta; \ + bun build --compile src/cli/bin/cli.ts --outfile /tmp/pgdelta; \ + /tmp/pgdelta --help > /dev/null; \ + xz -9 -e -T0 -c /tmp/pgdelta > /tmp/pgdelta.xz; \ + cd / && find build -path '*/@libpg-query/parser/wasm/libpg-query.wasm' \ + | tar -czf /tmp/libpg-query.tar.gz -T -; \ + printf '%s\n' \ + '#!/bin/sh' \ + 'set -e' \ + 'BIN=/app/.pgdelta-cache/pgdelta' \ + 'if [ ! -x "$BIN" ]; then' \ + ' mkdir -p "$(dirname "$BIN")"' \ + ' xz -dcT0 /usr/local/share/pgdelta/pgdelta.xz > "$BIN"' \ + ' chmod +x "$BIN"' \ + 'fi' \ + 'exec "$BIN" "$@"' \ + > /tmp/pgdelta-wrapper; \ + chmod +x /tmp/pgdelta-wrapper; \ + rm -rf /tmp/pgdelta /build /root/.bun /var/lib/apt/lists/* + +FROM ${BUILDER_IMAGE} AS builder ENV MIX_ENV="prod" @@ -19,7 +54,7 @@ RUN set -uex; \ mkdir -p /etc/apt/keyrings; \ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; \ - NODE_MAJOR=18; \ + NODE_MAJOR=24; \ echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" \ > /etc/apt/sources.list.d/nodesource.list; \ apt-get -qy update; \ @@ -34,6 +69,7 @@ RUN mix local.hex --force && \ # install mix dependencies COPY mix.exs mix.lock ./ +COPY forum forum RUN mix deps.get --only $MIX_ENV RUN mkdir config @@ -65,24 +101,30 @@ RUN mix release FROM ${RUNNER_IMAGE} ARG SLOT_NAME_SUFFIX -ENV SLOT_NAME_SUFFIX="${SLOT_NAME_SUFFIX}" -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US:en -ENV LC_ALL en_US.UTF-8 -ENV MIX_ENV="prod" -ENV ECTO_IPV6 true -ENV ERL_AFLAGS "-proto_dist inet6_tcp" +ENV SLOT_NAME_SUFFIX="${SLOT_NAME_SUFFIX}" \ + LANG="en_US.UTF-8" \ + LANGUAGE="en_US:en" \ + LC_ALL="en_US.UTF-8" \ + MIX_ENV="prod" \ + ECTO_IPV6="true" \ + ERL_AFLAGS="-proto_dist inet6_tcp" RUN apt-get update -y && \ - apt-get install -y libstdc++6 openssl libncurses5 locales iptables sudo tini curl awscli jq && \ - apt-get clean && rm -f /var/lib/apt/lists/*_* + apt-get install -y --no-install-recommends \ + libstdc++6 openssl libncurses5 locales iptables sudo tini curl awscli jq xz-utils && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +COPY --from=pgdelta-builder /tmp/pgdelta.xz /usr/local/share/pgdelta/pgdelta.xz +COPY --from=pgdelta-builder /tmp/pgdelta-wrapper /usr/local/bin/pgdelta +COPY --from=pgdelta-builder /tmp/libpg-query.tar.gz /tmp/libpg-query.tar.gz +RUN tar -C / -xzf /tmp/libpg-query.tar.gz && rm /tmp/libpg-query.tar.gz # Set the locale RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen WORKDIR "/app" -RUN chown nobody /app +RUN chown nobody /app && mkdir -p /app/.pgdelta-cache && chown nobody /app/.pgdelta-cache COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/realtime ./ COPY run.sh run.sh diff --git a/ENVS.md b/ENVS.md new file mode 100644 index 000000000..b99a7545f --- /dev/null +++ b/ENVS.md @@ -0,0 +1,129 @@ +# Environment Variables + +Most of these variables are used in [runtime.exs](https://github.com/supabase/realtime/blob/main/config/runtime.exs), check it out for more details and usage. + +> **Tip** +> Use a [mise.local.toml](https://mise.jdx.dev/configuration.html) file to set values in your local environment (gitignored). + +| Variable | Type | Description | +| ----------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PORT | number | Port which you can connect your client/listeners | +| DB_HOST | string | Database host URL | +| DB_PORT | number | Database port | +| DB_USER | string | Database user. Used for tenant migrations, which require a superuser. | +| DB_PASSWORD | string | Database password (for `DB_USER`). | +| DB_USER_REALTIME | string | Least-privilege database user (`supabase_realtime_admin`) used for runtime connections. Falls back to `DB_USER` when unset. | +| DB_PASS_REALTIME | string | Password for `DB_USER_REALTIME`. | +| DB_NAME | string | Postgres database name | +| DB_ENC_KEY | string | Key used to encrypt sensitive fields in \_realtime.tenants and \_realtime.extensions tables. Recommended: 16 characters. | +| DB_AFTER_CONNECT_QUERY | string | Query that is run after server connects to database. | +| DB_IP_VERSION | string | Sets the IP Version to be used for database connections. Allowed values are "ipv6" and "ipv4". If none are set we will try to infer the correct version | +| REALTIME_IP_VERSION | string | Sets the IP Version for the HTTP listener. Allowed values are "ipv6" and "ipv4". If none are set we will try to detect IPv6 support and fall back to IPv4. | +| DB_SSL | boolean | Whether or not the connection will be set-up using SSL | +| DB_SSL_CA_CERT | string | Filepath to a CA trust store (e.g.: /etc/cacert.pem). If defined it enables server certificate verification | +| API_JWT_SECRET | string | Secret that is used to sign tokens used to manage tenants and their extensions via HTTP requests. | +| API_JWT_SECRET_NEXT | string | Optional. When set, tokens signed with this secret are also accepted, enabling zero-downtime rotation of API_JWT_SECRET: stage the new secret here, switch token signers over, then promote it to API_JWT_SECRET and unset this variable. | +| API_TOKEN_BLOCKLIST | string | Comma-separated list of tokens blocked for tenant management API access. Defaults to an empty list. | +| SECRET_KEY_BASE | string | Secret used by the server to sign cookies. Recommended: 64 characters. | +| ERL_AFLAGS | string | Set to either "-proto_dist inet_tcp" or "-proto_dist inet6_tcp" depending on whether or not your network uses IPv4 or IPv6, respectively. | +| APP_NAME | string | A name of the server. | +| CLUSTER_STRATEGIES | string | Comma-separated cluster backends to enable. Supported values are `EPMD`, `DNS`, and `POSTGRES`. Defaults to `EPMD` outside production and `POSTGRES` in production. | +| DNS_NODES | string | Node name used when running server in a cluster. | +| DB_MASTER_REGION | string | Overrides the primary region used for region-aware routing and tenant placement. If not set, Realtime uses the current `REGION`. | +| MAX_CONNECTIONS | string | Set the soft maximum for WebSocket connections. Defaults to '16384'. | +| MAX_HEADER_LENGTH | string | Set the maximum header length for connections (in bytes). Defaults to '4096'. | +| HTTP_DYNAMIC_BUFFER_MIN | integer | Minimum buffer size in bytes for HTTP connections (Cowboy dynamic buffer). Must be set together with HTTP_DYNAMIC_BUFFER_MAX; omit both to use Cowboy's default behavior. | +| HTTP_DYNAMIC_BUFFER_MAX | integer | Maximum buffer size in bytes for HTTP connections (Cowboy dynamic buffer). Must be set together with HTTP_DYNAMIC_BUFFER_MIN; omit both to use Cowboy's default behavior. | +| NUM_ACCEPTORS | string | Set the number of server processes that will relay incoming WebSocket connection requests. Defaults to '100'. | +| DB_QUEUE_TARGET | string | Maximum time to wait for a connection from the pool. Defaults to '5000' or 5 seconds. See for more info: [DBConnection](https://hexdocs.pm/db_connection/DBConnection.html#start_link/2-queue-config). | +| DB_QUEUE_INTERVAL | string | Interval to wait to check if all connections were checked out under DB_QUEUE_TARGET. If all connections surpassed the target during this interval than the target is doubled. Defaults to '5000' or 5 seconds. See for more info: [DBConnection](https://hexdocs.pm/db_connection/DBConnection.html#start_link/2-queue-config). | +| DB_POOL_SIZE | string | Sets the number of connections in the database pool. Defaults to '5'. | +| DB_REPLICA_HOST | string | Hostname for the replica database. If set, enables the main replica connection pool. | +| DB_HOST_REPLICA_FRA | string | Hostname for the FRA replica database used by the legacy replica repos. Defaults to `DB_HOST`. | +| DB_HOST_REPLICA_IAD | string | Hostname for the IAD replica database used by the legacy replica repos. Defaults to `DB_HOST`. | +| DB_HOST_REPLICA_SIN | string | Hostname for the SIN replica database used by the legacy replica repos. Defaults to `DB_HOST`. | +| DB_HOST_REPLICA_SJC | string | Hostname for the SJC replica database used by the legacy replica repos. Defaults to `DB_HOST`. | +| DB_REPLICA_POOL_SIZE | string | Sets the number of connections in the replica database pool. Defaults to '5'. | +| SLOT_NAME_SUFFIX | string | This is appended to the replication slot which allows making a custom slot name. May contain lowercase letters, numbers, and the underscore character. Together with the default `supabase_realtime_replication_slot`, slot name should be up to 64 characters long. | +| TENANT_CACHE_EXPIRATION_IN_MS | string | Set tenant cache TTL in milliseconds | +| TENANT_MAX_BYTES_PER_SECOND | string | The default value of maximum bytes per second that each tenant can support, used when creating a tenant for the first time. Defaults to '100_000'. | +| TENANT_MAX_CHANNELS_PER_CLIENT | string | The default value of maximum number of channels each tenant can support, used when creating a tenant for the first time. Defaults to '100'. | +| TENANT_MAX_CONCURRENT_USERS | string | The default value of maximum concurrent users per channel that each tenant can support, used when creating a tenant for the first time. Defaults to '200'. | +| TENANT_MAX_EVENTS_PER_SECOND | string | The default value of maximum events per second that each tenant can support, used when creating a tenant for the first time. Defaults to '100'. | +| TENANT_MAX_JOINS_PER_SECOND | string | The default value of maximum channel joins per second that each tenant can support, used when creating a tenant for the first time. Defaults to '100'. | +| CLIENT_PRESENCE_MAX_CALLS | number | Maximum number of presence calls allowed per client (per WebSocket connection) within the time window. Defaults to '5'. | +| CLIENT_PRESENCE_WINDOW_MS | number | Time window in milliseconds for per-client presence rate limiting. Defaults to '30000' (30 seconds). | +| SEED_SELF_HOST | boolean | Seeds the system with default tenant | +| SELF_HOST_TENANT_NAME | string | Tenant reference to be used for self host. Do keep in mind to use a URL compatible name | +| REGION | string | Region name for the current node. Used in logs, latency reporting, and region-aware routing. | +| LOG_LEVEL | string | Sets log level for Realtime logs. Defaults to info, supported levels are: info, emergency, alert, critical, error, warning, notice, debug | +| LOGS_ENGINE | string | Log backend selector. Set to `logflare` to enable the Logflare HTTP backend. If unset, standard logger output is used. | +| LOGFLARE_LOGGER_BACKEND_URL | string | Endpoint used by the Logflare logger backend. Defaults to `https://api.logflare.app`. | +| LOGFLARE_API_KEY | string | API key required when `LOGS_ENGINE=logflare`. | +| LOGFLARE_SOURCE_ID | string | Source ID required when `LOGS_ENGINE=logflare`. | +| DISABLE_HEALTHCHECK_LOGGING | boolean | Disables request logging for healthcheck endpoints (/healthcheck and /api/tenants/:tenant_id/health). Defaults to false. | +| RUN_JANITOR | boolean | Do you want to janitor tasks to run | +| JANITOR_SCHEDULE_TIMER_IN_MS | number | Time in ms to run the janitor task | +| JANITOR_SCHEDULE_RANDOMIZE | boolean | Adds a randomized value of minutes to the timer | +| JANITOR_RUN_AFTER_IN_MS | number | Tells system when to start janitor tasks after boot | +| JANITOR_MAX_CHILDREN | number | Maximum number of concurrent tasks working on janitor cleanup. Defaults to `5`. | +| JANITOR_CHILDREN_TIMEOUT | number | Timeout in milliseconds for each janitor child task. Defaults to `5000`. | +| JANITOR_CHUNK_SIZE | number | Number of tenants to process per chunk. Each chunk will be processed by a Task | +| MIGRATION_PARTITION_SLOTS | number | Number of dynamic supervisor partitions used by the migrations process | +| CONNECT_PARTITION_SLOTS | number | Number of dynamic supervisor partitions used by the Connect, ReplicationConnect processes | +| METRICS_CLEANER_SCHEDULE_TIMER_IN_MS | number | Time in ms to run the Metric Cleaner task | +| METRICS_RPC_TIMEOUT_IN_MS | number | Time in ms to wait for RPC call to fetch Metric per node | +| WEBSOCKET_MAX_HEAP_SIZE | number | Max number of bytes to be allocated as heap for the WebSocket transport process. If the limit is reached the process is brutally killed. Defaults to 50MB. | +| REQUEST_ID_BAGGAGE_KEY | string | OTEL Baggage key to be used as request id | +| JWT_CLAIM_VALIDATORS | string | JSON object of claim validators applied to incoming JWTs, for example `{"iss":"Issuer"}`. Defaults to `{}`. | +| METRICS_JWT_SECRET | string | Secret used to sign JWTs for metrics endpoints. Required outside tests. | +| METRICS_TOKEN_BLOCKLIST | string | Comma-separated list of tokens blocked from metrics access. Defaults to an empty list. | +| OTEL_SDK_DISABLED | boolean | Disable OpenTelemetry tracing completely when 'true' | +| OTEL_TRACES_EXPORTER | string | Possible values: `otlp` or `none`. See [https://github.com/open-telemetry/opentelemetry-erlang/tree/v1.4.0/apps#os-environment] for more details on how to configure the traces exporter. | +| OTEL_TRACES_SAMPLER | string | Default to `parentbased_always_on` . More info [here](https://opentelemetry.io/docs/languages/erlang/sampling/#environment-variables) | +| GEN_RPC_TCP_SERVER_PORT | number | Port served by `gen_rpc`. Must be secured just like the Erlang distribution port. Defaults to 5369 | +| GEN_RPC_TCP_CLIENT_PORT | number | `gen_rpc` connects to another node using this port. Most of the time it should be the same as GEN_RPC_TCP_SERVER_PORT. Defaults to 5369 | +| GEN_RPC_SSL_SERVER_PORT | number | Port served by `gen_rpc` secured with TLS. Must also define GEN_RPC_CERTFILE, GEN_RPC_KEYFILE and GEN_RPC_CACERTFILE. If this is defined then only TLS connections will be set-up. | +| GEN_RPC_SSL_CLIENT_PORT | number | `gen_rpc` connects to another node using this port. Most of the time it should be the same as GEN_RPC_SSL_SERVER_PORT. Defaults to 6369 | +| GEN_RPC_CERTFILE | string | Path to the public key in PEM format. Only needs to be provided if GEN_RPC_SSL_SERVER_PORT is defined | +| GEN_RPC_KEYFILE | string | Path to the private key in PEM format. Only needs to be provided if GEN_RPC_SSL_SERVER_PORT is defined | +| GEN_RPC_CACERTFILE | string | Path to the certificate authority public key in PEM format. Only needs to be provided if GEN_RPC_SSL_SERVER_PORT is defined | +| GEN_RPC_CONNECT_TIMEOUT_IN_MS | number | `gen_rpc` client connect timeout in milliseconds. Defaults to 10000. | +| GEN_RPC_SEND_TIMEOUT_IN_MS | number | `gen_rpc` client and server send timeout in milliseconds. Defaults to 10000. | +| GEN_RPC_SOCKET_IP | string | Interface which `gen_rpc` will bind to. Defaults to "0.0.0.0" (ipv4) which means that all interfaces are going to expose the `gen_rpc` port. | +| GEN_RPC_IPV6_ONLY | boolean | Configure `gen_rpc` to use IPv6 only. | +| GEN_RPC_MAX_BATCH_SIZE | integer | Configure `gen_rpc` to batch when possible RPC casts. Defaults to 0 | +| GEN_RPC_COMPRESS | integer | Configure `gen_rpc` to compress or not payloads. 0 means no compression and 9 max compression level. Defaults to 0. | +| GEN_RPC_COMPRESSION_THRESHOLD_IN_BYTES | integer | Configure `gen_rpc` to compress only above a certain threshold in bytes. Defaults to 1000. | +| GEN_RPC_SOCKET_BUFFER | integer | Size in bytes of the user-level software socket buffer used by `gen_rpc`. When not set, the system default is used. | +| GEN_RPC_SOCKET_RECEIVE_BUFFER | integer | Size in bytes of the TCP receive buffer used by `gen_rpc`. When not set, the system default is used. | +| GEN_RPC_SOCKET_SEND_BUFFER | integer | Size in bytes of the TCP send buffer used by `gen_rpc`. When not set, the system default is used. | +| MAX_GEN_RPC_CLIENTS | number | Max amount of `gen_rpc` TCP connections per node-to-node channel | +| MAX_GEN_RPC_CALL_CLIENTS | number | Max amount of `gen_rpc` TCP call connections per node-to-node channel. Defaults to `1`. | +| REBALANCE_CHECK_INTERVAL_IN_MS | number | Time in ms to check if process is in the right region | +| NODE_BALANCE_UPTIME_THRESHOLD_IN_MS | number | Minimum node uptime in ms before using load-aware node picker. Nodes below this threshold use random selection as their metrics are not yet reliable. Defaults to 5 minutes. | +| CONNECT_ERROR_BACKOFF_MS | number | Time in ms to wait before returning a connection error to the client. Applied to all WebSocket connection failures (invalid JWT, tenant not found, rate limits, etc.). Acts as a backoff to slow down reconnection storms. Defaults to 2000 (2 seconds). | +| CHANNEL_ERROR_BACKOFF_MS | number | Time in ms to wait before returning a channel join error to the client. Applied to all channel join failures (invalid JWT, rate limits, DB unavailable, etc.) including unexpected exceptions. Acts as a backoff to slow down reconnection storms. Defaults to 5000 (5 seconds). | +| BROADCAST_POOL_SIZE | number | Number of processes to relay Phoenix.PubSub messages across the cluster | +| PRESENCE_POOL_SIZE | number | Number of tracker processes for Presence feature. Defaults to 10. Higher values improve concurrency for presence tracking across many channels. | +| PRESENCE_BROADCAST_PERIOD_IN_MS | number | Interval in milliseconds to send presence delta broadcasts across the cluster. Defaults to 1500 (1.5 seconds). Lower values increase network traffic but reduce presence sync latency. | +| PRESENCE_PERMDOWN_PERIOD_IN_MS | number | Interval in milliseconds to flag a replica as permanently down and discard its state. Defaults to 1200000 (20 minutes). Must be greater than down_period. Higher values are more forgiving of temporary network issues but slower to clean up truly dead replicas. | +| POSTGRES_CDC_SCOPE_SHARDS | number | Number of dynamic supervisor partitions used by the Postgres CDC extension. Defaults to 5. | +| USERS_SCOPE_SHARDS | number | Number of dynamic supervisor partitions used by the Users extension. Defaults to 5. | +| PROM_POLL_RATE | number | Poll interval in milliseconds for PromEx metrics collection. Defaults to `5000`. | +| REGION_MAPPING | string | Custom mapping of platform regions to tenant regions. Must be a valid JSON object with string keys and values (e.g., `{"custom-region-1": "us-east-1", "eu-north-1": "eu-west-2"}`). If not provided, uses the default hardcoded region mapping. When set, only the specified mappings are used (no fallback to defaults). | +| AWS_EXECUTION_ENV | string | Used to detect whether Realtime is running on ECS Fargate. When unset, the platform defaults to Fly-specific behavior. | +| METRICS_PUSHER_ENABLED | boolean | Enable periodic push of Prometheus metrics. Defaults to 'false'. Requires METRICS_PUSHER_URL to be set. | +| METRICS_PUSHER_URL | string | Full URL endpoint to push metrics using Prometheus exposition format (e.g., 'https://example.com/api/v1/import/prometheus'). Required when METRICS_PUSHER_ENABLED is 'true'. | +| METRICS_PUSHER_USER | string | Username for Basic auth (RFC 7617) on metrics pushes. Defaults to 'realtime'. Used together with METRICS_PUSHER_AUTH to form the Authorization header as `Basic Base64("user:password")`. | +| METRICS_PUSHER_AUTH | string | Password for Basic auth (RFC 7617) on metrics pushes. Used together with METRICS_PUSHER_USER to form the Authorization header as `Basic Base64("user:password")`. If not set, requests will be sent without authorization. Keep this secret if used. | +| METRICS_PUSHER_INTERVAL_MS | number | Interval in milliseconds between metrics pushes. Defaults to '30000' (30 seconds). | +| METRICS_PUSHER_TIMEOUT_MS | number | HTTP request timeout in milliseconds for metrics push operations. Defaults to '15000' (15 seconds). | +| METRICS_PUSHER_COMPRESS | boolean | Enable gzip compression for metrics payloads. Defaults to 'true'. | +| METRICS_PUSHER_EXTRA_LABELS | string | Comma-separated list of `key=value` pairs appended as `extra_label` query parameters on each metrics push (e.g., `region=us-east-1,env=prod`). Useful for label injection supported by systems like VictoriaMetrics. If not set, no extra labels are added. | +| DASHBOARD_AUTH | string | Authentication method for the admin dashboard (`/admin`). Accepted values: `basic_auth` (default) or `zta`. When `basic_auth`, `DASHBOARD_USER` and `DASHBOARD_PASSWORD` are required. When `zta`, `CF_TEAM_DOMAIN` is required. | +| DASHBOARD_USER | string | Username for admin dashboard basic auth. Required when `DASHBOARD_AUTH` is `basic_auth`. | +| DASHBOARD_PASSWORD | string | Password for admin dashboard basic auth. Required when `DASHBOARD_AUTH` is `basic_auth`. | +| CF_TEAM_DOMAIN | string | Cloudflare Zero Trust team domain used for ZTA authentication. Required when `DASHBOARD_AUTH` is `zta`. | + +The OpenTelemetry variables mentioned above are not an exhaustive list of all [supported environment variables](https://opentelemetry.io/docs/languages/sdk-configuration/). diff --git a/ERROR_CODES.md b/ERROR_CODES.md new file mode 100644 index 000000000..d4f8d90c9 --- /dev/null +++ b/ERROR_CODES.md @@ -0,0 +1,90 @@ +# Error Operational Codes + +This is the list of operational codes that can help you understand your deployment and your usage. + +| Code | Description | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| TopicNameRequired | You are trying to use Realtime without a topic name set | +| InvalidJoinPayload | The payload provided to Realtime on connect is invalid | +| RealtimeDisabledForConfiguration | The configuration provided to Realtime on connect will not be able to provide you any Postgres Changes | +| TenantNotFound | The tenant you are trying to connect to does not exist | +| MissingAPIKey | No API key was provided in the `x-api-key` header or `apikey` query parameter | +| ErrorConnectingToWebsocket | Error when trying to connect to the WebSocket server | +| ErrorAuthorizingWebsocket | Error when trying to authorize the WebSocket connection | +| UnableToDeleteTenant | Error when trying to delete a tenant | +| UnableToSetPolicies | Error when setting up Authorization Policies | +| UnableCheckoutConnection | Error when trying to checkout a connection from the tenant pool | +| UnableToSubscribeToPostgres | Error when trying to subscribe to Postgres changes | +| ReconnectSubscribeToPostgres | Postgres changes still waiting to be subscribed | +| ChannelRateLimitReached | The number of channels you can create has reached its limit | +| ConnectionRateLimitReached | The number of connected clients has reached its limit | +| ClientJoinRateLimitReached | The rate of joins per second from your clients has reached the channel limits | +| DatabaseConnectionRateLimitReached | The rate of attempts to connect to tenants database has reached the limit | +| MessagePerSecondRateLimitReached | The rate of messages per second from your clients has reached the channel limits | +| RealtimeDisabledForTenant | Realtime has been disabled for the tenant | +| UnableToConnectToTenantDatabase | Realtime was not able to connect to the tenant's database | +| DatabaseLackOfConnections | Realtime was not able to connect to the tenant's database due to not having enough available connections | +| RealtimeNodeDisconnected | Realtime is a distributed application and this means that one the system is unable to communicate with one of the distributed nodes | +| MigrationsFailedToRun | Error when running the migrations against the Tenant database that are required by Realtime | +| StartReplicationFailed | Error when starting the replication and listening of errors for database broadcasting | +| ReplicationConnectionTimeout | Replication connection timed out during initialization | +| ReplicationMaxWalSendersReached | Maximum number of WAL senders reached in tenant database, check how to increase this value in this [link](https://supabase.com/docs/guides/database/custom-postgres-config#cli-configurable-settings) | +| MigrationCheckFailed | Check to see if we require to run migrations fails | +| PartitionCreationFailed | Error when creating partitions for realtime.messages | +| ErrorStartingPostgresCDCStream | Error when starting the Postgres CDC stream which is used for Postgres Changes | +| UnknownDataProcessed | An unknown data type was processed by the Realtime system | +| ErrorStartingPostgresCDC | Error when starting the Postgres CDC extension which is used for Postgres Changes | +| ReplicationSlotBeingUsed | The replication slot is being used by another transaction | +| PoolingReplicationPreparationError | Error when preparing the replication slot | +| PoolingReplicationError | Error when pooling the replication slot | +| CheckOidsError | Error when fetching the publication tables (OIDs) during the periodic check; the existing OIDs, replication slot and subscribers are left untouched | +| SubscriptionCleanupFailed | Error when trying to clean up all subscriptions on subscription manager initialization or OID change | +| SubscriptionDeletionFailed | Error when trying to delete a subscription for postgres changes | +| SubscriptionsCheckerConnectionFailed | Error when the subscriptions checker process fails to connect to the database on startup | +| ReplicationPollerConnectionFailed | Error when the replication poller process fails to connect to the database on startup | +| ReplicationPollerMaxRetriesReached | The replication poller gave up after the maximum number of consecutive retries and stopped the tenant's Postgres Changes workers | +| DropReplicationSlotFailed | Error when dropping the replication slot after the publication became empty; the poller stops so the temporary slot is released with the connection | +| SubscriptionManagerConnectionFailed | Error when the subscription manager process fails to connect to the database on startup | +| PgStatActivityQueryFailed | Error when querying pg_stat_activity to diagnose a replication slot conflict | +| RateCounterError | Error when retrieving the subscription rate counter, falling back to blocking new subscriptions | +| UnableToDeletePhantomSubscriptions | Error when trying to delete subscriptions that are no longer being used | +| UnableToCheckProcessesOnRemoteNode | Error when trying to check the processes on a remote node | +| UnhandledProcessMessage | Unhandled message received by a Realtime process | +| UnableToTrackPresence | Error when handling track presence for this socket | +| UnknownPresenceEvent | Presence event type not recognized by service | +| IncreaseConnectionPool | The number of connections you have set for Realtime are not enough to handle your current use case | +| RlsPolicyError | Error on RLS policy used for authorization | +| ConnectionInitializing | Database is initializing connection | +| DatabaseConnectionIssue | Database had connection issues and connection was not able to be established | +| UnableToConnectToProject | Unable to connect to Project database | +| InvalidJWTExpiration | JWT exp claim value it's incorrect | +| JwtSignatureError | JWT signature was not able to be validated | +| MalformedJWT | Token received does not comply with the JWT format | +| Unauthorized | Unauthorized access to Realtime channel | +| RealtimeRestarting | Realtime is currently restarting | +| InvalidPresencePayload | Payload from track event sent to Presence isn't a map | +| UnableToProcessListenPayload | Payload sent in NOTIFY operation wasn't JSON parsable | +| UnprocessableEntity | Received a HTTP request with a body that was not able to be processed by the endpoint | +| InitializingProjectConnection | Connection against Tenant database is still starting | +| TimeoutOnRpcCall | RPC request within the Realtime server has timed out. | +| ErrorOnRpcCall | Error when calling another realtime node | +| ErrorExecutingTransaction | Error executing a database transaction in tenant database | +| SynInitializationError | Our framework to synchronize processes has failed to properly startup a connection to the database | +| JanitorFailedToDeleteOldMessages | Scheduled task for realtime.message cleanup was unable to run | +| UnableToEncodeJson | An error were we are not handling correctly the response to be sent to the end user | +| UnableToBroadcastChanges | Error when trying to broadcast database changes to subscribers | +| WarnSendingBroadcastMessage | Warning when `realtime.send` or `realtime.send_binary` cannot insert the message. See the [troubleshooting guide](https://supabase.com/docs/guides/realtime/troubleshooting). | +| UnexpectedMessageReceived | An unexpected message was received by the replication connection process | +| ErrorRunningQuery | Error when running a query against the tenant database | +| UnknownError | An unhandled error occurred | +| UnknownErrorOnController | An error we are not handling correctly was triggered on a controller | +| UnknownErrorOnChannel | An error we are not handling correctly was triggered on a channel | +| PresenceRateLimitReached | Limit of presence events reached | +| ClientPresenceRateLimitReached | Limit of presence events reached on socket | +| UnableToReplayMessages | An error while replaying messages | +| JwtSignerError | Failed to generate a JWT signer — check your JWT secret or JWKS configuration | +| MalformedWebSocketMessage | Received a WebSocket message that is empty, invalid JSON, or missing required fields (`ref`, `topic`, or `event`). The connection is kept alive but the message is dropped | +| UnknownErrorOnWebSocketMessage | An unexpected error occurred while processing an incoming WebSocket message. The connection is kept alive but the message is dropped | +| ReplicationSlotLagTooHigh | The replication slot WAL lag has exceeded 50% of `max_slot_wal_keep_size`. The replication connection is shut down and will be restarted to prevent the slot from being invalidated by PostgreSQL | +| ReplicationSlotLagCheckSkipped | The periodic replication slot lag check could not be completed, typically because the tenant database connection was unavailable. The check is skipped and retried on the next watchdog interval | + diff --git a/Makefile b/Makefile deleted file mode 100644 index fd7f0f7fd..000000000 --- a/Makefile +++ /dev/null @@ -1,58 +0,0 @@ -CLUSTER_STRATEGIES ?= EPMD -NODE_NAME ?= pink -PORT ?= 4000 - -.PHONY: dev dev.orange seed prod bench.% dev_db start start.% stop stop.% rebuild rebuild.% - -.DEFAULT_GOAL := help - -# Common commands - -dev: ## Start a dev server - ELIXIR_ERL_OPTIONS="+hmax 1000000000" SLOT_NAME_SUFFIX=some_sha PORT=$(PORT) MIX_ENV=dev SECURE_CHANNELS=true API_JWT_SECRET=dev METRICS_JWT_SECRET=dev REGION=fra DB_ENC_KEY="1234567890123456" CLUSTER_STRATEGIES=$(CLUSTER_STRATEGIES) ERL_AFLAGS="-kernel shell_history enabled" GEN_RPC_TCP_SERVER_PORT=5369 GEN_RPC_TCP_CLIENT_PORT=5469 iex --name $(NODE_NAME)@127.0.0.1 --cookie cookie -S mix phx.server - -dev.orange: ## Start another dev server (orange) on port 4001 - ELIXIR_ERL_OPTIONS="+hmax 1000000000" SLOT_NAME_SUFFIX=some_sha PORT=4001 MIX_ENV=dev SECURE_CHANNELS=true API_JWT_SECRET=dev METRICS_JWT_SECRET=dev DB_ENC_KEY="1234567890123456" CLUSTER_STRATEGIES=$(CLUSTER_STRATEGIES) ERL_AFLAGS="-kernel shell_history enabled" GEN_RPC_TCP_SERVER_PORT=5469 GEN_RPC_TCP_CLIENT_PORT=5369 iex --name orange@127.0.0.1 --cookie cookie -S mix phx.server - -seed: ## Seed the database - DB_ENC_KEY="1234567890123456" FLY_ALLOC_ID=123e4567-e89b-12d3-a456-426614174000 mix run priv/repo/dev_seeds.exs - -prod: ## Start a server with a MIX_ENV=prod - ELIXIR_ERL_OPTIONS="+hmax 1000000000" SLOT_NAME_SUFFIX=some_sha MIX_ENV=prod FLY_APP_NAME=realtime-local API_KEY=dev SECURE_CHANNELS=true API_JWT_SECRET=dev METRICS_JWT_SECRET=dev FLY_REGION=fra FLY_ALLOC_ID=123e4567-e89b-12d3-a456-426614174000 DB_ENC_KEY="1234567890123456" SECRET_KEY_BASE=M+55t7f6L9VWyhH03R5N7cIhrdRlZaMDfTE6Udz0eZS7gCbnoLQ8PImxwhEyao6D DASHBOARD_USER=realtime_local DASHBOARD_PASSWORD=password ERL_AFLAGS="-kernel shell_history enabled" iex -S mix phx.server - -bench.%: ## Run benchmark with a specific file. e.g. bench.secrets - ELIXIR_ERL_OPTIONS="+hmax 1000000000" SLOT_NAME_SUFFIX=some_sha MIX_ENV=dev SECURE_CHANNELS=true API_JWT_SECRET=dev METRICS_JWT_SECRET=dev FLY_REGION=fra FLY_ALLOC_ID=123e4567-e89b-12d3-a456-426614174000 DB_ENC_KEY="1234567890123456" ERL_AFLAGS="-kernel shell_history enabled" mix run bench/$* - -dev_db: ## Start dev databases using docker - docker-compose -f docker-compose.dbs.yml up -d && mix ecto.migrate --log-migrator-sql - -# Docker specific commands - -start: ## Start main docker compose - docker-compose up - -start.%: ## Start docker compose with a specific file. e.g. start.dbs - docker-compose -f docker-compose.$*.yml up - -stop: ## Stop main docker compose - docker-compose down --remove-orphans - -stop.%: ## Stop docker compose with a specific file. e.g. stop.dbs - docker-compose -f docker-compose.yml -f docker-compose.$*.yml down --remove-orphans - -rebuild: ## Rebuild main docker compose images - make stop - docker-compose build - docker-compose up --force-recreate --build - -rebuild.%: ## Rebuild docker compose images with a specific file. e.g. rebuild.dbs - make stop.$* - docker-compose -f docker-compose.yml -f docker-compose.$*.yml build - docker-compose -f docker-compose.yml -f docker-compose.$*.yml up --force-recreate --build - -# Based on https://gist.github.com/prwhite/8168133 -.DEFAULT_GOAL:=help -.PHONY: help -help: ## Display this help - $(info Realtime commands) - @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[%.a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) diff --git a/OBSERVABILITY_METRICS.md b/OBSERVABILITY_METRICS.md new file mode 100644 index 000000000..cac81ad86 --- /dev/null +++ b/OBSERVABILITY_METRICS.md @@ -0,0 +1,234 @@ +# Observability and Metrics + +## Table of contents + +- [Metrics Endpoints](#metrics-endpoints) +- [Metric Scopes](#metric-scopes) +- [Connection & Tenant Metrics](#connection--tenant-metrics) +- [Event Metrics](#event-metrics) +- [Payload & Traffic Metrics](#payload--traffic-metrics) +- [Latency & Performance Metrics](#latency--performance-metrics) +- [Authorization & Error Metrics](#authorization--error-metrics) +- [Subscription Pooler Metrics](#subscription-pooler-metrics) +- [Tenant Migration Metrics](#tenant-migration-metrics) +- [BEAM/Erlang VM Metrics](#beamerlang-vm-metrics) + - [Memory Metrics](#memory-metrics) + - [Process & Resource Metrics](#process--resource-metrics) + - [Performance Metrics](#performance-metrics) +- [Infrastructure Metrics](#infrastructure-metrics) + - [Node Metrics](#node-metrics) + - [Distributed System Metrics](#distributed-system-metrics) + +Supabase Realtime exposes comprehensive metrics for monitoring performance, resource usage, and application behavior. These metrics are exposed in Prometheus format and can be scraped by any compatible monitoring system (Victoria Metrics, Prometheus, Grafana Agent, etc.). + +## Metrics Endpoints + +Metrics are split across two endpoints with different priorities, allowing you to configure different scrape intervals in your monitoring system: + +| Endpoint | Priority | Recommended Scrape Interval | Contents | +| ----------------------------- | -------- | --------------------------- | ------------------------------------------------------------------------------------------------ | +| `GET /metrics` | **High** | 30s | BEAM/VM, OS, Phoenix, distributed infra, and global aggregated tenant totals (no `tenant` label) | +| `GET /tenant-metrics` | **Low** | 60s | Per-tenant labeled metrics (connection counts, channel events, replication, authorization) | +| `GET /metrics/:region` | **High** | 30s | Same as `/metrics` scoped to a specific region | +| `GET /tenant-metrics/:region` | **Low** | 60s | Same as `/tenant-metrics` scoped to a specific region | + +All endpoints require a `Bearer` JWT token in the `Authorization` header signed with `METRICS_JWT_SECRET`. + +**Victoria Metrics scrape configuration example:** + +```yaml +scrape_configs: + - job_name: realtime_global + scrape_interval: 30s + bearer_token: + static_configs: + - targets: [":4000"] + metrics_path: /metrics + + - job_name: realtime_tenant + scrape_interval: 60s + bearer_token: + static_configs: + - targets: [":4000"] + metrics_path: /tenant-metrics +``` + +## Metric Scopes + +Metrics are classified by their scope to help you understand what they measure: + +- **Per-Tenant**: Metrics tagged with a `tenant` label measure activity scoped to individual tenants. Exposed on `/tenant-metrics`. +- **Global Aggregate**: Metrics prefixed with `realtime_channel_global_*` or `realtime_connections_global_*` aggregate tenant data without the `tenant` label, suitable for cluster-wide dashboards. Exposed on `/metrics`. +- **Per-Node**: Metrics measure activity on the current Realtime node. Without explicit per-node indication, assume metrics apply to the local node. +- **BEAM/Erlang VM**: Metrics prefixed with `beam_*` and `phoenix_*` expose Erlang runtime internals. Exposed on `/metrics`. +- **Infrastructure**: Metrics prefixed with `osmon_*`, `gen_rpc_*`, and `dist_*` measure system-level resources and cluster communication. Exposed on `/metrics`. + +## Connection & Tenant Metrics + +These metrics track WebSocket connections and tenant activity across the Realtime cluster. + +| Metric | Type | Description | Scope | Endpoint | +| ----------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------- | +| `realtime_tenants_connected` | Gauge | Number of connected tenants per Realtime node. Use this to understand tenant distribution across your cluster and identify load imbalances. | Per-Node | `/metrics` | +| `realtime_connections_global_connected` | Gauge | Node total of active WebSocket connections across all tenants. Aggregated without a `tenant` label for cluster-wide dashboards. | Global Aggregate | `/metrics` | +| `realtime_connections_global_connected_cluster` | Gauge | Cluster-wide total of active WebSocket connections across all tenants. | Global Aggregate | `/metrics` | +| `realtime_connections_connected` | Gauge | Active WebSocket connections that have at least one subscribed channel. Indicates active client engagement with Realtime features. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_connections_connected_cluster` | Gauge | Cluster-wide active WebSocket connections for each individual tenant. | **Per-Tenant** | `/tenant-metrics` | +| `phoenix_connections_total` | Gauge | Total open connections to the Ranch listener (includes idle connections waiting for data). | Per-Node | `/metrics` | +| `phoenix_connections_active` | Gauge | Connections actively processing a WebSocket frame or HTTP request. Divide by `phoenix_connections_max` to get a saturation ratio. | Per-Node | `/metrics` | +| `phoenix_connections_max` | Gauge | The configured Ranch connection limit. When `phoenix_connections_total` approaches this the node is saturated and new connections will be queued. | Per-Node | `/metrics` | +| `realtime_channel_joins` | Counter | Rate of channel join attempts per second per tenant. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_channel_global_joins` | Counter | Global rate of channel join attempts per second across all tenants. | Global Aggregate | `/metrics` | + +## Event Metrics + +These metrics measure the volume and types of events flowing through your Realtime system, segmented by feature type. + +| Metric | Type | Description | Scope | Endpoint | +| ----------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------- | +| `realtime_channel_events` | Counter | Broadcast events per second per tenant. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_channel_presence_events` | Counter | Presence events per second per tenant. Includes online/offline status updates and custom presence metadata synchronization. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_channel_db_events` | Counter | Postgres Changes events per second per tenant. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_channel_global_events` | Counter | Global broadcast events per second across all tenants. Compare against per-tenant values for outlier detection. | Global Aggregate | `/metrics` | +| `realtime_channel_global_presence_events` | Counter | Global presence events per second across all tenants. | Global Aggregate | `/metrics` | +| `realtime_channel_global_db_events` | Counter | Global Postgres Changes events per second across all tenants. | Global Aggregate | `/metrics` | + +## Payload & Traffic Metrics + +These metrics provide insight into data volume, message sizes, and network I/O characteristics. + +| Metric | Type | Description | Scope | Endpoint | +| -------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------- | +| `realtime_payload_size_bucket` | Histogram | Global payload size distribution across all tenants, tagged by message type. Use for cluster-wide sizing and capacity planning. | Global Aggregate | `/metrics` | +| `realtime_tenants_payload_size_bucket` | Histogram | Per-tenant payload size distribution. Use this to identify tenants generating unusually large messages. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_channel_input_bytes` | Counter | Total ingress bytes per tenant. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_channel_output_bytes` | Counter | Total egress bytes per tenant. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_channel_global_input_bytes` | Counter | Global total ingress bytes across all tenants. | Global Aggregate | `/metrics` | +| `realtime_channel_global_output_bytes` | Counter | Global total egress bytes across all tenants. | Global Aggregate | `/metrics` | + +## Latency & Performance Metrics + +These metrics measure end-to-end latency and processing performance across different Realtime operations. + +| Metric | Type | Description | Scope | Endpoint | +| ---------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------- | +| `realtime_replication_poller_query_duration_bucket` | Histogram | Postgres Changes query latency in milliseconds per tenant. High values may indicate database performance issues. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_replication_poller_query_duration_count` | Counter | Number of database polling queries executed per tenant. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_tenants_broadcast_from_database_latency_committed_at_bucket` | Histogram | Time from database commit to client broadcast per tenant. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_tenants_broadcast_from_database_latency_inserted_at_bucket` | Histogram | Alternative latency using insert timestamp per tenant. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_tenants_replay_bucket` | Histogram | Broadcast replay latency per tenant. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_global_rpc_bucket` | Histogram | Inter-node RPC call latency distribution, tagged by `success` and `mechanism`. | Global Aggregate | `/metrics` | +| `realtime_global_rpc_count` | Counter | Total inter-node RPC calls. Divide failed by total to get error rate. | Global Aggregate | `/metrics` | +| `realtime_tenants_read_authorization_check_bucket` | Histogram | RLS policy evaluation time for read operations per tenant. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_tenants_read_authorization_check_count` | Counter | Number of read authorization checks per tenant. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_tenants_write_authorization_check_bucket` | Histogram | RLS policy evaluation time for write operations per tenant. | **Per-Tenant** | `/tenant-metrics` | +| `phoenix_channel_handled_in_duration_milliseconds_bucket` | Histogram | Time for the application to respond to a channel message. High p99 values indicate slow message handlers. | Per-Node | `/metrics` | +| `phoenix_socket_connected_duration_milliseconds_bucket` | Histogram | Time to establish a WebSocket socket connection, tagged by `result`/`transport`/`serializer`. | Per-Node | `/metrics` | + +## Authorization & Error Metrics + +These metrics track security policy enforcement and error rates. + +| Metric | Type | Description | Scope | Endpoint | +| ------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------- | +| `realtime_channel_error` | Counter | Unhandled channel errors per tenant. Any non-zero value warrants investigation. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_channel_global_error` | Counter | Global unhandled channel error count across all tenants, tagged by error code. | Global Aggregate | `/metrics` | +| `phoenix_channel_joined_total` | Counter | WebSocket channel join attempts tagged by `result` (`ok`/`error`) and `transport`. Use `result="error"` rate to detect client or policy issues. | Per-Node | `/metrics` | + +## Subscription Pooler Metrics + +These metrics cover the Postgres Changes subscription pooler per tenant: the workers that hold subscriptions, poll the replication slot, and broadcast row changes to clients. + +| Metric | Type | Description | Scope | Endpoint | +| ----------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------- | -------------- | ----------------- | +| `realtime_subscriptions_manager_subscribers` | Gauge | Subscribers tracked for the tenant across the cluster. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_replication_poller_stop_total` | Counter | How many times the tenant's poller terminated, tagged by `reason`. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_replication_poller_exception_total` | Counter | How many times the tenant's poller crashed (terminated abnormally). | **Per-Tenant** | `/tenant-metrics` | +| `realtime_replication_poller_query_exception_total` | Counter | How many of the tenant's polls failed reading changes from the slot, tagged by `reason`. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_replication_poller_prepare_exception_total` | Counter | How many of the tenant's attempts to prepare the replication slot for polling failed. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_replication_poller_changes_dispatch` | Counter | Number of Postgres Changes rows the poller broadcast to subscribers. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_replication_poller_changes_skip` | Counter | Number of Postgres Changes rows skipped without broadcasting, tagged by `reason`. | **Per-Tenant** | `/tenant-metrics` | +| `realtime_subscriptions_manager_dead_pid` | Counter | Not-alive subscriber pids the manager handled, tagged by `reason`: `phantom` or `not_found`. | **Per-Tenant** | `/tenant-metrics` | + +## Tenant Migration Metrics + +These metrics track tenants migration execution and reconciliations. + +| Metric | Type | Description | Scope | Endpoint | +| ---------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------ | ---------------- | ---------- | +| `realtime_tenants_migrations_duration_milliseconds_bucket` | Histogram | Tenant migration duration in milliseconds. | Global Aggregate | `/metrics` | +| `realtime_tenants_migrations_duration_milliseconds_count` | Counter | Completed tenant migration runs. | Global Aggregate | `/metrics` | +| `realtime_tenants_migrations_duration_milliseconds_sum` | Counter | Cumulative tenant migration time in milliseconds. | Global Aggregate | `/metrics` | +| `realtime_tenants_migrations_exceptions_total` | Counter | Failed tenant migration runs, tagged by `error_code`. | Global Aggregate | `/metrics` | +| `realtime_tenants_migrations_reconcile_total` | Counter | Tenants whose cached `migrations_ran` was reconciled against the database on connect. | Global Aggregate | `/metrics` | +| `realtime_tenants_migrations_reconcile_exceptions_total` | Counter | Failed reconciliations. | Global Aggregate | `/metrics` | + +Per-tenant attribution lives on the log path — see [TELEMETRY.md](./TELEMETRY.md) for the alert query foundation. + +## BEAM/Erlang VM Metrics + +These metrics provide insight into the underlying Erlang runtime that powers Realtime, critical for capacity planning and debugging performance issues. + +All BEAM/Erlang VM metrics are served from `GET /metrics`. + +### Memory Metrics + +| Metric | Type | Description | +| ----------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `beam_memory_allocated_bytes` | Gauge | Total memory allocated by the Erlang VM. Compare this to the container memory limit to ensure you have headroom. Steady increase may indicate a memory leak. | +| `beam_memory_atom_total_bytes` | Gauge | Memory used by the atom table. Atoms in Erlang are never garbage collected, so this should remain relatively stable. Unbounded growth indicates a bug creating new atoms. | +| `beam_memory_binary_total_bytes` | Gauge | Memory used by binary data (WebSocket payloads, database results). This metric closely correlates with active connection volume and message sizes. | +| `beam_memory_code_total_bytes` | Gauge | Memory used by compiled Erlang bytecode. Changes only during code reloads and should remain stable in production. | +| `beam_memory_ets_total_bytes` | Gauge | Memory used by ETS (in-memory tables) including channel subscriptions and presence state. Monitor this to understand session storage overhead. | +| `beam_memory_processes_total_bytes` | Gauge | Memory used by Erlang processes themselves. Each channel connection and background task consumes memory; this scales with concurrency. | +| `beam_memory_persistent_term_total_bytes` | Gauge | Memory used by persistent terms (immutable shared state). Should be minimal and stable in typical Realtime deployments. | + +### Process & Resource Metrics + +| Metric | Type | Description | +| -------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `beam_stats_process_count` | Gauge | Number of active Erlang processes. Each WebSocket connection spawns processes; high values correlate with connection count. Sudden spikes may indicate process leaks. | +| `beam_stats_port_count` | Gauge | Number of open port connections (network sockets, pipes). Should correlate roughly with connection count plus internal cluster communications. | +| `beam_stats_ets_count` | Gauge | Number of active ETS tables used for caching and state. Changes reflect dynamic supervisor activity and feature usage patterns. | +| `beam_stats_atom_count` | Gauge | Total atoms in the atom table. Should remain relatively stable; unbounded growth indicates code bugs. | + +### Performance Metrics + +| Metric | Type | Description | +| -------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `beam_stats_uptime_milliseconds_count` | Counter | Node uptime in milliseconds. Use this to track restarts and validate deployment stability. Unexpected resets indicate crashes. | +| `beam_stats_port_io_byte_count` | Counter | Total bytes transferred through network ports. Compare ingress and egress to identify asymmetric traffic patterns. | +| `beam_stats_gc_count` | Counter | Garbage collection events executed by the Erlang VM. Frequent GC indicates high memory churn; infrequent GC suggests stable state. | +| `beam_stats_gc_reclaimed_bytes` | Counter | Bytes reclaimed by garbage collection. Divide by GC count to understand average cleanup size. Low reclaim per GC may indicate inefficient memory allocation patterns. | +| `beam_stats_reduction_count` | Counter | Total reductions (work units) executed by the VM. Correlates with CPU usage; high reduction rates under stable load indicate inefficient algorithms. | +| `beam_stats_context_switch_count` | Counter | Process context switches by the Erlang scheduler. High values indicate contention between many processes; compare with process count to gauge congestion. | +| `beam_stats_active_task_count` | Gauge | Tasks currently executing on dirty schedulers (non-Erlang operations). High values indicate CPU-bound work or blocking I/O. | +| `beam_stats_run_queue_count` | Gauge | Processes waiting to be scheduled. High values indicate CPU saturation; the node cannot keep up with work demand. | + +## Infrastructure Metrics + +These metrics expose system-level resource usage and inter-node cluster communication. All infrastructure metrics are served from `GET /metrics`. + +### Node Metrics + +| Metric | Type | Description | +| ----------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `osmon_cpu_util` | Gauge | Current CPU utilization percentage (0-100). Monitor this to trigger horizontal scaling and identify CPU-bound bottlenecks. | +| `osmon_cpu_avg1` | Gauge | 1-minute CPU load average. Sharp increases indicate sudden load spikes; values > CPU count indicate sustained overload. | +| `osmon_cpu_avg5` | Gauge | 5-minute CPU load average. Smooths short-term spikes; use this to detect sustained load increases. | +| `osmon_cpu_avg15` | Gauge | 15-minute CPU load average. Indicates long-term trends; use for capacity planning and detecting gradual load growth. | +| `osmon_ram_usage` | Gauge | RAM utilization percentage (0-100). Combined with `beam_memory_allocated_bytes`, this indicates kernel memory overhead and other processes on the node. | + +### Distributed System Metrics + +| Metric | Type | Description | +| ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `gen_rpc_queue_size_bytes` | Gauge | Outbound queue size for gen_rpc inter-node communication in bytes. Large values indicate a receiving node cannot keep up with message rate. | +| `gen_rpc_send_pending_bytes` | Gauge | Bytes pending transmission in gen_rpc queues. Combined with queue size, helps identify network saturation or slow receivers. | +| `gen_rpc_send_bytes` | Counter | Total bytes sent via gen_rpc across the cluster. Monitor this to understand inter-node traffic and plan network capacity. | +| `gen_rpc_recv_bytes` | Counter | Total bytes received via gen_rpc from other nodes. Compare with send bytes to identify asymmetric communication patterns. | +| `dist_queue_size` | Gauge | Erlang distribution queue size for cluster communication. High values indicate network congestion or unbalanced load across nodes. | +| `dist_send_pending_bytes` | Gauge | Bytes pending in Erlang distribution queues. Works with queue size to diagnose cluster communication issues. | +| `dist_send_bytes` | Counter | Total bytes sent via Erlang distribution protocol. Includes all cluster metadata and RPC traffic. | +| `dist_recv_bytes` | Counter | Total bytes received via Erlang distribution protocol. Compare with send to validate symmetric communication. | + diff --git a/README.md b/README.md index 2235bf388..855e0112f 100644 --- a/README.md +++ b/README.md @@ -13,25 +13,25 @@

Send ephemeral messages, track and synchronize shared state, and listen to Postgres changes all over WebSockets.
- Multiplayer Demo + Examples · - Request Feature + Request Features · - Report Bug + Report Bugs

## Status -![GitHub License](https://img.shields.io/github/license/supabase/realtime) +[![GitHub License](https://img.shields.io/github/license/supabase/realtime)](https://github.com/supabase/realtime/blob/main/LICENSE) [![Coverage Status](https://coveralls.io/repos/github/supabase/realtime/badge.svg?branch=main)](https://coveralls.io/github/supabase/realtime?branch=main) | Features | v1 | v2 | Status | | ---------------- | --- | --- | ------ | | Postgres Changes | ✔ | ✔ | GA | -| Broadcast | | ✔ | Beta | -| Presence | | ✔ | Beta | +| Broadcast | | ✔ | GA | +| Presence | | ✔ | GA | This repository focuses on version 2 but you can still access the previous version's [code](https://github.com/supabase/realtime/tree/v1) and [Docker image](https://hub.docker.com/layers/supabase/realtime/v1.0.0/images/sha256-e2766e0e3b0d03f7e9aa1b238286245697d0892c2f6f192fd2995dca32a4446a). For the latest Docker images go to https://hub.docker.com/r/supabase/realtime. @@ -55,235 +55,34 @@ The server does not guarantee that every message will be delivered to your clien ## Quick start -You can check out the [Multiplayer demo](https://multiplayer.dev) that features Broadcast, Presence and Postgres Changes under the demo directory: https://github.com/supabase/realtime/tree/main/demo. +You can check out the [Supabase UI Library](https://supabase.com/ui) Realtime components and the [repository](https://github.com/supabase/multiplayer.dev) of the [multiplayer.dev](https://multiplayer.dev) demo app. -## Client libraries +## Developers -- JavaScript: [@supabase/realtime-js](https://github.com/supabase/realtime-js) -- Dart: [@supabase/realtime-dart](https://github.com/supabase/realtime-dart) +Start with [DEVELOPERS.md](DEVELOPERS.md) for local setup, `mise` tasks, and example workflows. -## Server Setup +Once your environment is up and running, check out the following docs to customize the server and troubleshooting: -To get started, spin up your Postgres database and Realtime server containers defined in `docker-compose.yml`. As an example, you may run `docker-compose -f docker-compose.yml up`. +- [ENVS.md](ENVS.md) - detailed list of all environment variables +- [ERROR_CODES.md](ERROR_CODES.md) - list of operational codes +- [OBSERVABILITY_METRICS.md](OBSERVABILITY_METRICS.md) - monitoring information -> **Note** -> Supabase runs Realtime in production with a separate database that keeps track of all tenants. However, a schema, `_realtime`, is created when spinning up containers via `docker-compose.yml` to simplify local development. +## Postgres compatibility -A tenant has already been added on your behalf. You can confirm this by checking the `_realtime.tenants` and `_realtime.extensions` tables inside the database. +| `supabase/postgres` version | Role | Status | +| --------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| < 14 | - | Not officially supported. | +| 14.x | `supabase_admin` | Requires superuser: `log_min_messages` can only be set by a superuser; supautils doesn't expose per-parameter delegation on this version. On <= 14.5, `realtime.broadcast_changes(...)` called from a trigger via `PERFORM` is unsupported. | +| 15.x < 15.14.1.018 | `supabase_admin` | Requires superuser: `supautils.policy_grants` on `realtime.subscription` is missing until [supabase/postgres@1b916920](https://github.com/supabase/postgres/commit/1b916920). | +| 15.x >= 15.14.1.018, 16.x, 17.x | `supabase_realtime_admin` | No superuser needed. Role must have `REPLICATION` and policies are managed by supautils. | -You can add your own by making a `POST` request to the server. You must change both `name` and `external_id` while you may update other values as you see fit: +## Contributing -```bash - curl -X POST \ - -H 'Content-Type: application/json' \ - -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiIiLCJpYXQiOjE2NzEyMzc4NzMsImV4cCI6MTcwMjc3Mzk5MywiYXVkIjoiIiwic3ViIjoiIn0._ARixa2KFUVsKBf3UGR90qKLCpGjxhKcXY4akVbmeNQ' \ - -d $'{ - "tenant" : { - "name": "realtime-dev", - "external_id": "realtime-dev", - "jwt_secret": "a1d99c8b-91b6-47b2-8f3c-aa7d9a9ad20f", - "extensions": [ - { - "type": "postgres_cdc_rls", - "settings": { - "db_name": "postgres", - "db_host": "host.docker.internal", - "db_user": "postgres", - "db_password": "postgres", - "db_port": "5432", - "region": "us-west-1", - "poll_interval_ms": 100, - "poll_max_record_bytes": 1048576, - "ssl_enforced": false - } - } - ] - } - }' \ - http://localhost:4000/api/tenants -``` +See [CONTRIBUTING.md](CONTRIBUTING.md) -> **Note** -> The `Authorization` token is signed with the secret set by `API_JWT_SECRET` in `docker-compose.yml`. +## Code of Conduct -If you want to listen to Postgres changes, you can create a table and then add the table to the `supabase_realtime` publication: - -```sql -create table test ( - id serial primary key -); - -alter publication supabase_realtime add table test; -``` - -You can start playing around with Broadcast, Presence, and Postgres Changes features either with the client libs (e.g. `@supabase/realtime-js`), or use the built in Realtime Inspector on localhost, `http://localhost:4000/inspector/new` (make sure the port is correct for your development environment). - -The WebSocket URL must contain the subdomain, `external_id` of the tenant on the `_realtime.tenants` table, and the token must be signed with the `jwt_secret` that was inserted along with the tenant. - -If you're using the default tenant, the URL is `ws://realtime-dev.localhost:4000/socket` (make sure the port is correct for your development environment), and you can use `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDMwMjgwODcsInJvbGUiOiJwb3N0Z3JlcyJ9.tz_XJ89gd6bN8MBpCl7afvPrZiBH6RB65iA1FadPT3Y` for the token. The token must have `exp` and `role` (database role) keys. - -**Environment Variables** - -| Variable | Type | Description | -| ----------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PORT | number | Port which you can connect your client/listeners | -| DB_HOST | string | Database host URL | -| DB_PORT | number | Database port | -| DB_USER | string | Database user | -| DB_PASSWORD | string | Database password | -| DB_NAME | string | Postgres database name | -| DB_ENC_KEY | string | Key used to encrypt sensitive fields in \_realtime.tenants and \_realtime.extensions tables. Recommended: 16 characters. | -| DB_AFTER_CONNECT_QUERY | string | Query that is run after server connects to database. | -| DB_IP_VERSION | string | Sets the IP Version to be used. Allowed values are "ipv6" and "ipv4". If none are set we will try to infer the correct version | -| DB_SSL | boolean | Whether or not the connection will be set-up using SSL | -| DB_SSL_CA_CERT | string | Filepath to a CA trust store (e.g.: /etc/cacert.pem). If defined it enables server certificate verification | -| API_JWT_SECRET | string | Secret that is used to sign tokens used to manage tenants and their extensions via HTTP requests. | -| SECRET_KEY_BASE | string | Secret used by the server to sign cookies. Recommended: 64 characters. | -| ERL_AFLAGS | string | Set to either "-proto_dist inet_tcp" or "-proto_dist inet6_tcp" depending on whether or not your network uses IPv4 or IPv6, respectively. | -| APP_NAME | string | A name of the server. | -| DNS_NODES | string | Node name used when running server in a cluster. | -| MAX_CONNECTIONS | string | Set the soft maximum for WebSocket connections. Defaults to '16384'. | -| MAX_HEADER_LENGTH | string | Set the maximum header length for connections (in bytes). Defaults to '4096'. | -| NUM_ACCEPTORS | string | Set the number of server processes that will relay incoming WebSocket connection requests. Defaults to '100'. | -| DB_QUEUE_TARGET | string | Maximum time to wait for a connection from the pool. Defaults to '5000' or 5 seconds. See for more info: [DBConnection](https://hexdocs.pm/db_connection/DBConnection.html#start_link/2-queue-config). | -| DB_QUEUE_INTERVAL | string | Interval to wait to check if all connections were checked out under DB_QUEUE_TARGET. If all connections surpassed the target during this interval than the target is doubled. Defaults to '5000' or 5 seconds. See for more info: [DBConnection](https://hexdocs.pm/db_connection/DBConnection.html#start_link/2-queue-config). | -| DB_POOL_SIZE | string | Sets the number of connections in the database pool. Defaults to '5'. | -| SLOT_NAME_SUFFIX | string | This is appended to the replication slot which allows making a custom slot name. May contain lowercase letters, numbers, and the underscore character. Together with the default `supabase_realtime_replication_slot`, slot name should be up to 64 characters long. | -| TENANT_CACHE_EXPIRATION_IN_MS | string | Set tenant cache TTL in milliseconds | -| TENANT_MAX_BYTES_PER_SECOND | string | The default value of maximum bytes per second that each tenant can support, used when creating a tenant for the first time. Defaults to '100_000'. | -| TENANT_MAX_CHANNELS_PER_CLIENT | string | The default value of maximum number of channels each tenant can support, used when creating a tenant for the first time. Defaults to '100'. | -| TENANT_MAX_CONCURRENT_USERS | string | The default value of maximum concurrent users per channel that each tenant can support, used when creating a tenant for the first time. Defaults to '200'. | -| TENANT_MAX_EVENTS_PER_SECOND | string | The default value of maximum events per second that each tenant can support, used when creating a tenant for the first time. Defaults to '100'. | -| TENANT_MAX_JOINS_PER_SECOND | string | The default value of maximum channel joins per second that each tenant can support, used when creating a tenant for the first time. Defaults to '100'. | -| SEED_SELF_HOST | boolean | Seeds the system with default tenant | -| SELF_HOST_TENANT_NAME | string | Tenant reference to be used for self host. Do keep in mind to use a URL compatible name | -| LOG_LEVEL | string | Sets log level for Realtime logs. Defaults to info, supported levels are: info, emergency, alert, critical, error, warning, notice, debug | -| RUN_JANITOR | boolean | Do you want to janitor tasks to run | -| JANITOR_SCHEDULE_TIMER_IN_MS | number | Time in ms to run the janitor task | -| JANITOR_SCHEDULE_RANDOMIZE | boolean | Adds a randomized value of minutes to the timer | -| JANITOR_RUN_AFTER_IN_MS | number | Tells system when to start janitor tasks after boot | -| JANITOR_CLEANUP_MAX_CHILDREN | number | Maximum number of concurrent tasks working on janitor cleanup | -| JANITOR_CLEANUP_CHILDREN_TIMEOUT | number | Timeout for each async task for janitor cleanup | -| JANITOR_CHUNK_SIZE | number | Number of tenants to process per chunk. Each chunk will be processed by a Task | -| MIGRATION_PARTITION_SLOTS | number | Number of dynamic supervisor partitions used by the migrations process | -| CONNECT_PARTITION_SLOTS | number | Number of dynamic supervisor partitions used by the Connect, ReplicationConnect processes | -| METRICS_CLEANER_SCHEDULE_TIMER_IN_MS | number | Time in ms to run the Metric Cleaner task | -| METRICS_RPC_TIMEOUT_IN_MS | number | Time in ms to wait for RPC call to fetch Metric per node | -| REQUEST_ID_BAGGAGE_KEY | string | OTEL Baggage key to be used as request id | -| OTEL_SDK_DISABLED | boolean | Disable OpenTelemetry tracing completely when 'true' | -| OTEL_TRACES_EXPORTER | string | Possible values: `otlp` or `none`. See [https://github.com/open-telemetry/opentelemetry-erlang/tree/v1.4.0/apps#os-environment] for more details on how to configure the traces exporter. | -| OTEL_TRACES_SAMPLER | string | Default to `parentbased_always_on` . More info [here](https://opentelemetry.io/docs/languages/erlang/sampling/#environment-variables) | -| GEN_RPC_TCP_SERVER_PORT | number | Port served by `gen_rpc`. Must be secured just like the Erlang distribution port. Defaults to 5369 | -| GEN_RPC_TCP_CLIENT_PORT | number | `gen_rpc` connects to another node using this port. Most of the time it should be the same as GEN_RPC_TCP_SERVER_PORT. Defaults to 5369 | -| GEN_RPC_SSL_SERVER_PORT | number | Port served by `gen_rpc` secured with TLS. Must also define GEN_RPC_CERTFILE, GEN_RPC_KEYFILE and GEN_RPC_CACERTFILE. If this is defined then only TLS connections will be set-up. | -| GEN_RPC_SSL_CLIENT_PORT | number | `gen_rpc` connects to another node using this port. Most of the time it should be the same as GEN_RPC_SSL_SERVER_PORT. Defaults to 6369 | -| GEN_RPC_CERTFILE | string | Path to the public key in PEM format. Only needs to be provided if GEN_RPC_SSL_SERVER_PORT is defined | -| GEN_RPC_KEYFILE | string | Path to the private key in PEM format. Only needs to be provided if GEN_RPC_SSL_SERVER_PORT is defined | -| GEN_RPC_CACERTFILE | string | Path to the certificate authority public key in PEM format. Only needs to be provided if GEN_RPC_SSL_SERVER_PORT is defined | -| GEN_RPC_CONNECT_TIMEOUT_IN_MS | number | `gen_rpc` client connect timeout in milliseconds. Defaults to 10000. | -| GEN_RPC_SEND_TIMEOUT_IN_MS | number | `gen_rpc` client and server send timeout in milliseconds. Defaults to 10000. | -| GEN_RPC_SOCKET_IP | string | Interface which `gen_rpc` will bind to. Defaults to "0.0.0.0" (ipv4) which means that all interfaces are going to expose the `gen_rpc` port. | -| GEN_RPC_IPV6_ONLY | boolean | Configure `gen_rpc` to use IPv6 only. | -| GEN_RPC_MAX_BATCH_SIZE | integer | Configure `gen_rpc` to batch when possible RPC casts. Defaults to 0 | -| GEN_RPC_COMPRESS | integer | Configure `gen_rpc` to compress or not payloads. 0 means no compression and 9 max compression level. Defaults to 0. | -| GEN_RPC_COMPRESSION_THRESHOLD_IN_BYTES | integer | Configure `gen_rpc` to compress only above a certain threshold in bytes. Defaults to 1000. | -| MAX_GEN_RPC_CLIENTS | number | Max amount of `gen_rpc` TCP connections per node-to-node channel | -| REBALANCE_CHECK_INTERVAL_IN_MS | number | Time in ms to check if process is in the right region | -| DISCONNECT_SOCKET_ON_NO_CHANNELS_INTERVAL_IN_MS | number | Time in ms to check if a socket has no channels open and if so, disconnect it | - -The OpenTelemetry variables mentioned above are not an exhaustive list of all [supported environment variables](https://opentelemetry.io/docs/languages/sdk-configuration/). - -## WebSocket URL - -The WebSocket URL is in the following format for local development: `ws://[external_id].localhost:4000/socket/websocket` - -If you're using Supabase's hosted Realtime in production the URL is `wss://[project-ref].supabase.co/realtime/v1/websocket?apikey=[anon-token]&log_level=info&vsn=1.0.0"` - -## WebSocket Connection Authorization - -WebSocket connections are authorized via symmetric JWT verification. Only supports JWTs signed with the following algorithms: - -- HS256 -- HS384 -- HS512 - -Verify JWT claims by setting JWT_CLAIM_VALIDATORS: - -> e.g. {'iss': 'Issuer', 'nbf': 1610078130} -> -> Then JWT's "iss" value must equal "Issuer" and "nbf" value must equal 1610078130. - -**Note:** - -> JWT expiration is checked automatically. `exp` and `role` (database role) keys are mandatory. - -**Authorizing Client Connection**: You can pass in the JWT by following the instructions under the Realtime client lib. For example, refer to the **Usage** section in the [@supabase/realtime-js](https://github.com/supabase/realtime-js) client library. - -## Error Operational Codes - -This is the list of operational codes that can help you understand your deployment and your usage. - -| Code | Description | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| TopicNameRequired | You are trying to use Realtime without a topic name set | -| InvalidJoinPayload | The payload provided to Realtime on connect is invalid | -| RealtimeDisabledForConfiguration | The configuration provided to Realtime on connect will not be able to provide you any Postgres Changes | -| TenantNotFound | The tenant you are trying to connect to does not exist | -| ErrorConnectingToWebsocket | Error when trying to connect to the WebSocket server | -| ErrorAuthorizingWebsocket | Error when trying to authorize the WebSocket connection | -| TableHasSpacesInName | The table you are trying to listen to has spaces in its name which we are unable to support | -| UnableToDeleteTenant | Error when trying to delete a tenant | -| UnableToSetPolicies | Error when setting up Authorization Policies | -| UnableCheckoutConnection | Error when trying to checkout a connection from the tenant pool | -| UnableToSubscribeToPostgres | Error when trying to subscribe to Postgres changes | -| ReconnectSubscribeToPostgres | Postgres changes still waiting to be subscribed | -| ChannelRateLimitReached | The number of channels you can create has reached its limit | -| ConnectionRateLimitReached | The number of connected clients as reached its limit | -| ClientJoinRateLimitReached | The rate of joins per second from your clients has reached the channel limits | -| MessagePerSecondRateLimitReached | The rate of messages per second from your clients has reached the channel limits | -| RealtimeDisabledForTenant | Realtime has been disabled for the tenant | -| UnableToConnectToTenantDatabase | Realtime was not able to connect to the tenant's database | -| DatabaseLackOfConnections | Realtime was not able to connect to the tenant's database due to not having enough available connections | -| RealtimeNodeDisconnected | Realtime is a distributed application and this means that one the system is unable to communicate with one of the distributed nodes | -| MigrationsFailedToRun | Error when running the migrations against the Tenant database that are required by Realtime | -| StartReplicationFailed | Error when starting the replication and listening of errors for database broadcasting | -| ReplicationMaxWalSendersReached | Maximum number of WAL senders reached in tenant database, check how to increase this value in this [link](https://supabase.com/docs/guides/database/custom-postgres-config#cli-configurable-settings) | -| MigrationCheckFailed | Check to see if we require to run migrations fails | -| PartitionCreationFailed | Error when creating partitions for realtime.messages | -| ErrorStartingPostgresCDCStream | Error when starting the Postgres CDC stream which is used for Postgres Changes | -| UnknownDataProcessed | An unknown data type was processed by the Realtime system | -| ErrorStartingPostgresCDC | Error when starting the Postgres CDC extension which is used for Postgres Changes | -| ReplicationSlotBeingUsed | The replication slot is being used by another transaction | -| PoolingReplicationPreparationError | Error when preparing the replication slot | -| PoolingReplicationError | Error when pooling the replication slot | -| SubscriptionDeletionFailed | Error when trying to delete a subscription for postgres changes | -| UnableToDeletePhantomSubscriptions | Error when trying to delete subscriptions that are no longer being used | -| UnableToCheckProcessesOnRemoteNode | Error when trying to check the processes on a remote node | -| UnhandledProcessMessage | Unhandled message received by a Realtime process | -| UnableToTrackPresence | Error when handling track presence for this socket | -| UnknownPresenceEvent | Presence event type not recognized by service | -| IncreaseConnectionPool | The number of connections you have set for Realtime are not enough to handle your current use case | -| RlsPolicyError | Error on RLS policy used for authorization | -| ConnectionInitializing | Database is initializing connection | -| DatabaseConnectionIssue | Database had connection issues and connection was not able to be established | -| UnableToConnectToProject | Unable to connect to Project database | -| InvalidJWTExpiration | JWT exp claim value it's incorrect | -| JwtSignatureError | JWT signature was not able to be validated | -| MalformedJWT | Token received does not comply with the JWT format | -| Unauthorized | Unauthorized access to Realtime channel | -| RealtimeRestarting | Realtime is currently restarting | -| UnableToProcessListenPayload | Payload sent in NOTIFY operation was JSON parsable | -| UnprocessableEntity | Received a HTTP request with a body that was not able to be processed by the endpoint | -| InitializingProjectConnection | Connection against Tenant database is still starting | -| TimeoutOnRpcCall | RPC request within the Realtime server as timed out. | -| ErrorOnRpcCall | Error when calling another realtime node | -| ErrorExecutingTransaction | Error executing a database transaction in tenant database | -| SynInitializationError | Our framework to syncronize processes has failed to properly startup a connection to the database | -| JanitorFailedToDeleteOldMessages | Scheduled task for realtime.message cleanup was unable to run | -| UnableToEncodeJson | An error were we are not handling correctly the response to be sent to the end user | -| UnknownErrorOnController | An error we are not handling correctly was triggered on a controller | -| UnknownErrorOnChannel | An error we are not handling correctly was triggered on a channel | -| PresenceRateLimitReached | Limit of presence events reached | +See [supabase/CODE_OF_CONDUCT.md](https://github.com/supabase/.github/blob/main/CODE_OF_CONDUCT.md) ## License diff --git a/assets/js/app.js b/assets/js/app.js index 9b19c27f5..858de8831 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -8,7 +8,7 @@ import { createClient } from "@supabase/supabase-js"; // LiveView is managing this page because we have Phoenix running // We're using LiveView to handle the Realtime client via LiveView Hooks -let Hooks = {}; +const Hooks = {}; Hooks.payload = { initRealtime( channelName, @@ -24,8 +24,6 @@ Hooks.payload = { private_channel ) { // Instantiate our client with the Realtime server and params to connect with - { - } const opts = { realtime: { params: { @@ -36,17 +34,20 @@ Hooks.payload = { this.realtimeSocket = createClient(host, token, opts); - if (bearer != "") { + if (bearer !== "") { this.realtimeSocket.realtime.setAuth(bearer); } - private_channel = private_channel == "true"; + private_channel = private_channel === "true"; // Join the Channel 'any' // Channels can be named anything // All clients on the same Channel will get messages sent to that Channel this.channel = this.realtimeSocket.channel(channelName, { - config: { broadcast: { self: true, private: private_channel } }, + config: { + broadcast: { self: true }, + private: private_channel, + }, }); // Hack to confirm Postgres is subscribed @@ -55,13 +56,13 @@ Hooks.payload = { if (payload.extension === "postgres_changes" && payload.status === "ok") { this.pushEventTo("#conn_info", "postgres_subscribed", {}); } - let ts = new Date(); - let line = ` + const ts = new Date(); + const line = ` SYSTEM ${ts.toISOString()} ${JSON.stringify(payload)} `; - let list = document.querySelector("#plist"); + const list = document.querySelector("#plist"); list.innerHTML = line + list.innerHTML; }); @@ -69,13 +70,13 @@ Hooks.payload = { // The event name can by anything // Match on specific event names to filter for only those types of events and do something with them this.channel.on("broadcast", { event: "*" }, (payload) => { - let ts = new Date(); - let line = ` + const ts = new Date(); + const line = ` BROADCAST ${ts.toISOString()} ${JSON.stringify(payload)} `; - let list = document.querySelector("#plist"); + const list = document.querySelector("#plist"); list.innerHTML = line + list.innerHTML; }); @@ -85,29 +86,33 @@ Hooks.payload = { this.channel.on("presence", { event: "*" }, (payload) => { this.pushEventTo("#conn_info", "presence_subscribed", {}); - let ts = new Date(); - let line = ` + const ts = new Date(); + const line = ` PRESENCE ${ts.toISOString()} ${JSON.stringify(payload)} `; - let list = document.querySelector("#plist"); + const list = document.querySelector("#plist"); list.innerHTML = line + list.innerHTML; }); } // Listen for all (`*`) `postgres_changes` events on tables in the `public` schema if (enable_db_changes === "true") { - let postgres_changes_opts = { event: "*", schema: schema, table: table }; + const postgres_changes_opts = { + event: "*", + schema: schema, + table: table, + }; if (filter !== "") { postgres_changes_opts.filter = filter; } this.channel.on("postgres_changes", postgres_changes_opts, (payload) => { - let ts = performance.now() + performance.timeOrigin; - let iso_ts = new Date(); - let payload_ts = Date.parse(payload.commit_timestamp); - let latency = ts - payload_ts; - let line = ` + const ts = performance.now() + performance.timeOrigin; + const iso_ts = new Date(); + const payload_ts = Date.parse(payload.commit_timestamp); + const latency = ts - payload_ts; + const line = ` POSTGRES ${iso_ts.toISOString()} @@ -117,7 +122,7 @@ Hooks.payload = { )} ms `; - let list = document.querySelector("#plist"); + const list = document.querySelector("#plist"); list.innerHTML = line + list.innerHTML; }); } @@ -178,10 +183,9 @@ Hooks.payload = { // } if (enable_presence === "true") { const name = "user_name_" + Math.floor(Math.random() * 100); - this.channel.send({ - type: "presence", - event: "TRACK", - payload: { name: name, t: performance.now() }, + await this.channel.track({ + name: name, + t: performance.now(), }); } } else { @@ -214,7 +218,7 @@ Hooks.payload = { }, mounted() { - let params = { + const params = { log_level: localStorage.getItem("log_level"), token: localStorage.getItem("token"), host: localStorage.getItem("host"), @@ -250,9 +254,9 @@ Hooks.payload = { this.sendRealtime(message.event, message.payload) ); - this.handleEvent("disconnect", ({}) => this.disconnectRealtime()); + this.handleEvent("disconnect", () => this.disconnectRealtime()); - this.handleEvent("clear_local_storage", ({}) => this.clearLocalStorage()); + this.handleEvent("clear_local_storage", () => this.clearLocalStorage()); }, }; @@ -266,18 +270,18 @@ Hooks.latency = { }, }; -let csrfToken = document +const csrfToken = document .querySelector("meta[name='csrf-token']") .getAttribute("content"); -let liveSocket = new LiveSocket("/live", Socket, { +const liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks, params: { _csrf_token: csrfToken }, }); topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); -window.addEventListener("phx:page-loading-start", (info) => topbar.show()); -window.addEventListener("phx:page-loading-stop", (info) => topbar.hide()); +window.addEventListener("phx:page-loading-start", () => topbar.show()); +window.addEventListener("phx:page-loading-stop", () => topbar.hide()); liveSocket.connect(); diff --git a/assets/package-lock.json b/assets/package-lock.json index 84debc7bd..abdbe65a6 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -5,140 +5,107 @@ "packages": { "": { "dependencies": { - "@supabase/supabase-js": "^2.50.0" + "@supabase/supabase-js": "2.108.2" } }, "node_modules/@supabase/auth-js": { - "version": "2.70.0", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.70.0.tgz", - "integrity": "sha512-BaAK/tOAZFJtzF1sE3gJ2FwTjLf4ky3PSvcvLGEgEmO4BSBkwWKu8l67rLLIBZPDnCyV7Owk2uPyKHa0kj5QGg==", + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz", + "integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==", + "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.14" + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@supabase/functions-js": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz", - "integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==", - "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/node-fetch": { - "version": "2.6.15", - "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", - "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz", + "integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==", + "license": "MIT", "dependencies": { - "whatwg-url": "^5.0.0" + "tslib": "2.8.1" }, "engines": { - "node": "4.x || >=6.0.0" + "node": ">=20.0.0" } }, + "node_modules/@supabase/phoenix": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", + "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", + "license": "MIT" + }, "node_modules/@supabase/postgrest-js": { - "version": "1.19.4", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz", - "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==", + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz", + "integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==", + "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.14" + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@supabase/realtime-js": { - "version": "2.11.10", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.10.tgz", - "integrity": "sha512-SJKVa7EejnuyfImrbzx+HaD9i6T784khuw1zP+MBD7BmJYChegGxYigPzkKX8CK8nGuDntmeSD3fvriaH0EGZA==", + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz", + "integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==", + "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.13", - "@types/phoenix": "^1.6.6", - "@types/ws": "^8.18.1", - "ws": "^8.18.2" + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@supabase/storage-js": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", - "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz", + "integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==", + "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.14" + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@supabase/supabase-js": { - "version": "2.50.0", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.50.0.tgz", - "integrity": "sha512-M1Gd5tPaaghYZ9OjeO1iORRqbTWFEz/cF3pPubRnMPzA+A8SiUsXXWDP+DWsASZcjEcVEcVQIAF38i5wrijYOg==", - "dependencies": { - "@supabase/auth-js": "2.70.0", - "@supabase/functions-js": "2.4.4", - "@supabase/node-fetch": "2.6.15", - "@supabase/postgrest-js": "1.19.4", - "@supabase/realtime-js": "2.11.10", - "@supabase/storage-js": "2.7.1" - } - }, - "node_modules/@types/node": { - "version": "22.15.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.30.tgz", - "integrity": "sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==", + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz", + "integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==", + "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/phoenix": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", - "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "@supabase/auth-js": "2.108.2", + "@supabase/functions-js": "2.108.2", + "@supabase/postgrest-js": "2.108.2", + "@supabase/realtime-js": "2.108.2", + "@supabase/storage-js": "2.108.2" + }, + "engines": { + "node": ">=20.0.0" } }, - "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node": ">=20.0.0" } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" } } } diff --git a/assets/package.json b/assets/package.json index b718d4593..3133a950f 100644 --- a/assets/package.json +++ b/assets/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "@supabase/supabase-js": "^2.50.0" + "@supabase/supabase-js": "2.108.2" } -} \ No newline at end of file +} diff --git a/compose.dbs.yml b/compose.dbs.yml new file mode 100644 index 000000000..97cc861bd --- /dev/null +++ b/compose.dbs.yml @@ -0,0 +1,34 @@ +services: + db: + image: ${POSTGRES_IMAGE:-supabase/postgres:17.6.1.127} + ports: + - "5432:5432" + volumes: + - ./dev/postgres/za-permit-supabase-admin.sh:/docker-entrypoint-initdb.d/za-permit-supabase-admin.sh + - ./dev/postgres/zb-supabase-schema.sql:/docker-entrypoint-initdb.d/zb-supabase-schema.sql + command: postgres -c config_file=/etc/postgresql/postgresql.conf + environment: + POSTGRES_HOST: /var/run/postgresql + POSTGRES_PASSWORD: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + tenant_db: + image: ${POSTGRES_IMAGE:-supabase/postgres:17.6.1.127} + ports: + - "5433:5432" + volumes: + - ./dev/postgres/za-permit-supabase-admin.sh:/docker-entrypoint-initdb.d/za-permit-supabase-admin.sh + - ./dev/postgres/zb-supabase-schema.sql:/docker-entrypoint-initdb.d/zb-supabase-schema.sql + command: postgres -c config_file=/etc/postgresql/postgresql.conf + environment: + POSTGRES_HOST: /var/run/postgresql + POSTGRES_PASSWORD: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/compose.tests.yml b/compose.tests.yml new file mode 100644 index 000000000..a4a092892 --- /dev/null +++ b/compose.tests.yml @@ -0,0 +1,87 @@ +services: + # Supabase Realtime service + test_db: + image: ${POSTGRES_IMAGE:-supabase/postgres:17.6.1.127} + container_name: test-realtime-db + ports: + - "5532:5432" + volumes: + - ./dev/postgres/za-permit-supabase-admin.sh:/docker-entrypoint-initdb.d/za-permit-supabase-admin.sh + - ./dev/postgres/zb-supabase-schema.sql:/docker-entrypoint-initdb.d/zb-supabase-schema.sql + command: postgres -c config_file=/etc/postgresql/postgresql.conf + environment: + POSTGRES_HOST: /var/run/postgresql + POSTGRES_PASSWORD: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + test_realtime: + depends_on: + - test_db + build: . + container_name: test-realtime-server + ports: + - "4100:4100" + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + PORT: 4100 + DB_HOST: host.docker.internal + DB_PORT: 5532 + DB_USER: ${DB_USER:-supabase_admin} + DB_USER_REALTIME: ${DB_USER_REALTIME:-supabase_realtime_admin} + DB_PASSWORD: postgres + DB_NAME: postgres + DB_ENC_KEY: 1234567890123456 + DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' + API_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long + METRICS_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long + SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + ERL_AFLAGS: -proto_dist inet_tcp + DNS_NODES: "''" + APP_NAME: realtime + RUN_JANITOR: true + JANITOR_INTERVAL: 60000 + LOG_LEVEL: "info" + SEED_SELF_HOST: true + DASHBOARD_USER: admin + DASHBOARD_PASSWORD: admin + networks: + test-network: + aliases: + - realtime-dev.local + - realtime-dev.localhost + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4100/"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + + # Deno test runner + test-runner: + image: denoland/deno:alpine-2.5.6 + container_name: deno-test-runner + depends_on: + test_realtime: + condition: service_healthy + test_db: + condition: service_healthy + volumes: + - ./test/integration/tests.ts:/app/tests.ts:ro + working_dir: /app + command: > + sh -c " + echo 'Running tests...' && + deno test tests.ts --allow-import --no-check --allow-read --allow-net --trace-leaks --allow-env=WS_NO_BUFFER_UTIL + " + networks: + - test-network + extra_hosts: + - "realtime-dev.localhost:host-gateway" + +networks: + test-network: + driver: bridge diff --git a/compose.yml b/compose.yml new file mode 100644 index 000000000..3b2e65919 --- /dev/null +++ b/compose.yml @@ -0,0 +1,36 @@ +include: + - compose.dbs.yml + +services: + realtime: + depends_on: + - db + - tenant_db + build: . + container_name: realtime-server + environment: + API_JWT_SECRET: dev + APP_NAME: realtime + DASHBOARD_PASSWORD: realtime + DASHBOARD_USER: realtime + DB_AFTER_CONNECT_QUERY: SET search_path TO _realtime + DB_ENC_KEY: "1234567890123456" + DB_HOST: db + DNS_NODES: "''" + ELIXIR_ERL_OPTIONS: +hmax 1000000000 + ERL_AFLAGS: -kernel shell_history enabled --proto_dist inet_tcp + GEN_RPC_TCP_CLIENT_PORT: 5469 + GEN_RPC_TCP_SERVER_PORT: 5369 + METRICS_JWT_SECRET: dev + NAME: pink + PORT: 4000 + REGION: us-east-1 + RLIMIT_NOFILE: 1000000 + RUN_JANITOR: "true" + SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + SEED_SELF_HOST: "true" + SLOT_NAME_SUFFIX: some_sha + ports: + - "${PORT:-4000}:${PORT:-4000}" + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/config/config.exs b/config/config.exs index cada8230f..76215644e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -8,8 +8,11 @@ import Config config :realtime, + websocket_fullsweep_after: 20, ecto_repos: [Realtime.Repo], - version: Mix.Project.config()[:version] + version: Mix.Project.config()[:version], + replication_watchdog_interval: :timer.minutes(5), + replication_watchdog_timeout: :timer.minutes(1) # Configures the endpoint config :realtime, RealtimeWeb.Endpoint, @@ -76,10 +79,15 @@ config :opentelemetry, span_processor: :batch config :gen_rpc, + extra_process_flags: [fullsweep_after: 20], # Inactivity period in milliseconds after which a pending process holding an async_call return value will exit. # This is used for process sanitation purposes so please make sure to set it in a sufficiently high number async_call_inactivity_timeout: 300_000 +config :prom_ex, :storage_adapter, Realtime.PromEx.Store +config :realtime, Realtime.PromEx, ets_flush_interval: 90_000 +config :realtime, Realtime.TenantPromEx, ets_flush_interval: 90_000 + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index a438f8ea4..9694bb350 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -12,7 +12,8 @@ import Config presence = System.get_env("PRESENCE", "false") == "false" config :realtime, - presence: presence + presence: presence, + node_balance_uptime_threshold_in_ms: 100 config :realtime, RealtimeWeb.Endpoint, http: [port: System.get_env("PORT", "4000"), compress: true], @@ -97,6 +98,8 @@ config :phoenix, :plug_init_mode, :runtime # Disable caching to ensure the rendered spec is refreshed config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache -config :opentelemetry, traces_exporter: {:otel_exporter_stdout, []} +# Disabled but can print to stdout with: +# config :opentelemetry, traces_exporter: {:otel_exporter_stdout, []} +config :opentelemetry, traces_exporter: :none config :mix_test_watch, clear: true diff --git a/config/prod.exs b/config/prod.exs index bcfc25bc9..146420af9 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -22,6 +22,7 @@ config :logger, :warning, :project, :external_id, :application_name, + :cluster, :region, :request_id, :sub, diff --git a/config/runtime.exs b/config/runtime.exs index 39310f093..a820957f8 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -1,111 +1,191 @@ import Config +alias Realtime.Env -defmodule Env do - def get_integer(env, default) do - value = System.get_env(env) - if value, do: String.to_integer(env), else: default - end - - def get_charlist(env, default) do - value = System.get_env(env) - if value, do: String.to_charlist(env), else: default - end - - def get_boolean(env, default) do - value = System.get_env(env) - if value, do: String.to_existing_atom(value), else: default - end -end +api_jwt_secret = + ["API_JWT_SECRET", "API_JWT_SECRET_NEXT"] + |> Enum.map(&System.get_env/1) + |> Enum.reject(&is_nil/1) +api_token_blocklist = Env.get_list("API_TOKEN_BLOCKLIST", []) app_name = System.get_env("APP_NAME", "") - -# Setup Database -default_db_host = System.get_env("DB_HOST", "127.0.0.1") -username = System.get_env("DB_USER", "postgres") -password = System.get_env("DB_PASSWORD", "postgres") -database = System.get_env("DB_NAME", "postgres") -port = System.get_env("DB_PORT", "5432") -db_version = System.get_env("DB_IP_VERSION") -slot_name_suffix = System.get_env("SLOT_NAME_SUFFIX") -db_ssl_enabled? = Env.get_boolean("DB_SSL", false) +broadcast_pool_size = Env.get_integer("BROADCAST_POOL_SIZE", 10) +channel_error_backoff_ms = Env.get_integer("CHANNEL_ERROR_BACKOFF_MS", :timer.seconds(5)) +client_presence_max_calls = Env.get_integer("CLIENT_PRESENCE_MAX_CALLS", 5) +client_presence_window_ms = Env.get_integer("CLIENT_PRESENCE_WINDOW_MS", 30_000) +connect_error_backoff_ms = Env.get_integer("CONNECT_ERROR_BACKOFF_MS", :timer.seconds(2)) +connect_partition_slots = Env.get_integer("CONNECT_PARTITION_SLOTS", System.schedulers_online() * 2) +dashboard_auth = System.get_env("DASHBOARD_AUTH", "basic_auth") +dashboard_password = System.get_env("DASHBOARD_PASSWORD", :crypto.strong_rand_bytes(12) |> Base.encode16(case: :lower)) +dashboard_user = System.get_env("DASHBOARD_USER", :crypto.strong_rand_bytes(12) |> Base.encode16(case: :lower)) +db_after_connect_query = System.get_env("DB_AFTER_CONNECT_QUERY") +db_enc_key = System.get_env("DB_ENC_KEY") +db_host = System.get_env("DB_HOST", "127.0.0.1") +db_ip_version = System.get_env("DB_IP_VERSION") +db_master_region = System.get_env("DB_MASTER_REGION") +db_name = System.get_env("DB_NAME", "postgres") +db_password = System.get_env("DB_PASSWORD", "postgres") +db_pool_size = Env.get_integer("DB_POOL_SIZE", 5) +db_port = System.get_env("DB_PORT", "5432") +db_queue_interval = Env.get_integer("DB_QUEUE_INTERVAL", 5000) +db_queue_target = Env.get_integer("DB_QUEUE_TARGET", 5000) +db_replica_host = System.get_env("DB_REPLICA_HOST") +db_replica_pool_size = Env.get_integer("DB_REPLICA_POOL_SIZE", 5) +db_ssl = Env.get_boolean("DB_SSL", false) db_ssl_ca_cert = System.get_env("DB_SSL_CA_CERT") -queue_target = Env.get_integer("DB_QUEUE_TARGET", 5000) -queue_interval = Env.get_integer("DB_QUEUE_INTERVAL", 5000) -pool_size = Env.get_integer("DB_POOL_SIZE", 5) +db_user = System.get_env("DB_USER", "supabase_realtime_admin") +disable_healthcheck_logging = Env.get_boolean("DISABLE_HEALTHCHECK_LOGGING", false) +dns_nodes = System.get_env("DNS_NODES") +gen_rpc_compress = Env.get_integer("GEN_RPC_COMPRESS", 0) +gen_rpc_compression_threshold_in_bytes = Env.get_integer("GEN_RPC_COMPRESSION_THRESHOLD_IN_BYTES", 1000) +gen_rpc_connect_timeout_in_ms = Env.get_integer("GEN_RPC_CONNECT_TIMEOUT_IN_MS", 10_000) +gen_rpc_ipv6_only = Env.get_boolean("GEN_RPC_IPV6_ONLY", false) +gen_rpc_max_batch_size = Env.get_integer("GEN_RPC_MAX_BATCH_SIZE", 0) +gen_rpc_send_timeout_in_ms = Env.get_integer("GEN_RPC_SEND_TIMEOUT_IN_MS", 10_000) +gen_rpc_socket_buffer = Env.get_integer("GEN_RPC_SOCKET_BUFFER") +gen_rpc_socket_ip = Env.get_charlist("GEN_RPC_SOCKET_IP", ~c"0.0.0.0") +gen_rpc_socket_recbuf = Env.get_integer("GEN_RPC_SOCKET_RECEIVE_BUFFER") +gen_rpc_socket_sndbuf = Env.get_integer("GEN_RPC_SOCKET_SEND_BUFFER") +gen_rpc_ssl_client_port = Env.get_integer("GEN_RPC_SSL_CLIENT_PORT", 6369) +gen_rpc_ssl_server_port = Env.get_integer("GEN_RPC_SSL_SERVER_PORT") +gen_rpc_tcp_client_port = Env.get_integer("GEN_RPC_TCP_CLIENT_PORT", 5369) +gen_rpc_tcp_server_port = Env.get_integer("GEN_RPC_TCP_SERVER_PORT", 5369) +http_dynamic_buffer_min = Env.get_integer("HTTP_DYNAMIC_BUFFER_MIN") +http_dynamic_buffer_max = Env.get_integer("HTTP_DYNAMIC_BUFFER_MAX") +janitor_children_timeout = Env.get_integer("JANITOR_CHILDREN_TIMEOUT", :timer.seconds(5)) +janitor_chunk_size = Env.get_integer("JANITOR_CHUNK_SIZE", 10) +janitor_max_children = Env.get_integer("JANITOR_MAX_CHILDREN", 5) +janitor_run_after_in_ms = Env.get_integer("JANITOR_RUN_AFTER_IN_MS", :timer.minutes(10)) +janitor_schedule_randomize = Env.get_boolean("JANITOR_SCHEDULE_RANDOMIZE", true) +janitor_schedule_timer_in_ms = Env.get_integer("JANITOR_SCHEDULE_TIMER_IN_MS", :timer.hours(4)) +jwt_claim_validators = System.get_env("JWT_CLAIM_VALIDATORS", "{}") +log_level = System.get_env("LOG_LEVEL", "info") |> String.to_existing_atom() +log_throttle_janitor_interval_in_ms = Env.get_integer("LOG_THROTTLE_JANITOR_INTERVAL_IN_MS", :timer.minutes(10)) +logflare_logger_backend_url = System.get_env("LOGFLARE_LOGGER_BACKEND_URL", "https://api.logflare.app") +logs_engine = System.get_env("LOGS_ENGINE") +max_gen_rpc_clients = Env.get_integer("MAX_GEN_RPC_CLIENTS", 5) +max_gen_rpc_call_clients = Env.get_integer("MAX_GEN_RPC_CALL_CLIENTS", 1) +measure_traffic_interval_in_ms = Env.get_integer("MEASURE_TRAFFIC_INTERVAL_IN_MS", :timer.seconds(10)) +metrics_cleaner_schedule_timer_in_ms = Env.get_integer("METRICS_CLEANER_SCHEDULE_TIMER_IN_MS", :timer.minutes(30)) +metrics_pusher_auth = System.get_env("METRICS_PUSHER_AUTH") +metrics_pusher_compress = Env.get_boolean("METRICS_PUSHER_COMPRESS", true) +metrics_pusher_enabled = Env.get_boolean("METRICS_PUSHER_ENABLED", false) +metrics_pusher_extra_labels = System.get_env("METRICS_PUSHER_EXTRA_LABELS", "") +metrics_pusher_interval_ms = Env.get_integer("METRICS_PUSHER_INTERVAL_MS", :timer.seconds(30)) +metrics_pusher_timeout_ms = Env.get_integer("METRICS_PUSHER_TIMEOUT_MS", :timer.seconds(15)) +metrics_pusher_url = System.get_env("METRICS_PUSHER_URL") +metrics_pusher_user = System.get_env("METRICS_PUSHER_USER", "realtime") +metrics_rpc_timeout_in_ms = Env.get_integer("METRICS_RPC_TIMEOUT_IN_MS", :timer.seconds(15)) +metrics_token_blocklist = Env.get_list("METRICS_TOKEN_BLOCKLIST", []) +migration_partition_slots = Env.get_integer("MIGRATION_PARTITION_SLOTS", System.schedulers_online() * 2) +no_channel_timeout_in_ms = Env.get_integer("NO_CHANNEL_TIMEOUT_IN_MS", :timer.minutes(10)) +node_balance_uptime_threshold_in_ms = Env.get_integer("NODE_BALANCE_UPTIME_THRESHOLD_IN_MS", :timer.minutes(5)) +platform = if System.get_env("AWS_EXECUTION_ENV") == "AWS_ECS_FARGATE", do: :aws, else: :fly +postgres_cdc_scope_shards = Env.get_integer("POSTGRES_CDC_SCOPE_SHARDS", 5) +presence_broadcast_period_in_ms = Env.get_integer("PRESENCE_BROADCAST_PERIOD_IN_MS", 1_500) +presence_permdown_period_in_ms = Env.get_integer("PRESENCE_PERMDOWN_PERIOD_IN_MS", 1_200_000) +presence_pool_size = Env.get_integer("PRESENCE_POOL_SIZE", 10) +prom_poll_rate = Env.get_integer("PROM_POLL_RATE", 5000) +realtime_ip_version = System.get_env("REALTIME_IP_VERSION") +rebalance_check_interval_in_ms = Env.get_integer("REBALANCE_CHECK_INTERVAL_IN_MS", :timer.minutes(10)) +region = System.get_env("REGION") +region_mapping = System.get_env("REGION_MAPPING") +request_id_baggage_key = System.get_env("REQUEST_ID_BAGGAGE_KEY", "request-id") +rpc_timeout = Env.get_integer("RPC_TIMEOUT", :timer.seconds(30)) +run_janitor = Env.get_boolean("RUN_JANITOR", false) +slot_name_suffix = System.get_env("SLOT_NAME_SUFFIX") +tenant_cache_expiration_in_ms = Env.get_integer("TENANT_CACHE_EXPIRATION_IN_MS", :timer.seconds(30)) +tenant_max_bytes_per_second = Env.get_integer("TENANT_MAX_BYTES_PER_SECOND", 100_000) +tenant_max_channels_per_client = Env.get_integer("TENANT_MAX_CHANNELS_PER_CLIENT", 100) +tenant_max_concurrent_users = Env.get_integer("TENANT_MAX_CONCURRENT_USERS", 200) +tenant_max_events_per_second = Env.get_integer("TENANT_MAX_EVENTS_PER_SECOND", 100) +tenant_max_joins_per_second = Env.get_integer("TENANT_MAX_JOINS_PER_SECOND", 100) +users_scope_shards = Env.get_integer("USERS_SCOPE_SHARDS", 5) +websocket_max_heap_size = div(Env.get_integer("WEBSOCKET_MAX_HEAP_SIZE", 50_000_000), :erlang.system_info(:wordsize)) + +cluster_strategies = + Env.get_binary("CLUSTER_STRATEGIES", fn -> + case config_env() do + :prod -> "POSTGRES" + _ -> "EPMD" + end + end) + +metrics_jwt_secret = + if config_env() == :test do + System.get_env("METRICS_JWT_SECRET") + else + System.fetch_env!("METRICS_JWT_SECRET") + end after_connect_query_args = - case System.get_env("DB_AFTER_CONNECT_QUERY") do + case db_after_connect_query do nil -> nil query -> {Postgrex, :query!, [query, []]} end ssl_opts = cond do - db_ssl_enabled? and is_binary(db_ssl_ca_cert) -> [cacertfile: db_ssl_ca_cert] - db_ssl_enabled? -> [verify: :verify_none] + db_ssl and is_binary(db_ssl_ca_cert) -> [cacertfile: db_ssl_ca_cert] + db_ssl -> [verify: :verify_none] true -> false end -tenant_cache_expiration = Env.get_integer("TENANT_CACHE_EXPIRATION_IN_MS", :timer.seconds(30)) -migration_partition_slots = Env.get_integer("MIGRATION_PARTITION_SLOTS", System.schedulers_online() * 2) -connect_partition_slots = Env.get_integer("CONNECT_PARTITION_SLOTS", System.schedulers_online() * 2) -metrics_cleaner_schedule_timer_in_ms = Env.get_integer("METRICS_CLEANER_SCHEDULE_TIMER_IN_MS", :timer.minutes(30)) -metrics_rpc_timeout_in_ms = Env.get_integer("METRICS_RPC_TIMEOUT_IN_MS", :timer.seconds(15)) -rebalance_check_interval_in_ms = Env.get_integer("REBALANCE_CHECK_INTERVAL_IN_MS", :timer.minutes(10)) -tenant_max_bytes_per_second = Env.get_integer("TENANT_MAX_BYTES_PER_SECOND", 100_000) -tenant_max_channels_per_client = Env.get_integer("TENANT_MAX_CHANNELS_PER_CLIENT", 100) -tenant_max_concurrent_users = Env.get_integer("TENANT_MAX_CONCURRENT_USERS", 200) -tenant_max_events_per_second = Env.get_integer("TENANT_MAX_EVENTS_PER_SECOND", 100) -tenant_max_joins_per_second = Env.get_integer("TENANT_MAX_JOINS_PER_SECOND", 100) -rpc_timeout = Env.get_integer("RPC_TIMEOUT", :timer.seconds(30)) -max_gen_rpc_clients = Env.get_integer("MAX_GEN_RPC_CLIENTS", 5) -run_janitor? = Env.get_boolean("RUN_JANITOR", false) -janitor_schedule_randomize = Env.get_boolean("JANITOR_SCHEDULE_RANDOMIZE", true) -janitor_max_children = Env.get_integer("JANITOR_MAX_CHILDREN", 5) -janitor_chunk_size = Env.get_integer("JANITOR_CHUNK_SIZE", 10) -janitor_run_after_in_ms = Env.get_integer("JANITOR_RUN_AFTER_IN_MS", :timer.minutes(10)) -janitor_children_timeout = Env.get_integer("JANITOR_CHILDREN_TIMEOUT", :timer.seconds(5)) -janitor_schedule_timer = Env.get_integer("JANITOR_SCHEDULE_TIMER_IN_MS", :timer.hours(4)) -platform = if System.get_env("AWS_EXECUTION_ENV") == "AWS_ECS_FARGATE", do: :aws, else: :fly - -no_channel_timeout_in_ms = - if config_env() == :test, - do: :timer.seconds(3), - else: Env.get_integer("NO_CHANNEL_TIMEOUT_IN_MS", :timer.minutes(10)) +metrics_pusher_extra_labels = + case metrics_pusher_extra_labels do + "" -> + [] + + labels -> + labels + |> String.split(",") + |> Enum.map(fn pair -> + [k, v] = String.split(pair, "=", parts: 2) + {k, v} + end) + end -if !(db_version in [nil, "ipv6", "ipv4"]), +if !(db_ip_version in [nil, "ipv6", "ipv4"]), do: raise("Invalid IP version, please set either ipv6 or ipv4") socket_options = cond do - db_version == "ipv6" -> + db_ip_version == "ipv6" -> [:inet6] - db_version == "ipv4" -> + db_ip_version == "ipv4" -> [:inet] true -> - case Realtime.Database.detect_ip_version(default_db_host) do + case Realtime.Database.detect_ip_version(db_host) do {:ok, ip_version} -> [ip_version] {:error, reason} -> raise "Failed to detect IP version for DB_HOST: #{reason}" end end +[_, node_host] = node() |> Atom.to_string() |> String.split("@") + +metrics_tags = %{ + region: region, + host: node_host, + id: Realtime.Nodes.short_node_id_from_name(node()) +} + config :realtime, Realtime.Repo, - hostname: default_db_host, - username: username, - password: password, - database: database, - port: port, - pool_size: pool_size, - queue_target: queue_target, - queue_interval: queue_interval, + hostname: db_host, + username: db_user, + password: db_password, + database: db_name, + port: db_port, + pool_size: db_pool_size, + queue_target: db_queue_target, + queue_interval: db_queue_interval, parameters: [application_name: "supabase_mt_realtime"], after_connect: after_connect_query_args, socket_options: socket_options, ssl: ssl_opts config :realtime, + websocket_max_heap_size: websocket_max_heap_size, migration_partition_slots: migration_partition_slots, connect_partition_slots: connect_partition_slots, rebalance_check_interval_in_ms: rebalance_check_interval_in_ms, @@ -116,13 +196,36 @@ config :realtime, tenant_max_joins_per_second: tenant_max_joins_per_second, metrics_cleaner_schedule_timer_in_ms: metrics_cleaner_schedule_timer_in_ms, metrics_rpc_timeout: metrics_rpc_timeout_in_ms, - tenant_cache_expiration: tenant_cache_expiration, + tenant_cache_expiration: tenant_cache_expiration_in_ms, rpc_timeout: rpc_timeout, - max_gen_rpc_clients: max_gen_rpc_clients, no_channel_timeout_in_ms: no_channel_timeout_in_ms, - platform: platform - -if config_env() != :test && run_janitor? do + platform: platform, + broadcast_pool_size: broadcast_pool_size, + presence_pool_size: presence_pool_size, + presence_broadcast_period: presence_broadcast_period_in_ms, + presence_permdown_period: presence_permdown_period_in_ms, + users_scope_shards: users_scope_shards, + postgres_cdc_scope_shards: postgres_cdc_scope_shards, + master_region: db_master_region, + region_mapping: region_mapping, + metrics_tags: metrics_tags, + measure_traffic_interval_in_ms: measure_traffic_interval_in_ms, + client_presence_rate_limit: [ + max_calls: client_presence_max_calls, + window_ms: client_presence_window_ms + ], + log_throttle_janitor_interval_ms: log_throttle_janitor_interval_in_ms, + disable_healthcheck_logging: disable_healthcheck_logging, + metrics_pusher_enabled: metrics_pusher_enabled, + metrics_pusher_url: metrics_pusher_url, + metrics_pusher_user: metrics_pusher_user, + metrics_pusher_auth: metrics_pusher_auth, + metrics_pusher_interval_ms: metrics_pusher_interval_ms, + metrics_pusher_timeout_ms: metrics_pusher_timeout_ms, + metrics_pusher_compress: metrics_pusher_compress, + metrics_pusher_extra_labels: metrics_pusher_extra_labels + +if config_env() != :test && run_janitor do config :realtime, run_janitor: true, janitor_schedule_randomize: janitor_schedule_randomize, @@ -130,17 +233,11 @@ if config_env() != :test && run_janitor? do janitor_chunk_size: janitor_chunk_size, janitor_run_after_in_ms: janitor_run_after_in_ms, janitor_children_timeout: janitor_children_timeout, - janitor_schedule_timer: janitor_schedule_timer + janitor_schedule_timer: janitor_schedule_timer_in_ms end -default_cluster_strategy = - case config_env() do - :prod -> "POSTGRES" - _ -> "EPMD" - end - cluster_topologies = - System.get_env("CLUSTER_STRATEGIES", default_cluster_strategy) + cluster_strategies |> String.upcase() |> String.split(",") |> Enum.reduce([], fn strategy, acc -> @@ -151,7 +248,7 @@ cluster_topologies = [ dns: [ strategy: Cluster.Strategy.DNSPoll, - config: [polling_interval: 5_000, query: System.get_env("DNS_NODES"), node_basename: app_name] + config: [polling_interval: 5_000, query: dns_nodes, node_basename: app_name] ] ] ++ acc @@ -160,11 +257,11 @@ cluster_topologies = postgres: [ strategy: LibclusterPostgres.Strategy, config: [ - hostname: default_db_host, - username: username, - password: password, - database: database, - port: port, + hostname: db_host, + username: db_user, + password: db_password, + database: db_name, + port: db_port, parameters: [application_name: "cluster_node_#{node()}"], socket_options: socket_options, ssl: ssl_opts, @@ -190,8 +287,8 @@ cluster_topologies = # Setup Logging -if System.get_env("LOGS_ENGINE") == "logflare" do - config :logflare_logger_backend, url: System.get_env("LOGFLARE_LOGGER_BACKEND_URL", "https://api.logflare.app") +if logs_engine == "logflare" do + config :logflare_logger_backend, url: logflare_logger_backend_url if !System.get_env("LOGFLARE_API_KEY") or !System.get_env("LOGFLARE_SOURCE_ID") do raise """ @@ -208,15 +305,6 @@ end # Setup production and development environments if config_env() != :test do - gen_rpc_socket_ip = System.get_env("GEN_RPC_SOCKET_IP", "0.0.0.0") |> to_charlist() - - gen_rpc_ssl_server_port = System.get_env("GEN_RPC_SSL_SERVER_PORT") - - gen_rpc_ssl_server_port = - if gen_rpc_ssl_server_port do - String.to_integer(gen_rpc_ssl_server_port) - end - gen_rpc_default_driver = if gen_rpc_ssl_server_port, do: :ssl, else: :tcp if gen_rpc_default_driver == :ssl do @@ -228,7 +316,7 @@ if config_env() != :test do config :gen_rpc, ssl_server_port: gen_rpc_ssl_server_port, - ssl_client_port: System.get_env("GEN_RPC_SSL_CLIENT_PORT", "6369") |> String.to_integer(), + ssl_client_port: gen_rpc_ssl_client_port, ssl_client_options: gen_rpc_ssl_opts, ssl_server_options: gen_rpc_ssl_opts, tcp_server_port: false, @@ -237,21 +325,32 @@ if config_env() != :test do config :gen_rpc, ssl_server_port: false, ssl_client_port: false, - tcp_server_port: System.get_env("GEN_RPC_TCP_SERVER_PORT", "5369") |> String.to_integer(), - tcp_client_port: System.get_env("GEN_RPC_TCP_CLIENT_PORT", "5369") |> String.to_integer() + tcp_server_port: gen_rpc_tcp_server_port, + tcp_client_port: gen_rpc_tcp_client_port end case :inet.parse_address(gen_rpc_socket_ip) do {:ok, address} -> config :gen_rpc, default_client_driver: gen_rpc_default_driver, - connect_timeout: System.get_env("GEN_RPC_CONNECT_TIMEOUT_IN_MS", "10000") |> String.to_integer(), - send_timeout: System.get_env("GEN_RPC_SEND_TIMEOUT_IN_MS", "10000") |> String.to_integer(), - ipv6_only: System.get_env("GEN_RPC_IPV6_ONLY", "false") == "true", + connect_timeout: gen_rpc_connect_timeout_in_ms, + send_timeout: gen_rpc_send_timeout_in_ms, + ipv6_only: gen_rpc_ipv6_only, socket_ip: address, - max_batch_size: System.get_env("GEN_RPC_MAX_BATCH_SIZE", "0") |> String.to_integer(), - compress: System.get_env("GEN_RPC_COMPRESS", "0") |> String.to_integer(), - compression_threshold: System.get_env("GEN_RPC_COMPRESSION_THRESHOLD_IN_BYTES", "1000") |> String.to_integer() + max_batch_size: gen_rpc_max_batch_size, + compress: gen_rpc_compress, + compression_threshold: gen_rpc_compression_threshold_in_bytes + + [ + socket_buffer: gen_rpc_socket_buffer, + socket_recbuf: gen_rpc_socket_recbuf, + socket_sndbuf: gen_rpc_socket_sndbuf + ] + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> case do + [] -> :ok + opts -> config(:gen_rpc, opts) + end _ -> raise """ @@ -260,41 +359,73 @@ if config_env() != :test do """ end - config :logger, level: System.get_env("LOG_LEVEL", "info") |> String.to_existing_atom() + config :logger, level: log_level config :realtime, - request_id_baggage_key: System.get_env("REQUEST_ID_BAGGAGE_KEY", "request-id"), - jwt_claim_validators: System.get_env("JWT_CLAIM_VALIDATORS", "{}"), - api_jwt_secret: System.get_env("API_JWT_SECRET"), - api_blocklist: System.get_env("API_TOKEN_BLOCKLIST", "") |> String.split(","), - metrics_blocklist: System.get_env("METRICS_TOKEN_BLOCKLIST", "") |> String.split(","), - metrics_jwt_secret: System.get_env("METRICS_JWT_SECRET"), - db_enc_key: System.get_env("DB_ENC_KEY"), - region: System.get_env("REGION"), - prom_poll_rate: Env.get_integer("PROM_POLL_RATE", 5000), - slot_name_suffix: slot_name_suffix + request_id_baggage_key: request_id_baggage_key, + jwt_claim_validators: jwt_claim_validators, + api_jwt_secret: api_jwt_secret, + api_blocklist: api_token_blocklist, + metrics_blocklist: metrics_token_blocklist, + metrics_jwt_secret: metrics_jwt_secret, + db_enc_key: db_enc_key, + region: region, + prom_poll_rate: prom_poll_rate, + slot_name_suffix: slot_name_suffix, + max_gen_rpc_clients: max_gen_rpc_clients, + max_gen_rpc_call_clients: max_gen_rpc_call_clients, + connect_error_backoff_ms: connect_error_backoff_ms, + channel_error_backoff_ms: channel_error_backoff_ms end # Setup Production if config_env() == :prod do config :libcluster, debug: false, topologies: cluster_topologies + config :realtime, node_balance_uptime_threshold_in_ms: node_balance_uptime_threshold_in_ms secret_key_base = System.fetch_env!("SECRET_KEY_BASE") if app_name == "", do: raise("APP_NAME not available") + realtime_ip_version = + case realtime_ip_version do + "ipv6" -> + :inet6 + + "ipv4" -> + :inet + + _ -> + case :gen_tcp.listen(0, [:inet6]) do + {:ok, socket} -> + :gen_tcp.close(socket) + :inet6 + + {:error, _} -> + :inet + end + end + + http_dynamic_buffer = + case {http_dynamic_buffer_min, http_dynamic_buffer_max} do + {nil, nil} -> [] + {min, max} when is_integer(min) and is_integer(max) -> [dynamic_buffer: {min, max}] + _ -> raise ArgumentError, "HTTP_DYNAMIC_BUFFER_MIN and HTTP_DYNAMIC_BUFFER_MAX must both be set or both be unset" + end + + http_protocol_options = + [max_header_value_length: Env.get_integer("MAX_HEADER_LENGTH", 4096)] ++ http_dynamic_buffer + config :realtime, RealtimeWeb.Endpoint, server: true, url: [host: "#{app_name}.supabase.co", port: 443], http: [ compress: true, port: Env.get_integer("PORT", 4000), - protocol_options: [ - max_header_value_length: Env.get_integer("MAX_HEADER_LENGTH", 4096) - ], + protocol_options: http_protocol_options, transport_options: [ max_connections: Env.get_integer("MAX_CONNECTIONS", 1000), num_acceptors: Env.get_integer("NUM_ACCEPTORS", 100), - socket_opts: [:inet6] + socket_opts: [realtime_ip_version] ] ], check_origin: false, @@ -303,29 +434,48 @@ if config_env() == :prod do alias Realtime.Repo.Replica replica_repos = %{ - Realtime.Repo.Replica.FRA => System.get_env("DB_HOST_REPLICA_FRA", default_db_host), - Realtime.Repo.Replica.IAD => System.get_env("DB_HOST_REPLICA_IAD", default_db_host), - Realtime.Repo.Replica.SIN => System.get_env("DB_HOST_REPLICA_SIN", default_db_host), - Realtime.Repo.Replica.SJC => System.get_env("DB_HOST_REPLICA_SJC", default_db_host), - Realtime.Repo.Replica.Singapore => System.get_env("DB_HOST_REPLICA_SIN", default_db_host), - Realtime.Repo.Replica.London => System.get_env("DB_HOST_REPLICA_FRA", default_db_host), - Realtime.Repo.Replica.NorthVirginia => System.get_env("DB_HOST_REPLICA_IAD", default_db_host), - Realtime.Repo.Replica.Oregon => System.get_env("DB_HOST_REPLICA_SJC", default_db_host), - Realtime.Repo.Replica.SanJose => System.get_env("DB_HOST_REPLICA_SJC", default_db_host), - Realtime.Repo.Replica.Local => default_db_host + Realtime.Repo.Replica.FRA => System.get_env("DB_HOST_REPLICA_FRA", db_host), + Realtime.Repo.Replica.IAD => System.get_env("DB_HOST_REPLICA_IAD", db_host), + Realtime.Repo.Replica.SIN => System.get_env("DB_HOST_REPLICA_SIN", db_host), + Realtime.Repo.Replica.SJC => System.get_env("DB_HOST_REPLICA_SJC", db_host), + Realtime.Repo.Replica.Singapore => System.get_env("DB_HOST_REPLICA_SIN", db_host), + Realtime.Repo.Replica.London => System.get_env("DB_HOST_REPLICA_FRA", db_host), + Realtime.Repo.Replica.NorthVirginia => System.get_env("DB_HOST_REPLICA_IAD", db_host), + Realtime.Repo.Replica.Oregon => System.get_env("DB_HOST_REPLICA_SJC", db_host), + Realtime.Repo.Replica.SanJose => System.get_env("DB_HOST_REPLICA_SJC", db_host), + Realtime.Repo.Replica.Local => db_host } + # Legacy repos # username, password, database, and port must match primary credentials for {replica_repo, hostname} <- replica_repos do config :realtime, replica_repo, hostname: hostname, - username: username, - password: password, - database: database, - port: port, - pool_size: System.get_env("DB_REPLICA_POOL_SIZE", "5") |> String.to_integer(), - queue_target: queue_target, - queue_interval: queue_interval, + username: db_user, + password: db_password, + database: db_name, + port: db_port, + pool_size: db_replica_pool_size, + queue_target: db_queue_target, + queue_interval: db_queue_interval, + parameters: [ + application_name: "supabase_mt_realtime_ro" + ], + socket_options: socket_options, + ssl: ssl_opts + end + + # New main replica repo + if db_replica_host do + config :realtime, Realtime.Repo.Replica, + hostname: db_replica_host, + username: db_user, + password: db_password, + database: db_name, + port: db_port, + pool_size: db_replica_pool_size, + queue_target: db_queue_target, + queue_interval: db_queue_interval, parameters: [ application_name: "supabase_mt_realtime_ro" ], @@ -333,3 +483,19 @@ if config_env() == :prod do ssl: ssl_opts end end + +if config_env() != :test do + case dashboard_auth do + "zta" -> + config :realtime, dashboard_auth: :zta + + _ -> + config :realtime, + dashboard_auth: :basic_auth, + dashboard_credentials: {dashboard_user, dashboard_password} + end +end + +if config_env() == :dev do + config :libcluster, debug: false, topologies: cluster_topologies +end diff --git a/config/test.exs b/config/test.exs index 4c7c66ae8..282f548d4 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,10 +1,7 @@ import Config -# Configure your database -# -# The MIX_TEST_PARTITION environment variable can be used -# to provide built-in test partitioning in CI environment. -# Run `mix help test` for more information. +partition = System.get_env("MIX_TEST_PARTITION") + for repo <- [ Realtime.Repo, Realtime.Repo.Replica.FRA, @@ -18,18 +15,22 @@ for repo <- [ Realtime.Repo.Replica.SanJose ] do config :realtime, repo, - username: "postgres", + username: "supabase_realtime_admin", password: "postgres", - database: "realtime_test", + database: "realtime_test#{partition}", hostname: "127.0.0.1", pool: Ecto.Adapters.SQL.Sandbox end -# Running server during tests to run integration tests +http_port = if partition, do: 4002 + String.to_integer(partition), else: 4002 + config :realtime, RealtimeWeb.Endpoint, - http: [port: 4002], + http: [port: http_port], server: true +# that's what config/runtime.exs expects to see as region +System.put_env("REGION", "us-east-1") + config :realtime, region: "us-east-1", db_enc_key: "1234567890123456", @@ -37,7 +38,15 @@ config :realtime, api_jwt_secret: System.get_env("API_JWT_SECRET", "secret"), metrics_jwt_secret: "test", prom_poll_rate: 5_000, - request_id_baggage_key: "sb-request-id" + request_id_baggage_key: "sb-request-id", + node_balance_uptime_threshold_in_ms: 999_999_999_999, + connect_error_backoff_ms: 100, + channel_error_backoff_ms: 100, + max_gen_rpc_clients: 5, + max_gen_rpc_call_clients: 1, + metrics_pusher_req_options: [ + plug: {Req.Test, Realtime.MetricsPusher} + ] # Print nothing during tests unless captured or a test failure happens config :logger, @@ -47,7 +56,7 @@ config :logger, # Configures Elixir's Logger config :logger, :console, format: "$time $metadata[$level] $message\n", - metadata: [:request_id, :project, :external_id, :application_name, :sub, :iss, :exp] + metadata: [:error_code, :request_id, :project, :external_id, :application_name, :sub, :iss, :exp] config :opentelemetry, span_processor: :simple, @@ -56,6 +65,12 @@ config :opentelemetry, # Using different ports so that a remote node during test can connect using the same local network # See Clustered module +gen_rpc_offset = if partition, do: String.to_integer(partition) * 10, else: 0 + config :gen_rpc, - tcp_server_port: 5969, - tcp_client_port: 5970 + tcp_server_port: 5969 + gen_rpc_offset, + tcp_client_port: 5970 + gen_rpc_offset, + connect_timeout: 500 + +config :realtime, :dashboard_auth, :basic_auth +config :realtime, :dashboard_credentials, {"test_user", "test_password"} diff --git a/demo/.env.example b/demo/.env.example deleted file mode 100644 index 25edd5cc0..000000000 --- a/demo/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= -LOGFLARE_API_KEY= -LOGFLARE_SOURCE_ID= diff --git a/demo/.eslintrc.json b/demo/.eslintrc.json deleted file mode 100644 index bffb357a7..000000000 --- a/demo/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/demo/.gitignore b/demo/.gitignore deleted file mode 100644 index 7d093c39f..000000000 --- a/demo/.gitignore +++ /dev/null @@ -1,38 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env.local -.env.development.local -.env.test.local -.env.production.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo diff --git a/demo/.prettierignore b/demo/.prettierignore deleted file mode 100644 index ba898f1ef..000000000 --- a/demo/.prettierignore +++ /dev/null @@ -1,3 +0,0 @@ -.next -node_modules -package-lock.json diff --git a/demo/.prettierrc.json b/demo/.prettierrc.json deleted file mode 100644 index 8df6df775..000000000 --- a/demo/.prettierrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "trailingComma": "es5", - "tabWidth": 2, - "semi": false, - "singleQuote": true, - "printWidth": 100 -} diff --git a/demo/README.md b/demo/README.md deleted file mode 100644 index c87e0421d..000000000 --- a/demo/README.md +++ /dev/null @@ -1,34 +0,0 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. - -[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. - -The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/demo/client.ts b/demo/client.ts deleted file mode 100644 index c39f3982d..000000000 --- a/demo/client.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createClient } from '@supabase/supabase-js' - -const supabaseClient = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - realtime: { - params: { - eventsPerSecond: 1000, - }, - }, - } -) - -export default supabaseClient diff --git a/demo/components/Chatbox.tsx b/demo/components/Chatbox.tsx deleted file mode 100644 index 0f9695537..000000000 --- a/demo/components/Chatbox.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { IconLoader } from '@supabase/ui' -import { FC, RefObject } from 'react' -import { Message } from '../types' - -interface Props { - messages: Message[] - chatboxRef: RefObject - messagesInTransit: string[] - areMessagesFetched: boolean -} - -const Chatbox: FC = ({ messages, chatboxRef, messagesInTransit, areMessagesFetched }) => { - return ( -
-
- {!areMessagesFetched ? ( -
- -

Loading messages

-
- ) : messages.length === 0 && messagesInTransit.length === 0 ? ( -
- Type anything to start chatting 🥳 -
- ) : ( -
- )} - {messages.map((message) => ( -

- {message.message} -

- ))} - {messagesInTransit.map((message, idx: number) => ( -

- {message} -

- ))} -
-
-
- ) -} - -export default Chatbox diff --git a/demo/components/Cursor.tsx b/demo/components/Cursor.tsx deleted file mode 100644 index f6ad32ea6..000000000 --- a/demo/components/Cursor.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { FC, FormEvent, useEffect, useRef, useState } from 'react' - -interface Props { - x?: number - y?: number - color: string - hue: string - message: string - isTyping: boolean - isCancelled?: boolean - isLocalClient?: boolean - onUpdateMessage?: (message: string) => void -} - -const MAX_MESSAGE_LENGTH = 70 -const MAX_DURATION = 4000 -const MAX_BUBBLE_WIDTH_THRESHOLD = 280 + 50 -const MAX_BUBBLE_HEIGHT_THRESHOLD = 40 + 50 - -const Cursor: FC = ({ - x, - y, - color, - hue, - message, - isTyping, - isCancelled, - isLocalClient, - onUpdateMessage = () => {}, -}) => { - // Don't show cursor for the local client - const _isLocalClient = !x || !y || isLocalClient - const inputRef = useRef() as any - const timeoutRef = useRef() as any - const chatBubbleRef = useRef() as any - - const [flipX, setFlipX] = useState(false) - const [flipY, setFlipY] = useState(false) - const [hideInput, setHideInput] = useState(false) - const [showMessageBubble, setShowMessageBubble] = useState(false) - - useEffect(() => { - if (isTyping) { - setShowMessageBubble(true) - if (timeoutRef.current) clearTimeout(timeoutRef.current) - - if (isLocalClient) { - if (inputRef.current) inputRef.current.focus() - setHideInput(false) - } - } else { - if (!message || isCancelled) { - setShowMessageBubble(false) - } else { - if (timeoutRef.current) clearTimeout(timeoutRef.current) - if (isLocalClient) setHideInput(true) - const timeoutId = setTimeout(() => { - setShowMessageBubble(false) - }, MAX_DURATION) - timeoutRef.current = timeoutId - } - } - }, [isLocalClient, isTyping, isCancelled, message, inputRef]) - - useEffect(() => { - // [Joshen] Experimental: dynamic flipping to ensure that chat - // bubble always stays within the viewport, comment this block - // out if the effect seems weird. - setFlipX((x || 0) + MAX_BUBBLE_WIDTH_THRESHOLD >= window.innerWidth) - setFlipY((y || 0) + MAX_BUBBLE_HEIGHT_THRESHOLD >= window.innerHeight) - }, [x, y, isTyping, chatBubbleRef]) - - return ( - <> - {!_isLocalClient && ( - - - - )} -
- {_isLocalClient && !hideInput ? ( - <> - ) => { - const text = e.currentTarget.value - if (text.length <= MAX_MESSAGE_LENGTH) onUpdateMessage(e.currentTarget.value) - }} - /> -

- {message.length}/{MAX_MESSAGE_LENGTH} -

- - ) : message.length ? ( -
{message}
- ) : ( -
-
-
-
-
-
-
-
- )} -
- - ) -} - -export default Cursor diff --git a/demo/components/DarkModeToggle.tsx b/demo/components/DarkModeToggle.tsx deleted file mode 100644 index c84626a90..000000000 --- a/demo/components/DarkModeToggle.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { IconSun, IconMoon } from '@supabase/ui' -import { useEffect } from 'react' -import { useTheme } from '../lib/ThemeProvider' - -function DarkModeToggle() { - const { isDarkMode, toggleTheme } = useTheme() - - const toggleDarkMode = () => { - localStorage.setItem('supabaseDarkMode', (!isDarkMode).toString()) - toggleTheme() - - const key = localStorage.getItem('supabaseDarkMode') - document.documentElement.className = key === 'true' ? 'dark' : '' - } - - useEffect(() => { - const key = localStorage.getItem('supabaseDarkMode') - if (key && key == 'false') { - document.documentElement.className = '' - } - }, []) - - return ( -
- -
- ) -} - -export default DarkModeToggle diff --git a/demo/components/Loader.tsx b/demo/components/Loader.tsx deleted file mode 100644 index ee7675512..000000000 --- a/demo/components/Loader.tsx +++ /dev/null @@ -1,12 +0,0 @@ -const Loader = () => { - return ( -
- - - - -
- ) -} - -export default Loader diff --git a/demo/components/Users.tsx b/demo/components/Users.tsx deleted file mode 100644 index 67051321b..000000000 --- a/demo/components/Users.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { FC } from 'react' -import { User } from '../types' - -interface Props { - users: Record -} - -const Users: FC = ({ users }) => { - return ( -
- {Object.entries(users).map(([userId, userData], idx) => { - return ( -
-
-
-
-
- ) - })} -
- ) -} - -export default Users diff --git a/demo/components/WaitlistPopover.tsx b/demo/components/WaitlistPopover.tsx deleted file mode 100644 index 7958af6cf..000000000 --- a/demo/components/WaitlistPopover.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { FC, useState, memo } from 'react' -import Link from 'next/link' -import Image from 'next/image' -import { - Button, - Form, - Input, - IconMinimize2, - IconMaximize2, - IconGitHub, - IconTwitter, -} from '@supabase/ui' -import supabaseClient from '../client' -import { useTheme } from '../lib/ThemeProvider' - -interface Props {} - -const WaitlistPopover: FC = ({}) => { - const { isDarkMode } = useTheme() - const [isExpanded, setIsExpanded] = useState(true) - const [isSuccess, setIsSuccess] = useState(false) - const [error, setError] = useState() - - const initialValues = { email: '' } - - const getGeneratedTweet = () => { - return `Join me to experience Realtime by Supabase!%0A%0A${window.location.href}` - } - - const onValidate = (values: any) => { - const errors = {} as any - const emailValidateRegex = - /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/ - if (!emailValidateRegex.test(values.email)) errors.email = 'Please enter a valid email' - return errors - } - - const onSubmit = async (values: any, { setSubmitting, resetForm }: any) => { - setIsSuccess(false) - setError(undefined) - setSubmitting(true) - const { error } = await supabaseClient.from('waitlist').insert([{ email: values.email }]) - if (!error) { - resetForm() - setIsSuccess(true) - } else { - setError(error) - } - setSubmitting(false) - } - - return ( -
-
-
- supabase -
-

- / -

-

- Realtime -

-
-
- {isExpanded ? ( - setIsExpanded(false)} - /> - ) : ( - setIsExpanded(true)} - /> - )} -
- -
-
-
-

Realtime

-
-

- Realtime collaborative app to display broadcast, presence, and database listening over - WebSockets -

-
-
- - Realtime Multiplayer by Supabase - Easily build real-time apps that enables user collaboration | Product Hunt - -
-
- - - - - - -
-
- -
- {({ isSubmitting }: any) => { - return ( - <> - - Get early access - , - ]} - /> - {isSuccess && ( -

- Thank you for submitting your interest! -

- )} - {error?.message.includes('duplicate key') && ( -

- Email has already been registered for waitlist -

- )} - {error && !error?.message.includes('duplicate key') && ( -

Unable to register email for waitlist

- )} - - ) - }} -
-
- ) -} - -export default memo(WaitlistPopover) diff --git a/demo/lib/RandomColor.ts b/demo/lib/RandomColor.ts deleted file mode 100644 index beea3b369..000000000 --- a/demo/lib/RandomColor.ts +++ /dev/null @@ -1,56 +0,0 @@ -import sampleSize from 'lodash.samplesize' - -const colors = { - tomato: { - bg: 'var(--colors-tomato9)', - hue: 'var(--colors-tomato7)', - }, - crimson: { - bg: 'var(--colors-crimson9)', - hue: 'var(--colors-crimson7)', - }, - pink: { - bg: 'var(--colors-pink9)', - hue: 'var(--colors-pink7)', - }, - plum: { - bg: 'var(--colors-plum9)', - hue: 'var(--colors-plum7)', - }, - indigo: { - bg: 'var(--colors-indigo9)', - hue: 'var(--colors-indigo7)', - }, - blue: { - bg: 'var(--colors-blue9)', - hue: 'var(--colors-blue7)', - }, - cyan: { - bg: 'var(--colors-cyan9)', - hue: 'var(--colors-cyan7)', - }, - green: { - bg: 'var(--colors-green9)', - hue: 'var(--colors-green7)', - }, - orange: { - bg: 'var(--colors-orange9)', - hue: 'var(--colors-orange7)', - }, -} - -export const getRandomUniqueColor = (currentColors: string[]) => { - const colorNames = Object.values(colors).map((col) => col.bg) - const uniqueColors = colorNames.filter((color: string) => !currentColors.includes(color)) - const uniqueColor = uniqueColors[Math.floor(Math.random() * uniqueColors.length)] - const uniqueColorSet = Object.values(colors).find((color) => color.bg === uniqueColor) - return uniqueColorSet || getRandomColor() -} - -export const getRandomColors = (qty: number) => { - return sampleSize(Object.values(colors), qty) -} - -export const getRandomColor = () => { - return Object.values(colors)[Math.floor(Math.random() * Object.values(colors).length)] -} diff --git a/demo/lib/ThemeProvider.tsx b/demo/lib/ThemeProvider.tsx deleted file mode 100644 index ab023562c..000000000 --- a/demo/lib/ThemeProvider.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { createContext, useContext, useEffect, useState } from 'react' - -interface UseThemeProps { - isDarkMode?: boolean - toggleTheme: () => void -} - -interface ThemeProviderProps { - children?: any -} - -export const ThemeContext = createContext({ - isDarkMode: true, - toggleTheme: () => {}, -}) - -export const useTheme = () => useContext(ThemeContext) - -export const ThemeProvider = ({ children }: ThemeProviderProps) => { - const [isDarkMode, setIsDarkMode] = useState(false) - - useEffect(() => { - const key = localStorage.getItem('supabaseDarkMode') - // Default to dark mode if no preference config - setIsDarkMode(!key || key === 'true') - }, []) - - const toggleTheme = () => { - setIsDarkMode(!isDarkMode) - } - - return ( - <> - - {children} - - - ) -} diff --git a/demo/lib/sendLog.ts b/demo/lib/sendLog.ts deleted file mode 100644 index c3c7e1ab5..000000000 --- a/demo/lib/sendLog.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function sendLog(message: string) { - return fetch('/api/log', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ message }), - }) -} diff --git a/demo/next-env.d.ts b/demo/next-env.d.ts deleted file mode 100644 index 4f11a03dc..000000000 --- a/demo/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/demo/next.config.js b/demo/next.config.js deleted file mode 100644 index b556a415f..000000000 --- a/demo/next.config.js +++ /dev/null @@ -1,14 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - async rewrites() { - return [ - { - source: '/', - destination: '/room', - }, - ] - }, - reactStrictMode: true, -} - -module.exports = nextConfig diff --git a/demo/package-lock.json b/demo/package-lock.json deleted file mode 100644 index bcf6da697..000000000 --- a/demo/package-lock.json +++ /dev/null @@ -1,10978 +0,0 @@ -{ - "name": "demo", - "version": "0.1.2", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "demo", - "version": "0.1.2", - "dependencies": { - "@supabase/supabase-js": "^2.1.0", - "@supabase/ui": "0.37.0-alpha.81", - "lodash.clonedeep": "^4.5.0", - "lodash.samplesize": "^4.2.0", - "lodash.throttle": "^4.1.1", - "next": "^15.2.4", - "react": "17.0.2", - "react-dom": "17.0.2" - }, - "devDependencies": { - "@types/lodash.clonedeep": "^4.5.6", - "@types/lodash.samplesize": "^4.2.6", - "@types/lodash.throttle": "^4.1.6", - "@types/node": "17.0.21", - "@types/react": "17.0.41", - "autoprefixer": "^10.4.4", - "eslint": "8.11.0", - "eslint-config-next": "^12.3.4", - "postcss": "^8.4.31", - "tailwindcss": "^3.0.23", - "typescript": "4.6.2" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", - "dependencies": { - "@babel/highlight": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.16.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", - "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", - "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime-corejs3": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.20.1.tgz", - "integrity": "sha512-CGulbEDcg/ND1Im7fUNRZdGXmX2MTWVVZacQi/6DiKE5HNwZ3aVTm5PV4lO8HHz0B2h8WQyvKKjbX5XgTtydsg==", - "dev": true, - "dependencies": { - "core-js-pure": "^3.25.1", - "regenerator-runtime": "^0.13.10" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime/node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, - "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.1.tgz", - "integrity": "sha512-bxvbYnBPN1Gibwyp6NrpnFzA3YtRL3BBAyEAFVIpNTm2Rn4Vy87GA5M4aSn3InRrlsbX5N0GW7XIx+U4SAEKdQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.3.1", - "globals": "^13.9.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.0.4", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@headlessui/react": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.4.tgz", - "integrity": "sha512-D8n5yGCF3WIkPsjEYeM8knn9jQ70bigGGb5aUvN6y4BGxcT3OcOQOKcM3zRGllRCZCFxCZyQvYJF6ZE7bQUOyQ==", - "dependencies": { - "client-only": "^0.0.1" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": "^16 || ^17 || ^18", - "react-dom": "^16 || ^17 || ^18" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", - "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.2.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@mertasan/tailwindcss-variables": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@mertasan/tailwindcss-variables/-/tailwindcss-variables-2.5.1.tgz", - "integrity": "sha512-I1Jvpu5fcinGT/yEDL53dRXznFWV4LoTCUVcTvQqA1YH1iAfs72OO/VZdBKPqcxe/lS2nBr/Ikloe+pLsxemmA==", - "dependencies": { - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "autoprefixer": "^10.0.2", - "postcss": "^8.0.9" - } - }, - "node_modules/@next/env": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz", - "integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==", - "license": "MIT" - }, - "node_modules/@next/eslint-plugin-next": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-12.3.4.tgz", - "integrity": "sha512-BFwj8ykJY+zc1/jWANsDprDIu2MgwPOIKxNVnrKvPs+f5TPegrVnem8uScND+1veT4B7F6VeqgaNLFW1Hzl9Og==", - "dev": true, - "dependencies": { - "glob": "7.1.7" - } - }, - "node_modules/@next/eslint-plugin-next/node_modules/glob": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", - "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz", - "integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz", - "integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz", - "integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz", - "integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz", - "integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz", - "integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz", - "integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz", - "integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@radix-ui/colors": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-0.1.8.tgz", - "integrity": "sha512-jwRMXYwC0hUo0mv6wGpuw254Pd9p/R6Td5xsRpOmaWkUHlooNWqVcadgyzlRumMq3xfOTXwJReU0Jv+EIy4Jbw==" - }, - "node_modules/@radix-ui/popper": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/popper/-/popper-0.1.0.tgz", - "integrity": "sha512-uzYeElL3w7SeNMuQpXiFlBhTT+JyaNMCwDfjKkrzugEcYrf5n52PHqncNdQPUtR42hJh8V9FsqyEDbDxkeNjJQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "csstype": "^3.0.4" - } - }, - "node_modules/@radix-ui/primitive": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-0.1.0.tgz", - "integrity": "sha512-tqxZKybwN5Fa3VzZry4G6mXAAb9aAqKmPtnVbZpL0vsBwvOHTBwsjHVPXylocYLwEtBY9SCe665bYnNB515uoA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-0.1.2.tgz", - "integrity": "sha512-3BRlFZraooIUfRlyN+b/Xs5hq1lanOOo/+3h6Pwu2GMFjkGKKa4Rd51fcqGqnVlbr3jYg+WLuGyAV4KlgqwrQw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@radix-ui/rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-0.1.1.tgz", - "integrity": "sha512-g3hnE/UcOg7REdewduRPAK88EPuLZtaq7sA9ouu8S+YEtnyFRI16jgv6GZYe3VMoQLL1T171ebmEPtDjyxWLzw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - } - }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", - "integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==", - "dev": true - }, - "node_modules/@supabase/functions-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.0.0.tgz", - "integrity": "sha512-ozb7bds2yvf5k7NM2ZzUkxvsx4S4i2eRKFSJetdTADV91T65g4gCzEs9L3LUXSrghcGIkUaon03VPzOrFredqg==", - "dependencies": { - "cross-fetch": "^3.1.5" - } - }, - "node_modules/@supabase/gotrue-js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@supabase/gotrue-js/-/gotrue-js-2.3.1.tgz", - "integrity": "sha512-txYVDrKAFXxT4nyVGnW3M9Oid4u3Xe/Na+wTEzwU+IBuPUEz72ZBHNKo6HBKlZNpnlGtgCSciYhH8qFkZYGV3g==", - "dependencies": { - "cross-fetch": "^3.1.5" - } - }, - "node_modules/@supabase/postgrest-js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.1.0.tgz", - "integrity": "sha512-qkY8TqIu5sJuae8gjeDPjEqPrefzcTraW9PNSVJQHq4TEv98ZmwaXGwBGz0bVL63bqrGA5hqREbQHkANUTXrvA==", - "dependencies": { - "cross-fetch": "^3.1.5" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.1.0.tgz", - "integrity": "sha512-iplLCofTeYjnx9FIOsIwHLhMp0+7UVyiA4/sCeq40VdOgN9eTIhjEno9Tgh4dJARi4aaXoKfRX1DTxgZaOpPAw==", - "dependencies": { - "@types/phoenix": "^1.5.4", - "websocket": "^1.0.34" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.0.0.tgz", - "integrity": "sha512-7kXThdRt/xqnOOvZZxBqNkeX1CFNUWc0hYBJtNN/Uvt8ok9hD14foYmroWrHn046wEYFqUrB9U35JYsfTrvltA==", - "dependencies": { - "cross-fetch": "^3.1.5" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.1.0.tgz", - "integrity": "sha512-hODrAUDSC6RV6EhwuSMyhaQCF32gij0EBTceuDR+8suJsg7XcyUG0fYgeYecWIvt0nz61xAMY6E+Ywb0tJaAng==", - "dependencies": { - "@supabase/functions-js": "^2.0.0", - "@supabase/gotrue-js": "^2.3.0", - "@supabase/postgrest-js": "^1.1.0", - "@supabase/realtime-js": "^2.1.0", - "@supabase/storage-js": "^2.0.0", - "cross-fetch": "^3.1.5" - } - }, - "node_modules/@supabase/ui": { - "version": "0.37.0-alpha.81", - "resolved": "https://registry.npmjs.org/@supabase/ui/-/ui-0.37.0-alpha.81.tgz", - "integrity": "sha512-CxqdikE6wGw6pGQ6b3vRA8qnvCK20VyeMyy8Z4hJ/Dg2qRfgQqbrv7qS+6A1S8pg657EzCCo0DIH75SijaU8eA==", - "dependencies": { - "@headlessui/react": "^1.0.0", - "@mertasan/tailwindcss-variables": "^2.0.1", - "@radix-ui/colors": "^0.1.8", - "@radix-ui/react-accordion": "^0.1.5", - "@radix-ui/react-collapsible": "^0.1.5", - "@radix-ui/react-context-menu": "^0.1.0", - "@radix-ui/react-dialog": "^0.1.5", - "@radix-ui/react-dropdown-menu": "^0.1.4", - "@radix-ui/react-popover": "^0.1.0", - "@radix-ui/react-portal": "^0.1.3", - "@radix-ui/react-tabs": "^0.1.0", - "@tailwindcss/forms": "^0.4.0", - "@tailwindcss/typography": "^0.5.0", - "autoprefixer": "^10.4.2", - "deepmerge": "^4.2.2", - "formik": "^2.2.9", - "lodash": "^4.17.20", - "postcss": "^8.4.5", - "prop-types": "^15.7.2", - "tailwindcss": "^3.0.15", - "tailwindcss-radix": "^1.6.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - }, - "peerDependencies": { - "react": "^16.13.1 || ^17.0.1", - "react-dom": "^16.13.1 || ^17.0.1" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-0.1.6.tgz", - "integrity": "sha512-LOXlqPU6y6EMBopdRIKCWFvMPY1wPTQ4uJiX7ZVxldrMJcM7imBzI3wlRTkPCHZ3FLHmpuw+cQi3du23pzJp1g==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collapsible": "0.1.6", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-0.1.6.tgz", - "integrity": "sha512-Gkf8VuqMc6HTLzA2AxVYnyK6aMczVLpatCjdD9Lj4wlYLXCz9KtiqZYslLMeqnQFLwLyZS0WKX/pQ8j5fioIBw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-0.1.6.tgz", - "integrity": "sha512-0qa6ABaeqD+WYI+8iT0jH0QLLcV8Kv0xI+mZL4FFnG4ec9H0v+yngb5cfBBfs9e/KM8mDzFFpaeegqsQlLNqyQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-menu": "0.1.6", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-0.1.6.tgz", - "integrity": "sha512-ho3+bhpr3oAFkOBJ8VkUb1BcGoiZBB3OmcWPqa6i5RTUKrzNX/d6rauochu2xDlWjiRtpVuiAcsTVOeIC4FbYQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-popper": "0.1.4", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-roving-focus": "0.1.5", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-direction": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.4.tgz", - "integrity": "sha512-18gDYof97t8UQa7zwklG1Dr8jIdj3u+rVOQLzPi9f5i1YQak/pVGkaqw8aY+iDUknKKuZniTk/7jbAJUYlKyOw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/popper": "0.1.0", - "@radix-ui/react-arrow": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-rect": "0.1.1", - "@radix-ui/react-use-size": "0.1.1", - "@radix-ui/rect": "0.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-arrow": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.4.tgz", - "integrity": "sha512-BB6XzAb7Ml7+wwpFdYVtZpK1BlMgqyafSQNGzhIpSZ4uXvXOHPlR5GP8M449JkeQzgQjv9Mp1AsJxFC0KuOtuA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz", - "integrity": "sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "0.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-size": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz", - "integrity": "sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-0.1.5.tgz", - "integrity": "sha512-ClwKPS5JZE+PaHCoW7eu1onvE61pDv4kO8W4t5Ra3qMFQiTJLZMdpBQUhksN//DaVygoLirz4Samdr5Y1x1FSA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-direction": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-direction/-/react-use-direction-0.1.0.tgz", - "integrity": "sha512-NajpY/An9TCPSfOVkgWIdXJV+VuWl67PxB6kOKYmtNAFHvObzIoh8o0n9sAuwSAyFCZVq211FEf9gvVDRhOyiA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-0.1.7.tgz", - "integrity": "sha512-jXt8srGhHBRvEr9jhEAiwwJzWCWZoGRJ030aC9ja/gkRJbZdy0iD3FwXf+Ff4RtsZyLUMHW7VUwFOlz3Ixe1Vw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2", - "@radix-ui/react-use-controllable-state": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-0.1.6.tgz", - "integrity": "sha512-RZhtzjWwJ4ZBN7D8ek4Zn+ilHzYuYta9yIxFnbC0pfqMnSi67IQNONo1tuuNqtFh9SRHacPKc65zo+kBBlxtdg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-menu": "0.1.6", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-0.1.6.tgz", - "integrity": "sha512-ho3+bhpr3oAFkOBJ8VkUb1BcGoiZBB3OmcWPqa6i5RTUKrzNX/d6rauochu2xDlWjiRtpVuiAcsTVOeIC4FbYQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-popper": "0.1.4", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-roving-focus": "0.1.5", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-direction": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.4.tgz", - "integrity": "sha512-18gDYof97t8UQa7zwklG1Dr8jIdj3u+rVOQLzPi9f5i1YQak/pVGkaqw8aY+iDUknKKuZniTk/7jbAJUYlKyOw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/popper": "0.1.0", - "@radix-ui/react-arrow": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-rect": "0.1.1", - "@radix-ui/react-use-size": "0.1.1", - "@radix-ui/rect": "0.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-arrow": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.4.tgz", - "integrity": "sha512-BB6XzAb7Ml7+wwpFdYVtZpK1BlMgqyafSQNGzhIpSZ4uXvXOHPlR5GP8M449JkeQzgQjv9Mp1AsJxFC0KuOtuA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz", - "integrity": "sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "0.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-size": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz", - "integrity": "sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-0.1.5.tgz", - "integrity": "sha512-ClwKPS5JZE+PaHCoW7eu1onvE61pDv4kO8W4t5Ra3qMFQiTJLZMdpBQUhksN//DaVygoLirz4Samdr5Y1x1FSA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-direction": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-direction/-/react-use-direction-0.1.0.tgz", - "integrity": "sha512-NajpY/An9TCPSfOVkgWIdXJV+VuWl67PxB6kOKYmtNAFHvObzIoh8o0n9sAuwSAyFCZVq211FEf9gvVDRhOyiA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-0.1.6.tgz", - "integrity": "sha512-zQzgUqW4RQDb0ItAL1xNW4K4olUrkfV3jeEPs9rG+nsDQurO+W9TT+YZ9H1mmgAJqlthyv1sBRZGdBm4YjtD6Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-popper": "0.1.4", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.4.tgz", - "integrity": "sha512-18gDYof97t8UQa7zwklG1Dr8jIdj3u+rVOQLzPi9f5i1YQak/pVGkaqw8aY+iDUknKKuZniTk/7jbAJUYlKyOw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/popper": "0.1.0", - "@radix-ui/react-arrow": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-rect": "0.1.1", - "@radix-ui/react-use-size": "0.1.1", - "@radix-ui/rect": "0.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-arrow": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.4.tgz", - "integrity": "sha512-BB6XzAb7Ml7+wwpFdYVtZpK1BlMgqyafSQNGzhIpSZ4uXvXOHPlR5GP8M449JkeQzgQjv9Mp1AsJxFC0KuOtuA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz", - "integrity": "sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "0.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-size": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz", - "integrity": "sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-portal": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-0.1.4.tgz", - "integrity": "sha512-MO0wRy2eYRTZ/CyOri9NANCAtAtq89DEtg90gicaTlkCfdqCLEBsLb+/q66BZQTr3xX/Vq01nnVfc/TkCqoqvw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-0.1.5.tgz", - "integrity": "sha512-ieVQS1TFr0dX1XA8B+CsSFKOE7kcgEaNWWEfItxj9D1GZjn1o3WqPkW+FhQWDAWZLSKCH2PezYF3MNyO41lgJg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-roving-focus": "0.1.5", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-0.1.5.tgz", - "integrity": "sha512-ClwKPS5JZE+PaHCoW7eu1onvE61pDv4kO8W4t5Ra3qMFQiTJLZMdpBQUhksN//DaVygoLirz4Samdr5Y1x1FSA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@tailwindcss/forms": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.4.1.tgz", - "integrity": "sha512-gS9xjCmJjUBz/eP12QlENPLnf0tCx68oYE3mri0GMP5jdtVwLbGUNSRpjsp6NzLAZzZy3ueOwrcqB78Ax6Z84A==", - "dependencies": { - "mini-svg-data-uri": "^1.2.3" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" - } - }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.8.tgz", - "integrity": "sha512-xGQEp8KXN8Sd8m6R4xYmwxghmswrd0cPnNI2Lc6fmrC3OojysTBJJGSIVwPV56q4t6THFUK3HJ0EaWwpglSxWw==", - "dependencies": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true - }, - "node_modules/@types/lodash": { - "version": "4.14.180", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.180.tgz", - "integrity": "sha512-XOKXa1KIxtNXgASAnwj7cnttJxS4fksBRywK/9LzRV5YxrF80BXZIGeQSuoESQ/VkUj30Ae0+YcuHc15wJCB2g==", - "dev": true - }, - "node_modules/@types/lodash.clonedeep": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.6.tgz", - "integrity": "sha512-cE1jYr2dEg1wBImvXlNtp0xDoS79rfEdGozQVgliDZj1uERH4k+rmEMTudP9b4VQ8O6nRb5gPqft0QzEQGMQgA==", - "dev": true, - "dependencies": { - "@types/lodash": "*" - } - }, - "node_modules/@types/lodash.samplesize": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/@types/lodash.samplesize/-/lodash.samplesize-4.2.6.tgz", - "integrity": "sha512-yBgEuIxVIM+corHdvB+NHgzni1Oc0aEd7acuO/jET0vO2Y2f6sl7vfQlaZKgzcN+ZqWLB6B2VQTKc1T5zQra+Q==", - "dev": true, - "dependencies": { - "@types/lodash": "*" - } - }, - "node_modules/@types/lodash.throttle": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.6.tgz", - "integrity": "sha512-/UIH96i/sIRYGC60NoY72jGkCJtFN5KVPhEMMMTjol65effe1gPn0tycJqV5tlSwMTzX8FqzB5yAj0rfGHTPNg==", - "dev": true, - "dependencies": { - "@types/lodash": "*" - } - }, - "node_modules/@types/node": { - "version": "17.0.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz", - "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==", - "dev": true - }, - "node_modules/@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" - }, - "node_modules/@types/phoenix": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.5.4.tgz", - "integrity": "sha512-L5eZmzw89eXBKkiqVBcJfU1QGx9y+wurRIEgt0cuLH0hwNtVUxtx+6cu0R2STwWj468sjXyBYPYDtGclUd1kjQ==" - }, - "node_modules/@types/prop-types": { - "version": "15.7.4", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", - "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==", - "devOptional": true - }, - "node_modules/@types/react": { - "version": "17.0.41", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.41.tgz", - "integrity": "sha512-chYZ9ogWUodyC7VUTRBfblysKLjnohhFY9bGLwvnUFFy48+vB9DikmB3lW0qTFmBcKSzmdglcvkHK71IioOlDA==", - "devOptional": true, - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "devOptional": true - }, - "node_modules/@typescript-eslint/parser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.44.0.tgz", - "integrity": "sha512-H7LCqbZnKqkkgQHaKLGC6KUjt3pjJDx8ETDqmwncyb6PuoigYajyAwBGz08VU/l86dZWZgI4zm5k2VaKqayYyA==", - "dev": true, - "dependencies": { - "@typescript-eslint/scope-manager": "5.44.0", - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/typescript-estree": "5.44.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.44.0.tgz", - "integrity": "sha512-2pKml57KusI0LAhgLKae9kwWeITZ7IsZs77YxyNyIVOwQ1kToyXRaJLl+uDEXzMN5hnobKUOo2gKntK9H1YL8g==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/visitor-keys": "5.44.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.44.0.tgz", - "integrity": "sha512-Tp+zDnHmGk4qKR1l+Y1rBvpjpm5tGXX339eAlRBDg+kgZkz9Bw+pqi4dyseOZMsGuSH69fYfPJCBKBrbPCxYFQ==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.44.0.tgz", - "integrity": "sha512-M6Jr+RM7M5zeRj2maSfsZK2660HKAJawv4Ud0xT+yauyvgrsHu276VtXlKDFnEmhG+nVEd0fYZNXGoAgxwDWJw==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/visitor-keys": "5.44.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.44.0.tgz", - "integrity": "sha512-a48tLG8/4m62gPFbJ27FxwCOqPKxsb8KC3HkmYoq2As/4YyjQl1jDbRr1s63+g4FS/iIehjmN3L5UjmKva1HzQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "5.44.0", - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/acorn": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", - "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-node": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", - "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", - "dependencies": { - "acorn": "^7.0.0", - "acorn-walk": "^7.0.0", - "xtend": "^4.0.2" - } - }, - "node_modules/acorn-node/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", - "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", - "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/aria-hidden": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.2.tgz", - "integrity": "sha512-6y/ogyDTk/7YAe91T3E2PR1ALVKyM2QbTio5HwM+N1Q6CMlCKhvClyIjkckBswa0f2xJhjsfzIGa1yVSe1UMVA==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.9.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/aria-query": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", - "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.10.2", - "@babel/runtime-corejs3": "^7.10.2" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/array-includes": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", - "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz", - "integrity": "sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz", - "integrity": "sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.1.tgz", - "integrity": "sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.1.3" - } - }, - "node_modules/ast-types-flow": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", - "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==", - "dev": true - }, - "node_modules/autoprefixer": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.4.tgz", - "integrity": "sha512-Tm8JxsB286VweiZ5F0anmbyGiNI3v3wGv3mz9W+cxEDYB/6jbnj6GM9H9mK3wIL8ftgl+C07Lcwb8PG5PCCPzA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - } - ], - "dependencies": { - "browserslist": "^4.20.2", - "caniuse-lite": "^1.0.30001317", - "fraction.js": "^4.2.0", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/axe-core": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.5.2.tgz", - "integrity": "sha512-u2MVsXfew5HBvjsczCv+xlwdNnB1oQR9HlAcsejZttNjKKSkeDNVwB1vMThIUIFI9GoT57Vtk8iQLwqOfAkboA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/axobject-query": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", - "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==", - "dev": true - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "engines": { - "node": ">=8" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.20.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.2.tgz", - "integrity": "sha512-CQOBCqp/9pDvDbx3xfMi+86pr4KXIf2FDkTTdeuYw8OxS9t898LA1Khq57gtufFILXpfgsSx5woNgsBgvGjpsA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001317", - "electron-to-chromium": "^1.4.84", - "escalade": "^3.1.1", - "node-releases": "^2.0.2", - "picocolors": "^1.0.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bufferutil": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz", - "integrity": "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==", - "hasInstallScript": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001689", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001689.tgz", - "integrity": "sha512-CmeR2VBycfa+5/jOfnp/NpWPGd06nf1XYiefUvhXFfZE4GkRc9jv+eGPS4nT558WS/8lYCzV8SlANCIPvbWP1g==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" - }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "node_modules/core-js-pure": { - "version": "3.26.1", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.26.1.tgz", - "integrity": "sha512-VVXcDpp/xJ21KdULRq/lXdLzQAtX7+37LzpyfFM973il0tWSsDEoyzG38G14AjTpK9VTfiNM9jnFauq/CpaWGQ==", - "dev": true, - "hasInstallScript": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", - "dependencies": { - "node-fetch": "2.6.7" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" - }, - "node_modules/d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "dependencies": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, - "node_modules/damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/define-properties": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", - "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", - "dev": true, - "dependencies": { - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/defined": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", - "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=" - }, - "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, - "node_modules/detective": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", - "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", - "dependencies": { - "acorn-node": "^1.6.1", - "defined": "^1.0.0", - "minimist": "^1.1.1" - }, - "bin": { - "detective": "bin/detective.js" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.4.93", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.93.tgz", - "integrity": "sha512-ywq9Pc5Gwwpv7NG767CtoU8xF3aAUQJjH9//Wy3MBCg4w5JSLbJUq2L8IsCdzPMjvSgxuue9WcVaTOyyxCL0aQ==" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-abstract": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.4.tgz", - "integrity": "sha512-0UtvRN79eMe2L+UNEF1BwRe364sj/DXhQ/k5FmivgoSdpM90b8Jc0mDzKMGo7QS0BVbOP/bTwBKNnDc9rNzaPA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.1.3", - "get-symbol-description": "^1.0.0", - "has": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "is-string": "^1.0.7", - "is-weakref": "^1.0.2", - "object-inspect": "^1.12.2", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", - "safe-regex-test": "^1.0.0", - "string.prototype.trimend": "^1.0.5", - "string.prototype.trimstart": "^1.0.5", - "unbox-primitive": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", - "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", - "dev": true, - "dependencies": { - "has": "^1.0.3" - } - }, - "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, - "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "hasInstallScript": true, - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "dependencies": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.11.0.tgz", - "integrity": "sha512-/KRpd9mIRg2raGxHRGwW9ZywYNAClZrHjdueHcrVDuO3a6bj83eoTirCCk0M0yPwOjWYKHwRVRid+xK4F/GHgA==", - "dev": true, - "dependencies": { - "@eslint/eslintrc": "^1.2.1", - "@humanwhocodes/config-array": "^0.9.2", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.1", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^6.0.1", - "globals": "^13.6.0", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.0.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-config-next": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-12.3.4.tgz", - "integrity": "sha512-WuT3gvgi7Bwz00AOmKGhOeqnyA5P29Cdyr0iVjLyfDbk+FANQKcOjFUTZIdyYfe5Tq1x4TGcmoe4CwctGvFjHQ==", - "dev": true, - "dependencies": { - "@next/eslint-plugin-next": "12.3.4", - "@rushstack/eslint-patch": "^1.1.3", - "@typescript-eslint/parser": "^5.21.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^2.7.1", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.31.7", - "eslint-plugin-react-hooks": "^4.5.0" - }, - "peerDependencies": { - "eslint": "^7.23.0 || ^8.0.0", - "typescript": ">=3.3.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", - "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", - "dev": true, - "dependencies": { - "debug": "^3.2.7", - "resolve": "^1.20.0" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-import-resolver-typescript": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-2.7.1.tgz", - "integrity": "sha512-00UbgGwV8bSgUv34igBDbTOtKhqoRMy9bFjNehT40bXg6585PNIct8HhXZ0SybqB9rWtXj9crcku8ndDn/gIqQ==", - "dev": true, - "dependencies": { - "debug": "^4.3.4", - "glob": "^7.2.0", - "is-glob": "^4.0.3", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz", - "integrity": "sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==", - "dev": true, - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", - "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.4", - "array.prototype.flat": "^1.2.5", - "debug": "^2.6.9", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-module-utils": "^2.7.3", - "has": "^1.0.3", - "is-core-module": "^2.8.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.values": "^1.1.5", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/eslint-plugin-import/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.6.1.tgz", - "integrity": "sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.18.9", - "aria-query": "^4.2.2", - "array-includes": "^3.1.5", - "ast-types-flow": "^0.0.7", - "axe-core": "^4.4.3", - "axobject-query": "^2.2.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "has": "^1.0.3", - "jsx-ast-utils": "^3.3.2", - "language-tags": "^1.0.5", - "minimatch": "^3.1.2", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.31.11", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.31.11.tgz", - "integrity": "sha512-TTvq5JsT5v56wPa9OYHzsrOlHzKZKjV+aLgS+55NJP/cuzdiQPC7PfYoUjMoxlffKtvijpk7vA/jmuqRb9nohw==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flatmap": "^1.3.1", - "array.prototype.tosorted": "^1.1.1", - "doctrine": "^2.1.0", - "estraverse": "^5.3.0", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.6", - "object.fromentries": "^2.0.6", - "object.hasown": "^1.1.2", - "object.values": "^1.1.6", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.3", - "semver": "^6.3.0", - "string.prototype.matchall": "^4.0.8" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", - "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", - "dev": true, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.4", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz", - "integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==", - "dev": true, - "dependencies": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^2.0.0" - }, - "engines": { - "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=5" - } - }, - "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/eslint/node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esniff/node_modules/type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - }, - "node_modules/espree": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.1.tgz", - "integrity": "sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ==", - "dev": true, - "dependencies": { - "acorn": "^8.7.0", - "acorn-jsx": "^5.3.1", - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "dependencies": { - "type": "^2.7.2" - } - }, - "node_modules/ext/node_modules/type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-glob": { - "version": "3.2.11", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", - "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", - "dev": true - }, - "node_modules/fastq": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", - "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "dependencies": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz", - "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==", - "dev": true - }, - "node_modules/formik": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz", - "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==", - "funding": [ - { - "type": "individual", - "url": "https://opencollective.com/formik" - } - ], - "dependencies": { - "deepmerge": "^2.1.1", - "hoist-non-react-statics": "^3.3.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "react-fast-compare": "^2.0.1", - "tiny-warning": "^1.0.2", - "tslib": "^1.10.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/formik/node_modules/deepmerge": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", - "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/formik/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "node_modules/fraction.js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", - "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://www.patreon.com/infusion" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "node_modules/function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", - "dev": true - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", - "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "engines": { - "node": ">=6" - } - }, - "node_modules/get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "13.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz", - "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/ignore": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", - "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" - }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "dependencies": { - "has-bigints": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dependencies": { - "has": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" - }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/jsx-ast-utils": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", - "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.5", - "object.assign": "^4.1.3" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/language-subtag-registry": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", - "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", - "dev": true - }, - "node_modules/language-tags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", - "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", - "dev": true, - "dependencies": { - "language-subtag-registry": "~0.3.2" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lilconfig": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.5.tgz", - "integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==", - "engines": { - "node": ">=10" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" - }, - "node_modules/lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==" - }, - "node_modules/lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" - }, - "node_modules/lodash.samplesize": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.samplesize/-/lodash.samplesize-4.2.0.tgz", - "integrity": "sha1-Rgdi+7KzQikFF0mekNUVhttGX/k=" - }, - "node_modules/lodash.throttle": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mini-svg-data-uri": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", - "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", - "bin": { - "mini-svg-data-uri": "cli.js" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", - "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true - }, - "node_modules/next": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz", - "integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==", - "license": "MIT", - "dependencies": { - "@next/env": "15.2.4", - "@swc/counter": "0.1.3", - "@swc/helpers": "0.5.15", - "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "15.2.4", - "@next/swc-darwin-x64": "15.2.4", - "@next/swc-linux-arm64-gnu": "15.2.4", - "@next/swc-linux-arm64-musl": "15.2.4", - "@next/swc-linux-x64-gnu": "15.2.4", - "@next/swc-linux-x64-musl": "15.2.4", - "@next/swc-win32-arm64-msvc": "15.2.4", - "@next/swc-win32-x64-msvc": "15.2.4", - "sharp": "^0.33.5" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" - }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-gyp-build": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.5.0.tgz", - "integrity": "sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/node-releases": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.2.tgz", - "integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/object-inspect": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", - "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.6.tgz", - "integrity": "sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.6.tgz", - "integrity": "sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.hasown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.2.tgz", - "integrity": "sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==", - "dev": true, - "dependencies": { - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.values": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.6.tgz", - "integrity": "sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.4.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", - "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-js": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", - "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.3.3" - } - }, - "node_modules/postcss-nested": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz", - "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==", - "dependencies": { - "postcss-selector-parser": "^6.0.6" - }, - "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - }, - "peerDependencies": { - "react": "17.0.2" - } - }, - "node_modules/react-fast-compare": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", - "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "node_modules/react-remove-scroll": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", - "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", - "dependencies": { - "react-remove-scroll-bar": "^2.3.3", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz", - "integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==", - "dependencies": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "dependencies": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true - }, - "node_modules/regexp.prototype.flags": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", - "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "functions-have-names": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, - "node_modules/resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", - "dependencies": { - "is-core-module": "^2.8.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "is-regex": "^1.1.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "license": "MIT", - "optional": true, - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/simple-swizzle/node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "license": "MIT", - "optional": true - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", - "integrity": "sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "regexp.prototype.flags": "^1.4.3", - "side-channel": "^1.0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwindcss": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.0.23.tgz", - "integrity": "sha512-+OZOV9ubyQ6oI2BXEhzw4HrqvgcARY38xv3zKcjnWtMIZstEsXdI9xftd1iB7+RbOnj2HOEzkA0OyB5BaSxPQA==", - "dependencies": { - "arg": "^5.0.1", - "chalk": "^4.1.2", - "chokidar": "^3.5.3", - "color-name": "^1.1.4", - "cosmiconfig": "^7.0.1", - "detective": "^5.2.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.2.11", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "normalize-path": "^3.0.0", - "object-hash": "^2.2.0", - "postcss": "^8.4.6", - "postcss-js": "^4.0.0", - "postcss-load-config": "^3.1.0", - "postcss-nested": "5.0.6", - "postcss-selector-parser": "^6.0.9", - "postcss-value-parser": "^4.2.0", - "quick-lru": "^5.1.1", - "resolve": "^1.22.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "autoprefixer": "^10.0.2", - "postcss": "^8.0.9" - } - }, - "node_modules/tailwindcss-radix": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/tailwindcss-radix/-/tailwindcss-radix-1.6.0.tgz", - "integrity": "sha512-5oBgGCVGsITMiUVlc6Euj4kt03l8htLJxVT9AXbkFxcJiXLtQxJriFq/8R+3s63OKit/ynCVdkqvlnW6H7iG1g==" - }, - "node_modules/tailwindcss/node_modules/postcss-load-config": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.3.tgz", - "integrity": "sha512-5EYgaM9auHGtO//ljHH+v/aC/TQ5LHXtL7bQajNAUBKUVKiYE8rYpFms7+V26D9FncaGe2zwCoPQsFKb5zF/Hw==", - "dependencies": { - "lilconfig": "^2.0.4", - "yaml": "^1.10.2" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "ts-node": { - "optional": true - } - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true - }, - "node_modules/tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/tsconfig-paths": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", - "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", - "dev": true, - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.1", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - } - }, - "node_modules/tsutils/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - }, - "node_modules/type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, - "node_modules/typescript": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.2.tgz", - "integrity": "sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/use-callback-ref": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz", - "integrity": "sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" - }, - "node_modules/v8-compile-cache": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/websocket": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz", - "integrity": "sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==", - "dependencies": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.50", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/websocket/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/websocket/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, - "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/word-wrap": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", - "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", - "engines": { - "node": ">=0.10.32" - } - }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "engines": { - "node": ">= 6" - } - } - }, - "dependencies": { - "@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", - "requires": { - "@babel/highlight": "^7.16.7" - } - }, - "@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==" - }, - "@babel/highlight": { - "version": "7.16.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", - "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", - "requires": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", - "requires": { - "regenerator-runtime": "^0.14.0" - }, - "dependencies": { - "regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - } - } - }, - "@babel/runtime-corejs3": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.20.1.tgz", - "integrity": "sha512-CGulbEDcg/ND1Im7fUNRZdGXmX2MTWVVZacQi/6DiKE5HNwZ3aVTm5PV4lO8HHz0B2h8WQyvKKjbX5XgTtydsg==", - "dev": true, - "requires": { - "core-js-pure": "^3.25.1", - "regenerator-runtime": "^0.13.10" - } - }, - "@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", - "optional": true, - "requires": { - "tslib": "^2.4.0" - } - }, - "@eslint/eslintrc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.1.tgz", - "integrity": "sha512-bxvbYnBPN1Gibwyp6NrpnFzA3YtRL3BBAyEAFVIpNTm2Rn4Vy87GA5M4aSn3InRrlsbX5N0GW7XIx+U4SAEKdQ==", - "dev": true, - "requires": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.3.1", - "globals": "^13.9.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.0.4", - "strip-json-comments": "^3.1.1" - } - }, - "@headlessui/react": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.4.tgz", - "integrity": "sha512-D8n5yGCF3WIkPsjEYeM8knn9jQ70bigGGb5aUvN6y4BGxcT3OcOQOKcM3zRGllRCZCFxCZyQvYJF6ZE7bQUOyQ==", - "requires": { - "client-only": "^0.0.1" - } - }, - "@humanwhocodes/config-array": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", - "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==", - "dev": true, - "requires": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.4" - } - }, - "@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true - }, - "@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", - "optional": true, - "requires": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "optional": true, - "requires": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", - "optional": true - }, - "@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "optional": true - }, - "@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "optional": true - }, - "@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "optional": true - }, - "@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", - "optional": true - }, - "@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "optional": true - }, - "@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "optional": true - }, - "@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "optional": true - }, - "@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "optional": true, - "requires": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "optional": true, - "requires": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", - "optional": true, - "requires": { - "@img/sharp-libvips-linux-s390x": "1.0.4" - } - }, - "@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "optional": true, - "requires": { - "@img/sharp-libvips-linux-x64": "1.0.4" - } - }, - "@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "optional": true, - "requires": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" - } - }, - "@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "optional": true, - "requires": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" - } - }, - "@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", - "optional": true, - "requires": { - "@emnapi/runtime": "^1.2.0" - } - }, - "@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", - "optional": true - }, - "@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "optional": true - }, - "@mertasan/tailwindcss-variables": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@mertasan/tailwindcss-variables/-/tailwindcss-variables-2.5.1.tgz", - "integrity": "sha512-I1Jvpu5fcinGT/yEDL53dRXznFWV4LoTCUVcTvQqA1YH1iAfs72OO/VZdBKPqcxe/lS2nBr/Ikloe+pLsxemmA==", - "requires": { - "lodash": "^4.17.21" - } - }, - "@next/env": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz", - "integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==" - }, - "@next/eslint-plugin-next": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-12.3.4.tgz", - "integrity": "sha512-BFwj8ykJY+zc1/jWANsDprDIu2MgwPOIKxNVnrKvPs+f5TPegrVnem8uScND+1veT4B7F6VeqgaNLFW1Hzl9Og==", - "dev": true, - "requires": { - "glob": "7.1.7" - }, - "dependencies": { - "glob": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", - "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } - } - }, - "@next/swc-darwin-arm64": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz", - "integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==", - "optional": true - }, - "@next/swc-darwin-x64": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz", - "integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==", - "optional": true - }, - "@next/swc-linux-arm64-gnu": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz", - "integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==", - "optional": true - }, - "@next/swc-linux-arm64-musl": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz", - "integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==", - "optional": true - }, - "@next/swc-linux-x64-gnu": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz", - "integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==", - "optional": true - }, - "@next/swc-linux-x64-musl": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz", - "integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==", - "optional": true - }, - "@next/swc-win32-arm64-msvc": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz", - "integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==", - "optional": true - }, - "@next/swc-win32-x64-msvc": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz", - "integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==", - "optional": true - }, - "@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - } - }, - "@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" - }, - "@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "@radix-ui/colors": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-0.1.8.tgz", - "integrity": "sha512-jwRMXYwC0hUo0mv6wGpuw254Pd9p/R6Td5xsRpOmaWkUHlooNWqVcadgyzlRumMq3xfOTXwJReU0Jv+EIy4Jbw==" - }, - "@radix-ui/popper": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/popper/-/popper-0.1.0.tgz", - "integrity": "sha512-uzYeElL3w7SeNMuQpXiFlBhTT+JyaNMCwDfjKkrzugEcYrf5n52PHqncNdQPUtR42hJh8V9FsqyEDbDxkeNjJQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "csstype": "^3.0.4" - } - }, - "@radix-ui/primitive": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-0.1.0.tgz", - "integrity": "sha512-tqxZKybwN5Fa3VzZry4G6mXAAb9aAqKmPtnVbZpL0vsBwvOHTBwsjHVPXylocYLwEtBY9SCe665bYnNB515uoA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-presence": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-0.1.2.tgz", - "integrity": "sha512-3BRlFZraooIUfRlyN+b/Xs5hq1lanOOo/+3h6Pwu2GMFjkGKKa4Rd51fcqGqnVlbr3jYg+WLuGyAV4KlgqwrQw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-0.1.1.tgz", - "integrity": "sha512-g3hnE/UcOg7REdewduRPAK88EPuLZtaq7sA9ouu8S+YEtnyFRI16jgv6GZYe3VMoQLL1T171ebmEPtDjyxWLzw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@rushstack/eslint-patch": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", - "integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==", - "dev": true - }, - "@supabase/functions-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.0.0.tgz", - "integrity": "sha512-ozb7bds2yvf5k7NM2ZzUkxvsx4S4i2eRKFSJetdTADV91T65g4gCzEs9L3LUXSrghcGIkUaon03VPzOrFredqg==", - "requires": { - "cross-fetch": "^3.1.5" - } - }, - "@supabase/gotrue-js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@supabase/gotrue-js/-/gotrue-js-2.3.1.tgz", - "integrity": "sha512-txYVDrKAFXxT4nyVGnW3M9Oid4u3Xe/Na+wTEzwU+IBuPUEz72ZBHNKo6HBKlZNpnlGtgCSciYhH8qFkZYGV3g==", - "requires": { - "cross-fetch": "^3.1.5" - } - }, - "@supabase/postgrest-js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.1.0.tgz", - "integrity": "sha512-qkY8TqIu5sJuae8gjeDPjEqPrefzcTraW9PNSVJQHq4TEv98ZmwaXGwBGz0bVL63bqrGA5hqREbQHkANUTXrvA==", - "requires": { - "cross-fetch": "^3.1.5" - } - }, - "@supabase/realtime-js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.1.0.tgz", - "integrity": "sha512-iplLCofTeYjnx9FIOsIwHLhMp0+7UVyiA4/sCeq40VdOgN9eTIhjEno9Tgh4dJARi4aaXoKfRX1DTxgZaOpPAw==", - "requires": { - "@types/phoenix": "^1.5.4", - "websocket": "^1.0.34" - } - }, - "@supabase/storage-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.0.0.tgz", - "integrity": "sha512-7kXThdRt/xqnOOvZZxBqNkeX1CFNUWc0hYBJtNN/Uvt8ok9hD14foYmroWrHn046wEYFqUrB9U35JYsfTrvltA==", - "requires": { - "cross-fetch": "^3.1.5" - } - }, - "@supabase/supabase-js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.1.0.tgz", - "integrity": "sha512-hODrAUDSC6RV6EhwuSMyhaQCF32gij0EBTceuDR+8suJsg7XcyUG0fYgeYecWIvt0nz61xAMY6E+Ywb0tJaAng==", - "requires": { - "@supabase/functions-js": "^2.0.0", - "@supabase/gotrue-js": "^2.3.0", - "@supabase/postgrest-js": "^1.1.0", - "@supabase/realtime-js": "^2.1.0", - "@supabase/storage-js": "^2.0.0", - "cross-fetch": "^3.1.5" - } - }, - "@supabase/ui": { - "version": "0.37.0-alpha.81", - "resolved": "https://registry.npmjs.org/@supabase/ui/-/ui-0.37.0-alpha.81.tgz", - "integrity": "sha512-CxqdikE6wGw6pGQ6b3vRA8qnvCK20VyeMyy8Z4hJ/Dg2qRfgQqbrv7qS+6A1S8pg657EzCCo0DIH75SijaU8eA==", - "requires": { - "@headlessui/react": "^1.0.0", - "@mertasan/tailwindcss-variables": "^2.0.1", - "@radix-ui/colors": "^0.1.8", - "@radix-ui/react-accordion": "^0.1.5", - "@radix-ui/react-collapsible": "^0.1.5", - "@radix-ui/react-context-menu": "^0.1.0", - "@radix-ui/react-dialog": "^0.1.5", - "@radix-ui/react-dropdown-menu": "^0.1.4", - "@radix-ui/react-popover": "^0.1.0", - "@radix-ui/react-portal": "^0.1.3", - "@radix-ui/react-tabs": "^0.1.0", - "@tailwindcss/forms": "^0.4.0", - "@tailwindcss/typography": "^0.5.0", - "autoprefixer": "^10.4.2", - "deepmerge": "^4.2.2", - "formik": "^2.2.9", - "fsevents": "^2.3.2", - "lodash": "^4.17.20", - "postcss": "^8.4.5", - "prop-types": "^15.7.2", - "tailwindcss": "^3.0.15", - "tailwindcss-radix": "^1.6.0" - }, - "dependencies": { - "@radix-ui/react-accordion": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-0.1.6.tgz", - "integrity": "sha512-LOXlqPU6y6EMBopdRIKCWFvMPY1wPTQ4uJiX7ZVxldrMJcM7imBzI3wlRTkPCHZ3FLHmpuw+cQi3du23pzJp1g==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collapsible": "0.1.6", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-collapsible": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-0.1.6.tgz", - "integrity": "sha512-Gkf8VuqMc6HTLzA2AxVYnyK6aMczVLpatCjdD9Lj4wlYLXCz9KtiqZYslLMeqnQFLwLyZS0WKX/pQ8j5fioIBw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-context-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-0.1.6.tgz", - "integrity": "sha512-0qa6ABaeqD+WYI+8iT0jH0QLLcV8Kv0xI+mZL4FFnG4ec9H0v+yngb5cfBBfs9e/KM8mDzFFpaeegqsQlLNqyQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-menu": "0.1.6", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-0.1.6.tgz", - "integrity": "sha512-ho3+bhpr3oAFkOBJ8VkUb1BcGoiZBB3OmcWPqa6i5RTUKrzNX/d6rauochu2xDlWjiRtpVuiAcsTVOeIC4FbYQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-popper": "0.1.4", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-roving-focus": "0.1.5", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-direction": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "dependencies": { - "@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - } - } - }, - "@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-popper": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.4.tgz", - "integrity": "sha512-18gDYof97t8UQa7zwklG1Dr8jIdj3u+rVOQLzPi9f5i1YQak/pVGkaqw8aY+iDUknKKuZniTk/7jbAJUYlKyOw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/popper": "0.1.0", - "@radix-ui/react-arrow": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-rect": "0.1.1", - "@radix-ui/react-use-size": "0.1.1", - "@radix-ui/rect": "0.1.1" - }, - "dependencies": { - "@radix-ui/react-arrow": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.4.tgz", - "integrity": "sha512-BB6XzAb7Ml7+wwpFdYVtZpK1BlMgqyafSQNGzhIpSZ4uXvXOHPlR5GP8M449JkeQzgQjv9Mp1AsJxFC0KuOtuA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4" - } - }, - "@radix-ui/react-use-rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz", - "integrity": "sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "0.1.1" - } - }, - "@radix-ui/react-use-size": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz", - "integrity": "sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-roving-focus": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-0.1.5.tgz", - "integrity": "sha512-ClwKPS5JZE+PaHCoW7eu1onvE61pDv4kO8W4t5Ra3qMFQiTJLZMdpBQUhksN//DaVygoLirz4Samdr5Y1x1FSA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - } - } - }, - "@radix-ui/react-use-direction": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-direction/-/react-use-direction-0.1.0.tgz", - "integrity": "sha512-NajpY/An9TCPSfOVkgWIdXJV+VuWl67PxB6kOKYmtNAFHvObzIoh8o0n9sAuwSAyFCZVq211FEf9gvVDRhOyiA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-dialog": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-0.1.7.tgz", - "integrity": "sha512-jXt8srGhHBRvEr9jhEAiwwJzWCWZoGRJ030aC9ja/gkRJbZdy0iD3FwXf+Ff4RtsZyLUMHW7VUwFOlz3Ixe1Vw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2", - "@radix-ui/react-use-controllable-state": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - } - } - }, - "@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - } - }, - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-dropdown-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-0.1.6.tgz", - "integrity": "sha512-RZhtzjWwJ4ZBN7D8ek4Zn+ilHzYuYta9yIxFnbC0pfqMnSi67IQNONo1tuuNqtFh9SRHacPKc65zo+kBBlxtdg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-menu": "0.1.6", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-0.1.6.tgz", - "integrity": "sha512-ho3+bhpr3oAFkOBJ8VkUb1BcGoiZBB3OmcWPqa6i5RTUKrzNX/d6rauochu2xDlWjiRtpVuiAcsTVOeIC4FbYQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-popper": "0.1.4", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-roving-focus": "0.1.5", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-direction": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "dependencies": { - "@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - } - } - }, - "@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - }, - "@radix-ui/react-popper": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.4.tgz", - "integrity": "sha512-18gDYof97t8UQa7zwklG1Dr8jIdj3u+rVOQLzPi9f5i1YQak/pVGkaqw8aY+iDUknKKuZniTk/7jbAJUYlKyOw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/popper": "0.1.0", - "@radix-ui/react-arrow": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-rect": "0.1.1", - "@radix-ui/react-use-size": "0.1.1", - "@radix-ui/rect": "0.1.1" - }, - "dependencies": { - "@radix-ui/react-arrow": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.4.tgz", - "integrity": "sha512-BB6XzAb7Ml7+wwpFdYVtZpK1BlMgqyafSQNGzhIpSZ4uXvXOHPlR5GP8M449JkeQzgQjv9Mp1AsJxFC0KuOtuA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4" - } - }, - "@radix-ui/react-use-rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz", - "integrity": "sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "0.1.1" - } - }, - "@radix-ui/react-use-size": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz", - "integrity": "sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-roving-focus": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-0.1.5.tgz", - "integrity": "sha512-ClwKPS5JZE+PaHCoW7eu1onvE61pDv4kO8W4t5Ra3qMFQiTJLZMdpBQUhksN//DaVygoLirz4Samdr5Y1x1FSA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-controllable-state": "0.1.0" - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-use-direction": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-direction/-/react-use-direction-0.1.0.tgz", - "integrity": "sha512-NajpY/An9TCPSfOVkgWIdXJV+VuWl67PxB6kOKYmtNAFHvObzIoh8o0n9sAuwSAyFCZVq211FEf9gvVDRhOyiA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-popover": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-0.1.6.tgz", - "integrity": "sha512-zQzgUqW4RQDb0ItAL1xNW4K4olUrkfV3jeEPs9rG+nsDQurO+W9TT+YZ9H1mmgAJqlthyv1sBRZGdBm4YjtD6Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-popper": "0.1.4", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - } - } - }, - "@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-popper": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.4.tgz", - "integrity": "sha512-18gDYof97t8UQa7zwklG1Dr8jIdj3u+rVOQLzPi9f5i1YQak/pVGkaqw8aY+iDUknKKuZniTk/7jbAJUYlKyOw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/popper": "0.1.0", - "@radix-ui/react-arrow": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-rect": "0.1.1", - "@radix-ui/react-use-size": "0.1.1", - "@radix-ui/rect": "0.1.1" - }, - "dependencies": { - "@radix-ui/react-arrow": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.4.tgz", - "integrity": "sha512-BB6XzAb7Ml7+wwpFdYVtZpK1BlMgqyafSQNGzhIpSZ4uXvXOHPlR5GP8M449JkeQzgQjv9Mp1AsJxFC0KuOtuA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4" - } - }, - "@radix-ui/react-use-rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz", - "integrity": "sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "0.1.1" - } - }, - "@radix-ui/react-use-size": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz", - "integrity": "sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-portal": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-0.1.4.tgz", - "integrity": "sha512-MO0wRy2eYRTZ/CyOri9NANCAtAtq89DEtg90gicaTlkCfdqCLEBsLb+/q66BZQTr3xX/Vq01nnVfc/TkCqoqvw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-tabs": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-0.1.5.tgz", - "integrity": "sha512-ieVQS1TFr0dX1XA8B+CsSFKOE7kcgEaNWWEfItxj9D1GZjn1o3WqPkW+FhQWDAWZLSKCH2PezYF3MNyO41lgJg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-roving-focus": "0.1.5", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-roving-focus": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-0.1.5.tgz", - "integrity": "sha512-ClwKPS5JZE+PaHCoW7eu1onvE61pDv4kO8W4t5Ra3qMFQiTJLZMdpBQUhksN//DaVygoLirz4Samdr5Y1x1FSA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - } - } - }, - "@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" - }, - "@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "requires": { - "tslib": "^2.8.0" - } - }, - "@tailwindcss/forms": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.4.1.tgz", - "integrity": "sha512-gS9xjCmJjUBz/eP12QlENPLnf0tCx68oYE3mri0GMP5jdtVwLbGUNSRpjsp6NzLAZzZy3ueOwrcqB78Ax6Z84A==", - "requires": { - "mini-svg-data-uri": "^1.2.3" - } - }, - "@tailwindcss/typography": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.8.tgz", - "integrity": "sha512-xGQEp8KXN8Sd8m6R4xYmwxghmswrd0cPnNI2Lc6fmrC3OojysTBJJGSIVwPV56q4t6THFUK3HJ0EaWwpglSxWw==", - "requires": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "postcss-selector-parser": "6.0.10" - } - }, - "@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true - }, - "@types/lodash": { - "version": "4.14.180", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.180.tgz", - "integrity": "sha512-XOKXa1KIxtNXgASAnwj7cnttJxS4fksBRywK/9LzRV5YxrF80BXZIGeQSuoESQ/VkUj30Ae0+YcuHc15wJCB2g==", - "dev": true - }, - "@types/lodash.clonedeep": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.6.tgz", - "integrity": "sha512-cE1jYr2dEg1wBImvXlNtp0xDoS79rfEdGozQVgliDZj1uERH4k+rmEMTudP9b4VQ8O6nRb5gPqft0QzEQGMQgA==", - "dev": true, - "requires": { - "@types/lodash": "*" - } - }, - "@types/lodash.samplesize": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/@types/lodash.samplesize/-/lodash.samplesize-4.2.6.tgz", - "integrity": "sha512-yBgEuIxVIM+corHdvB+NHgzni1Oc0aEd7acuO/jET0vO2Y2f6sl7vfQlaZKgzcN+ZqWLB6B2VQTKc1T5zQra+Q==", - "dev": true, - "requires": { - "@types/lodash": "*" - } - }, - "@types/lodash.throttle": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.6.tgz", - "integrity": "sha512-/UIH96i/sIRYGC60NoY72jGkCJtFN5KVPhEMMMTjol65effe1gPn0tycJqV5tlSwMTzX8FqzB5yAj0rfGHTPNg==", - "dev": true, - "requires": { - "@types/lodash": "*" - } - }, - "@types/node": { - "version": "17.0.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz", - "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==", - "dev": true - }, - "@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" - }, - "@types/phoenix": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.5.4.tgz", - "integrity": "sha512-L5eZmzw89eXBKkiqVBcJfU1QGx9y+wurRIEgt0cuLH0hwNtVUxtx+6cu0R2STwWj468sjXyBYPYDtGclUd1kjQ==" - }, - "@types/prop-types": { - "version": "15.7.4", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", - "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==", - "devOptional": true - }, - "@types/react": { - "version": "17.0.41", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.41.tgz", - "integrity": "sha512-chYZ9ogWUodyC7VUTRBfblysKLjnohhFY9bGLwvnUFFy48+vB9DikmB3lW0qTFmBcKSzmdglcvkHK71IioOlDA==", - "devOptional": true, - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "devOptional": true - }, - "@typescript-eslint/parser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.44.0.tgz", - "integrity": "sha512-H7LCqbZnKqkkgQHaKLGC6KUjt3pjJDx8ETDqmwncyb6PuoigYajyAwBGz08VU/l86dZWZgI4zm5k2VaKqayYyA==", - "dev": true, - "requires": { - "@typescript-eslint/scope-manager": "5.44.0", - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/typescript-estree": "5.44.0", - "debug": "^4.3.4" - } - }, - "@typescript-eslint/scope-manager": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.44.0.tgz", - "integrity": "sha512-2pKml57KusI0LAhgLKae9kwWeITZ7IsZs77YxyNyIVOwQ1kToyXRaJLl+uDEXzMN5hnobKUOo2gKntK9H1YL8g==", - "dev": true, - "requires": { - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/visitor-keys": "5.44.0" - } - }, - "@typescript-eslint/types": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.44.0.tgz", - "integrity": "sha512-Tp+zDnHmGk4qKR1l+Y1rBvpjpm5tGXX339eAlRBDg+kgZkz9Bw+pqi4dyseOZMsGuSH69fYfPJCBKBrbPCxYFQ==", - "dev": true - }, - "@typescript-eslint/typescript-estree": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.44.0.tgz", - "integrity": "sha512-M6Jr+RM7M5zeRj2maSfsZK2660HKAJawv4Ud0xT+yauyvgrsHu276VtXlKDFnEmhG+nVEd0fYZNXGoAgxwDWJw==", - "dev": true, - "requires": { - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/visitor-keys": "5.44.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - } - }, - "@typescript-eslint/visitor-keys": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.44.0.tgz", - "integrity": "sha512-a48tLG8/4m62gPFbJ27FxwCOqPKxsb8KC3HkmYoq2As/4YyjQl1jDbRr1s63+g4FS/iIehjmN3L5UjmKva1HzQ==", - "dev": true, - "requires": { - "@typescript-eslint/types": "5.44.0", - "eslint-visitor-keys": "^3.3.0" - } - }, - "acorn": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", - "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", - "dev": true - }, - "acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} - }, - "acorn-node": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", - "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", - "requires": { - "acorn": "^7.0.0", - "acorn-walk": "^7.0.0", - "xtend": "^4.0.2" - }, - "dependencies": { - "acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" - } - } - }, - "acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==" - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "anymatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", - "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "requires": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - } - }, - "arg": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", - "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==" - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "aria-hidden": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.2.tgz", - "integrity": "sha512-6y/ogyDTk/7YAe91T3E2PR1ALVKyM2QbTio5HwM+N1Q6CMlCKhvClyIjkckBswa0f2xJhjsfzIGa1yVSe1UMVA==", - "requires": { - "tslib": "^2.0.0" - } - }, - "aria-query": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", - "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.10.2", - "@babel/runtime-corejs3": "^7.10.2" - } - }, - "array-includes": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", - "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "is-string": "^1.0.7" - } - }, - "array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true - }, - "array.prototype.flat": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz", - "integrity": "sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" - } - }, - "array.prototype.flatmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz", - "integrity": "sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" - } - }, - "array.prototype.tosorted": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.1.tgz", - "integrity": "sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.1.3" - } - }, - "ast-types-flow": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", - "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==", - "dev": true - }, - "autoprefixer": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.4.tgz", - "integrity": "sha512-Tm8JxsB286VweiZ5F0anmbyGiNI3v3wGv3mz9W+cxEDYB/6jbnj6GM9H9mK3wIL8ftgl+C07Lcwb8PG5PCCPzA==", - "requires": { - "browserslist": "^4.20.2", - "caniuse-lite": "^1.0.30001317", - "fraction.js": "^4.2.0", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - } - }, - "axe-core": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.5.2.tgz", - "integrity": "sha512-u2MVsXfew5HBvjsczCv+xlwdNnB1oQR9HlAcsejZttNjKKSkeDNVwB1vMThIUIFI9GoT57Vtk8iQLwqOfAkboA==", - "dev": true - }, - "axobject-query": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", - "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==", - "dev": true - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "requires": { - "fill-range": "^7.1.1" - } - }, - "browserslist": { - "version": "4.20.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.2.tgz", - "integrity": "sha512-CQOBCqp/9pDvDbx3xfMi+86pr4KXIf2FDkTTdeuYw8OxS9t898LA1Khq57gtufFILXpfgsSx5woNgsBgvGjpsA==", - "requires": { - "caniuse-lite": "^1.0.30001317", - "electron-to-chromium": "^1.4.84", - "escalade": "^3.1.1", - "node-releases": "^2.0.2", - "picocolors": "^1.0.0" - } - }, - "bufferutil": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz", - "integrity": "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==", - "requires": { - "node-gyp-build": "^4.3.0" - } - }, - "busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "requires": { - "streamsearch": "^1.1.0" - } - }, - "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, - "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" - }, - "camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==" - }, - "caniuse-lite": { - "version": "1.0.30001689", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001689.tgz", - "integrity": "sha512-CmeR2VBycfa+5/jOfnp/NpWPGd06nf1XYiefUvhXFfZE4GkRc9jv+eGPS4nT558WS/8lYCzV8SlANCIPvbWP1g==" - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "requires": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "fsevents": "~2.3.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "dependencies": { - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "requires": { - "is-glob": "^4.0.1" - } - } - } - }, - "client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" - }, - "color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "optional": true, - "requires": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "optional": true, - "requires": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "core-js-pure": { - "version": "3.26.1", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.26.1.tgz", - "integrity": "sha512-VVXcDpp/xJ21KdULRq/lXdLzQAtX7+37LzpyfFM973il0tWSsDEoyzG38G14AjTpK9VTfiNM9jnFauq/CpaWGQ==", - "dev": true - }, - "cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - } - }, - "cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", - "requires": { - "node-fetch": "2.6.7" - } - }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==" - }, - "csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" - }, - "d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "requires": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, - "damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true - }, - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" - }, - "define-properties": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", - "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", - "dev": true, - "requires": { - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - } - }, - "defined": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", - "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=" - }, - "detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "optional": true - }, - "detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, - "detective": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", - "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", - "requires": { - "acorn-node": "^1.6.1", - "defined": "^1.0.0", - "minimist": "^1.1.1" - } - }, - "didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" - }, - "dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "requires": { - "path-type": "^4.0.0" - } - }, - "dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" - }, - "doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "electron-to-chromium": { - "version": "1.4.93", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.93.tgz", - "integrity": "sha512-ywq9Pc5Gwwpv7NG767CtoU8xF3aAUQJjH9//Wy3MBCg4w5JSLbJUq2L8IsCdzPMjvSgxuue9WcVaTOyyxCL0aQ==" - }, - "emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "requires": { - "is-arrayish": "^0.2.1" - } - }, - "es-abstract": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.4.tgz", - "integrity": "sha512-0UtvRN79eMe2L+UNEF1BwRe364sj/DXhQ/k5FmivgoSdpM90b8Jc0mDzKMGo7QS0BVbOP/bTwBKNnDc9rNzaPA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.1.3", - "get-symbol-description": "^1.0.0", - "has": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "is-string": "^1.0.7", - "is-weakref": "^1.0.2", - "object-inspect": "^1.12.2", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", - "safe-regex-test": "^1.0.0", - "string.prototype.trimend": "^1.0.5", - "string.prototype.trimstart": "^1.0.5", - "unbox-primitive": "^1.0.2" - } - }, - "es-shim-unscopables": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", - "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, - "requires": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - } - }, - "es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "requires": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - } - }, - "es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "requires": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "requires": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" - }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, - "eslint": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.11.0.tgz", - "integrity": "sha512-/KRpd9mIRg2raGxHRGwW9ZywYNAClZrHjdueHcrVDuO3a6bj83eoTirCCk0M0yPwOjWYKHwRVRid+xK4F/GHgA==", - "dev": true, - "requires": { - "@eslint/eslintrc": "^1.2.1", - "@humanwhocodes/config-array": "^0.9.2", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.1", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^6.0.1", - "globals": "^13.6.0", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.0.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - }, - "dependencies": { - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - } - } - }, - "eslint-config-next": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-12.3.4.tgz", - "integrity": "sha512-WuT3gvgi7Bwz00AOmKGhOeqnyA5P29Cdyr0iVjLyfDbk+FANQKcOjFUTZIdyYfe5Tq1x4TGcmoe4CwctGvFjHQ==", - "dev": true, - "requires": { - "@next/eslint-plugin-next": "12.3.4", - "@rushstack/eslint-patch": "^1.1.3", - "@typescript-eslint/parser": "^5.21.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^2.7.1", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.31.7", - "eslint-plugin-react-hooks": "^4.5.0" - } - }, - "eslint-import-resolver-node": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", - "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", - "dev": true, - "requires": { - "debug": "^3.2.7", - "resolve": "^1.20.0" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - } - } - }, - "eslint-import-resolver-typescript": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-2.7.1.tgz", - "integrity": "sha512-00UbgGwV8bSgUv34igBDbTOtKhqoRMy9bFjNehT40bXg6585PNIct8HhXZ0SybqB9rWtXj9crcku8ndDn/gIqQ==", - "dev": true, - "requires": { - "debug": "^4.3.4", - "glob": "^7.2.0", - "is-glob": "^4.0.3", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" - } - }, - "eslint-module-utils": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz", - "integrity": "sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==", - "dev": true, - "requires": { - "debug": "^3.2.7" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - } - } - }, - "eslint-plugin-import": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", - "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", - "dev": true, - "requires": { - "array-includes": "^3.1.4", - "array.prototype.flat": "^1.2.5", - "debug": "^2.6.9", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-module-utils": "^2.7.3", - "has": "^1.0.3", - "is-core-module": "^2.8.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.values": "^1.1.5", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - } - } - }, - "eslint-plugin-jsx-a11y": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.6.1.tgz", - "integrity": "sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==", - "dev": true, - "requires": { - "@babel/runtime": "^7.18.9", - "aria-query": "^4.2.2", - "array-includes": "^3.1.5", - "ast-types-flow": "^0.0.7", - "axe-core": "^4.4.3", - "axobject-query": "^2.2.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "has": "^1.0.3", - "jsx-ast-utils": "^3.3.2", - "language-tags": "^1.0.5", - "minimatch": "^3.1.2", - "semver": "^6.3.0" - }, - "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } - } - }, - "eslint-plugin-react": { - "version": "7.31.11", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.31.11.tgz", - "integrity": "sha512-TTvq5JsT5v56wPa9OYHzsrOlHzKZKjV+aLgS+55NJP/cuzdiQPC7PfYoUjMoxlffKtvijpk7vA/jmuqRb9nohw==", - "dev": true, - "requires": { - "array-includes": "^3.1.6", - "array.prototype.flatmap": "^1.3.1", - "array.prototype.tosorted": "^1.1.1", - "doctrine": "^2.1.0", - "estraverse": "^5.3.0", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.6", - "object.fromentries": "^2.0.6", - "object.hasown": "^1.1.2", - "object.values": "^1.1.6", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.3", - "semver": "^6.3.0", - "string.prototype.matchall": "^4.0.8" - }, - "dependencies": { - "resolve": { - "version": "2.0.0-next.4", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz", - "integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==", - "dev": true, - "requires": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } - } - }, - "eslint-plugin-react-hooks": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", - "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", - "dev": true, - "requires": {} - }, - "eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - } - }, - "eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^2.0.0" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true - } - } - }, - "eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", - "dev": true - }, - "esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "requires": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "dependencies": { - "type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - } - } - }, - "espree": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.1.tgz", - "integrity": "sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ==", - "dev": true, - "requires": { - "acorn": "^8.7.0", - "acorn-jsx": "^5.3.1", - "eslint-visitor-keys": "^3.3.0" - } - }, - "esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", - "dev": true, - "requires": { - "estraverse": "^5.1.0" - } - }, - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "requires": { - "estraverse": "^5.2.0" - } - }, - "estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "requires": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "requires": { - "type": "^2.7.2" - }, - "dependencies": { - "type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - } - } - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-glob": { - "version": "3.2.11", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", - "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", - "requires": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "dependencies": { - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "requires": { - "is-glob": "^4.0.1" - } - } - } - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", - "dev": true - }, - "fastq": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", - "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", - "requires": { - "reusify": "^1.0.4" - } - }, - "file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "requires": { - "flat-cache": "^3.0.4" - } - }, - "fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "requires": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - } - }, - "flatted": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz", - "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==", - "dev": true - }, - "formik": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz", - "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==", - "requires": { - "deepmerge": "^2.1.1", - "hoist-non-react-statics": "^3.3.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "react-fast-compare": "^2.0.1", - "tiny-warning": "^1.0.2", - "tslib": "^1.10.0" - }, - "dependencies": { - "deepmerge": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", - "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==" - }, - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "fraction.js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", - "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==" - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "optional": true - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" - } - }, - "functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", - "dev": true - }, - "functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true - }, - "get-intrinsic": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", - "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", - "dev": true, - "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" - } - }, - "get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" - }, - "get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - } - }, - "glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "requires": { - "is-glob": "^4.0.3" - } - }, - "globals": { - "version": "13.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz", - "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - } - }, - "globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "requires": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - } - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", - "dev": true, - "requires": { - "get-intrinsic": "^1.1.1" - } - }, - "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true - }, - "has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, - "requires": { - "has-symbols": "^1.0.2" - } - }, - "hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "requires": { - "react-is": "^16.7.0" - } - }, - "ignore": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", - "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", - "dev": true - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", - "dev": true, - "requires": { - "get-intrinsic": "^1.1.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" - } - }, - "invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "requires": { - "loose-envify": "^1.0.0" - } - }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" - }, - "is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "requires": { - "has-bigints": "^1.0.1" - } - }, - "is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "requires": { - "binary-extensions": "^2.0.0" - } - }, - "is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true - }, - "is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "requires": { - "has": "^1.0.3" - } - }, - "is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" - }, - "is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", - "dev": true - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" - }, - "is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2" - } - }, - "is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "requires": { - "has-symbols": "^1.0.2" - } - }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" - }, - "is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2" - } - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "requires": { - "minimist": "^1.2.0" - } - }, - "jsx-ast-utils": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", - "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", - "dev": true, - "requires": { - "array-includes": "^3.1.5", - "object.assign": "^4.1.3" - } - }, - "language-subtag-registry": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", - "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", - "dev": true - }, - "language-tags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", - "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", - "dev": true, - "requires": { - "language-subtag-registry": "~0.3.2" - } - }, - "levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - } - }, - "lilconfig": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.5.tgz", - "integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==" - }, - "lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" - }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" - }, - "lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==" - }, - "lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" - }, - "lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" - }, - "lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" - }, - "lodash.samplesize": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.samplesize/-/lodash.samplesize-4.2.0.tgz", - "integrity": "sha1-Rgdi+7KzQikFF0mekNUVhttGX/k=" - }, - "lodash.throttle": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=" - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" - }, - "micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "requires": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - } - }, - "mini-svg-data-uri": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", - "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==" - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", - "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==" - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==" - }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true - }, - "next": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz", - "integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==", - "requires": { - "@next/env": "15.2.4", - "@next/swc-darwin-arm64": "15.2.4", - "@next/swc-darwin-x64": "15.2.4", - "@next/swc-linux-arm64-gnu": "15.2.4", - "@next/swc-linux-arm64-musl": "15.2.4", - "@next/swc-linux-x64-gnu": "15.2.4", - "@next/swc-linux-x64-musl": "15.2.4", - "@next/swc-win32-arm64-msvc": "15.2.4", - "@next/swc-win32-x64-msvc": "15.2.4", - "@swc/counter": "0.1.3", - "@swc/helpers": "0.5.15", - "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "sharp": "^0.33.5", - "styled-jsx": "5.1.6" - }, - "dependencies": { - "postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "requires": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - } - } - } - }, - "next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" - }, - "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "requires": { - "whatwg-url": "^5.0.0" - } - }, - "node-gyp-build": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.5.0.tgz", - "integrity": "sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==" - }, - "node-releases": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.2.tgz", - "integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==" - }, - "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" - }, - "normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=" - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" - }, - "object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" - }, - "object-inspect": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", - "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", - "dev": true - }, - "object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true - }, - "object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - } - }, - "object.entries": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.6.tgz", - "integrity": "sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "object.fromentries": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.6.tgz", - "integrity": "sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "object.hasown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.2.tgz", - "integrity": "sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==", - "dev": true, - "requires": { - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "object.values": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.6.tgz", - "integrity": "sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, - "requires": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" - } - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "requires": { - "callsites": "^3.0.0" - } - }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" - }, - "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" - }, - "postcss": { - "version": "8.4.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", - "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", - "requires": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - } - }, - "postcss-js": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", - "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", - "requires": { - "camelcase-css": "^2.0.1" - } - }, - "postcss-nested": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz", - "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==", - "requires": { - "postcss-selector-parser": "^6.0.6" - } - }, - "postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "requires": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - } - }, - "postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" - }, - "prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true - }, - "prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" - }, - "quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==" - }, - "react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - } - }, - "react-fast-compare": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", - "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" - }, - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "react-remove-scroll": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", - "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", - "requires": { - "react-remove-scroll-bar": "^2.3.3", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - } - }, - "react-remove-scroll-bar": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz", - "integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==", - "requires": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - } - }, - "react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "requires": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - } - }, - "readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "requires": { - "picomatch": "^2.2.1" - } - }, - "regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true - }, - "regexp.prototype.flags": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", - "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "functions-have-names": "^1.2.2" - } - }, - "regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true - }, - "resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", - "requires": { - "is-core-module": "^2.8.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" - }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "is-regex": "^1.1.4" - } - }, - "scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true - }, - "sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", - "optional": true, - "requires": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5", - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" - } - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, - "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, - "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - } - }, - "simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "optional": true, - "requires": { - "is-arrayish": "^0.3.1" - }, - "dependencies": { - "is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "optional": true - } - } - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - }, - "source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" - }, - "streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" - }, - "string.prototype.matchall": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", - "integrity": "sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "regexp.prototype.flags": "^1.4.3", - "side-channel": "^1.0.4" - } - }, - "string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true - }, - "strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true - }, - "styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "requires": { - "client-only": "0.0.1" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "requires": { - "has-flag": "^4.0.0" - } - }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" - }, - "tailwindcss": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.0.23.tgz", - "integrity": "sha512-+OZOV9ubyQ6oI2BXEhzw4HrqvgcARY38xv3zKcjnWtMIZstEsXdI9xftd1iB7+RbOnj2HOEzkA0OyB5BaSxPQA==", - "requires": { - "arg": "^5.0.1", - "chalk": "^4.1.2", - "chokidar": "^3.5.3", - "color-name": "^1.1.4", - "cosmiconfig": "^7.0.1", - "detective": "^5.2.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.2.11", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "normalize-path": "^3.0.0", - "object-hash": "^2.2.0", - "postcss": "^8.4.6", - "postcss-js": "^4.0.0", - "postcss-load-config": "^3.1.0", - "postcss-nested": "5.0.6", - "postcss-selector-parser": "^6.0.9", - "postcss-value-parser": "^4.2.0", - "quick-lru": "^5.1.1", - "resolve": "^1.22.0" - }, - "dependencies": { - "postcss-load-config": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.3.tgz", - "integrity": "sha512-5EYgaM9auHGtO//ljHH+v/aC/TQ5LHXtL7bQajNAUBKUVKiYE8rYpFms7+V26D9FncaGe2zwCoPQsFKb5zF/Hw==", - "requires": { - "lilconfig": "^2.0.4", - "yaml": "^1.10.2" - } - } - } - }, - "tailwindcss-radix": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/tailwindcss-radix/-/tailwindcss-radix-1.6.0.tgz", - "integrity": "sha512-5oBgGCVGsITMiUVlc6Euj4kt03l8htLJxVT9AXbkFxcJiXLtQxJriFq/8R+3s63OKit/ynCVdkqvlnW6H7iG1g==" - }, - "text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true - }, - "tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "requires": { - "is-number": "^7.0.0" - } - }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "tsconfig-paths": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", - "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", - "dev": true, - "requires": { - "@types/json5": "^0.0.29", - "json5": "^1.0.1", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "requires": { - "tslib": "^1.8.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - } - } - }, - "type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" - }, - "type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1" - } - }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - }, - "typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "requires": { - "is-typedarray": "^1.0.0" - } - }, - "typescript": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.2.tgz", - "integrity": "sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==", - "dev": true - }, - "unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - } - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "use-callback-ref": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz", - "integrity": "sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==", - "requires": { - "tslib": "^2.0.0" - } - }, - "use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "requires": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - } - }, - "utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "requires": { - "node-gyp-build": "^4.3.0" - } - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" - }, - "v8-compile-cache": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true - }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "websocket": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz", - "integrity": "sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==", - "requires": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.50", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - } - } - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, - "requires": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - } - }, - "word-wrap": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", - "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", - "dev": true - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" - }, - "yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==" - }, - "yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" - } - } -} diff --git a/demo/package.json b/demo/package.json deleted file mode 100644 index 00dcfcf0b..000000000 --- a/demo/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "demo", - "version": "0.1.2", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "@supabase/supabase-js": "^2.1.0", - "@supabase/ui": "0.37.0-alpha.81", - "lodash.clonedeep": "^4.5.0", - "lodash.samplesize": "^4.2.0", - "lodash.throttle": "^4.1.1", - "next": "^15.2.4", - "react": "17.0.2", - "react-dom": "17.0.2" - }, - "devDependencies": { - "@types/lodash.clonedeep": "^4.5.6", - "@types/lodash.samplesize": "^4.2.6", - "@types/lodash.throttle": "^4.1.6", - "@types/node": "17.0.21", - "@types/react": "17.0.41", - "autoprefixer": "^10.4.4", - "eslint": "8.11.0", - "eslint-config-next": "^12.3.4", - "postcss": "^8.4.31", - "tailwindcss": "^3.0.23", - "typescript": "4.6.2" - } -} diff --git a/demo/pages/[...slug].tsx b/demo/pages/[...slug].tsx deleted file mode 100644 index 07c5be211..000000000 --- a/demo/pages/[...slug].tsx +++ /dev/null @@ -1,578 +0,0 @@ -import { useEffect, useState, useRef, ReactElement } from 'react' -import type { NextPage } from 'next' -import { useRouter } from 'next/router' -import { nanoid } from 'nanoid' -import cloneDeep from 'lodash.clonedeep' -import throttle from 'lodash.throttle' -import { Badge } from '@supabase/ui' -import { - PostgrestResponse, - REALTIME_LISTEN_TYPES, - REALTIME_POSTGRES_CHANGES_LISTEN_EVENT, - REALTIME_PRESENCE_LISTEN_EVENTS, - REALTIME_SUBSCRIBE_STATES, - RealtimeChannel, - RealtimeChannelSendResponse, - RealtimePostgresInsertPayload, -} from '@supabase/supabase-js' - -import supabaseClient from '../client' -import { Coordinates, Message, Payload, User } from '../types' -import { removeFirst } from '../utils' -import { getRandomColor, getRandomColors, getRandomUniqueColor } from '../lib/RandomColor' -import { sendLog } from '../lib/sendLog' - -import Chatbox from '../components/Chatbox' -import Cursor from '../components/Cursor' -import Loader from '../components/Loader' -import Users from '../components/Users' -import WaitlistPopover from '../components/WaitlistPopover' -import DarkModeToggle from '../components/DarkModeToggle' - -const LATENCY_THRESHOLD = 400 -const MAX_ROOM_USERS = 50 -const MAX_DISPLAY_MESSAGES = 50 -const MAX_EVENTS_PER_SECOND = 10 -const X_THRESHOLD = 25 -const Y_THRESHOLD = 35 - -// Generate a random user id -const userId = nanoid() - -const Room: NextPage = () => { - const router = useRouter() - - const localColorBackup = getRandomColor() - - const chatboxRef = useRef() - // [Joshen] Super hacky fix for a really weird bug for onKeyDown - // input field. For some reason the first keydown event appends the character twice - const chatInputFix = useRef(true) - - // These states will be managed via ref as they're mutated within event listeners - const usersRef = useRef<{ [key: string]: User }>({}) - const isTypingRef = useRef(false) - const isCancelledRef = useRef(false) - const messageRef = useRef() - const messagesInTransitRef = useRef() - const mousePositionRef = useRef() - - const joinTimestampRef = useRef() - const insertMsgTimestampRef = useRef() - - // We manage the refs with a state so that the UI can re-render - const [isTyping, _setIsTyping] = useState(false) - const [isCancelled, _setIsCancelled] = useState(false) - const [message, _setMessage] = useState('') - const [messagesInTransit, _setMessagesInTransit] = useState([]) - const [mousePosition, _setMousePosition] = useState() - - const [areMessagesFetched, setAreMessagesFetched] = useState(false) - const [isInitialStateSynced, setIsInitialStateSynced] = useState(false) - const [latency, setLatency] = useState(0) - const [messages, setMessages] = useState([]) - const [roomId, setRoomId] = useState(undefined) - const [users, setUsers] = useState<{ [key: string]: User }>({}) - - const setIsTyping = (value: boolean) => { - isTypingRef.current = value - _setIsTyping(value) - } - - const setIsCancelled = (value: boolean) => { - isCancelledRef.current = value - _setIsCancelled(value) - } - - const setMessage = (value: string) => { - messageRef.current = value - _setMessage(value) - } - - const setMousePosition = (coordinates: Coordinates) => { - mousePositionRef.current = coordinates - _setMousePosition(coordinates) - } - - const setMessagesInTransit = (messages: string[]) => { - messagesInTransitRef.current = messages - _setMessagesInTransit(messages) - } - - const mapInitialUsers = (userChannel: RealtimeChannel, roomId: string) => { - const state = userChannel.presenceState() - const _users = state[roomId] - - if (!_users) return - - // Deconflict duplicate colours at the beginning of the browser session - const colors = Object.keys(usersRef.current).length === 0 ? getRandomColors(_users.length) : [] - - if (_users) { - setUsers((existingUsers) => { - const updatedUsers = _users.reduce( - (acc: { [key: string]: User }, { user_id: userId }: any, index: number) => { - const userColors = Object.values(usersRef.current).map((user: any) => user.color) - // Deconflict duplicate colors for incoming clients during the browser session - const color = colors.length > 0 ? colors[index] : getRandomUniqueColor(userColors) - - acc[userId] = existingUsers[userId] || { - x: 0, - y: 0, - color: color.bg, - hue: color.hue, - } - return acc - }, - {} - ) - usersRef.current = updatedUsers - return updatedUsers - }) - } - } - - useEffect(() => { - let roomChannel: RealtimeChannel - - const { slug } = router.query - const slugRoomId = Array.isArray(slug) ? slug[0] : undefined - - if (!roomId) { - // roomId is undefined when user first attempts to join a room - - joinTimestampRef.current = performance.now() - - /* - Client is joining 'rooms' channel to examine existing rooms and their users - and then the channel is removed once a room is selected - */ - roomChannel = supabaseClient.channel('rooms') - - roomChannel - .on(REALTIME_LISTEN_TYPES.PRESENCE, { event: REALTIME_PRESENCE_LISTEN_EVENTS.SYNC }, () => { - let newRoomId - const state = roomChannel.presenceState() - - // User attempting to navigate directly to an existing room with users - if (slugRoomId && slugRoomId in state && state[slugRoomId].length < MAX_ROOM_USERS) { - newRoomId = slugRoomId - } - - // User will be assigned an existing room with the fewest users - if (!newRoomId) { - const [mostVacantRoomId, users] = - Object.entries(state).sort(([, a], [, b]) => a.length - b.length)[0] ?? [] - - if (users && users.length < MAX_ROOM_USERS) { - newRoomId = mostVacantRoomId - } - } - - // Generate an id if no existing rooms are available - setRoomId(newRoomId ?? nanoid()) - }) - .subscribe() - } else { - // When user has been placed in a room - - joinTimestampRef.current && - sendLog( - `User ${userId} joined Room ${roomId} in ${( - performance.now() - joinTimestampRef.current - ).toFixed(1)} ms` - ) - - /* - Client is re-joining 'rooms' channel and the user's id will be tracked with Presence. - - Note: Realtime enforces unique channel names per client so the previous 'rooms' channel - has already been removed in the cleanup function. - */ - roomChannel = supabaseClient.channel('rooms', { config: { presence: { key: roomId } } }) - roomChannel.on( - REALTIME_LISTEN_TYPES.PRESENCE, - { event: REALTIME_PRESENCE_LISTEN_EVENTS.SYNC }, - () => { - setIsInitialStateSynced(true) - mapInitialUsers(roomChannel, roomId) - } - ) - roomChannel.subscribe(async (status: `${REALTIME_SUBSCRIBE_STATES}`) => { - if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { - const resp: RealtimeChannelSendResponse = await roomChannel.track({ user_id: userId }) - - if (resp === 'ok') { - router.push(`/${roomId}`) - } else { - router.push(`/`) - } - } - }) - - // Get the room's existing messages that were saved to database - supabaseClient - .from('messages') - .select('id, user_id, message') - .filter('room_id', 'eq', roomId) - .order('created_at', { ascending: false }) - .limit(MAX_DISPLAY_MESSAGES) - .then((resp: PostgrestResponse) => { - resp.data && setMessages(resp.data.reverse()) - setAreMessagesFetched(true) - if (chatboxRef.current) chatboxRef.current.scrollIntoView({ behavior: 'smooth' }) - }) - } - - // Must properly remove subscribed channel - return () => { - roomChannel && supabaseClient.removeChannel(roomChannel) - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [roomId]) - - useEffect(() => { - if (!roomId || !isInitialStateSynced) return - - let pingIntervalId: ReturnType | undefined - let messageChannel: RealtimeChannel, pingChannel: RealtimeChannel - let setMouseEvent: (e: MouseEvent) => void = () => {}, - onKeyDown: (e: KeyboardEvent) => void = () => {} - - // Ping channel is used to calculate roundtrip time from client to server to client - pingChannel = supabaseClient.channel(`ping:${userId}`, { - config: { broadcast: { ack: true } }, - }) - pingChannel.subscribe((status: `${REALTIME_SUBSCRIBE_STATES}`) => { - if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { - pingIntervalId = setInterval(async () => { - const start = performance.now() - const resp = await pingChannel.send({ - type: 'broadcast', - event: 'PING', - payload: {}, - }) - - if (resp !== 'ok') { - console.log('pingChannel broadcast error') - setLatency(-1) - } else { - const end = performance.now() - const newLatency = end - start - - if (newLatency >= LATENCY_THRESHOLD) { - sendLog( - `Roundtrip Latency for User ${userId} surpassed ${LATENCY_THRESHOLD} ms at ${newLatency.toFixed( - 1 - )} ms` - ) - } - - setLatency(newLatency) - } - }, 1000) - } - }) - - messageChannel = supabaseClient.channel(`chat_messages:${roomId}`) - - // Listen for messages inserted into the database - messageChannel.on( - REALTIME_LISTEN_TYPES.POSTGRES_CHANGES, - { - event: REALTIME_POSTGRES_CHANGES_LISTEN_EVENT.INSERT, - schema: 'public', - table: 'messages', - filter: `room_id=eq.${roomId}`, - }, - ( - payload: RealtimePostgresInsertPayload<{ - id: number - created_at: string - message: string - user_id: string - room_id: string - }> - ) => { - if (payload.new.user_id === userId && insertMsgTimestampRef.current) { - sendLog( - `Message Latency for User ${userId} from insert to receive was ${( - performance.now() - insertMsgTimestampRef.current - ).toFixed(1)} ms` - ) - insertMsgTimestampRef.current = undefined - } - - setMessages((prevMsgs: Message[]) => { - const messages = prevMsgs.slice(-MAX_DISPLAY_MESSAGES + 1) - const msg = (({ id, message, room_id, user_id }) => ({ - id, - message, - room_id, - user_id, - }))(payload.new) - messages.push(msg) - - if (msg.user_id === userId) { - const updatedMessagesInTransit = removeFirst( - messagesInTransitRef?.current ?? [], - msg.message - ) - setMessagesInTransit(updatedMessagesInTransit) - } - - return messages - }) - - if (chatboxRef.current) { - chatboxRef.current.scrollIntoView({ behavior: 'smooth' }) - } - } - ) - - // Listen for cursor positions from other users in the room - messageChannel.on( - REALTIME_LISTEN_TYPES.BROADCAST, - { event: 'POS' }, - (payload: Payload<{ user_id: string } & Coordinates>) => { - setUsers((users) => { - const userId = payload!.payload!.user_id - const existingUser = users[userId] - - if (existingUser) { - const x = - (payload?.payload?.x ?? 0) - X_THRESHOLD > window.innerWidth - ? window.innerWidth - X_THRESHOLD - : payload?.payload?.x - const y = - (payload?.payload?.y ?? 0 - Y_THRESHOLD) > window.innerHeight - ? window.innerHeight - Y_THRESHOLD - : payload?.payload?.y - - users[userId] = { ...existingUser, ...{ x, y } } - users = cloneDeep(users) - } - - return users - }) - } - ) - - // Listen for messages sent by other users directly via Broadcast - messageChannel.on( - REALTIME_LISTEN_TYPES.BROADCAST, - { event: 'MESSAGE' }, - (payload: Payload<{ user_id: string; isTyping: boolean; message: string }>) => { - setUsers((users) => { - const userId = payload!.payload!.user_id - const existingUser = users[userId] - - if (existingUser) { - users[userId] = { - ...existingUser, - ...{ isTyping: payload?.payload?.isTyping, message: payload?.payload?.message }, - } - users = cloneDeep(users) - } - - return users - }) - } - ) - messageChannel.subscribe((status: `${REALTIME_SUBSCRIBE_STATES}`) => { - if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { - // Lodash throttle will be removed once realtime-js client throttles on the channel level - const sendMouseBroadcast = throttle(({ x, y }) => { - messageChannel - .send({ - type: 'broadcast', - event: 'POS', - payload: { user_id: userId, x, y }, - }) - .catch(() => {}) - }, 1000 / MAX_EVENTS_PER_SECOND) - - setMouseEvent = (e: MouseEvent) => { - const [x, y] = [e.clientX, e.clientY] - sendMouseBroadcast({ x, y }) - setMousePosition({ x, y }) - } - - onKeyDown = async (e: KeyboardEvent) => { - if (document.activeElement?.id === 'email') return - - // Start typing session - if (e.code === 'Enter' || (e.key.length === 1 && !e.metaKey)) { - if (!isTypingRef.current) { - setIsTyping(true) - setIsCancelled(false) - - if (chatInputFix.current) { - setMessage('') - chatInputFix.current = false - } else { - setMessage(e.key.length === 1 ? e.key : '') - } - messageChannel - .send({ - type: 'broadcast', - event: 'MESSAGE', - payload: { user_id: userId, isTyping: true, message: '' }, - }) - .catch(() => {}) - } else if (e.code === 'Enter') { - // End typing session and send message - setIsTyping(false) - messageChannel - .send({ - type: 'broadcast', - event: 'MESSAGE', - payload: { user_id: userId, isTyping: false, message: messageRef.current }, - }) - .catch(() => {}) - if (messageRef.current) { - const updatedMessagesInTransit = (messagesInTransitRef?.current ?? []).concat([ - messageRef.current, - ]) - setMessagesInTransit(updatedMessagesInTransit) - if (chatboxRef.current) chatboxRef.current.scrollIntoView({ behavior: 'smooth' }) - insertMsgTimestampRef.current = performance.now() - await supabaseClient.from('messages').insert([ - { - user_id: userId, - room_id: roomId, - message: messageRef.current, - }, - ]) - } - } - } - - // End typing session without sending - if (e.code === 'Escape' && isTypingRef.current) { - setIsTyping(false) - setIsCancelled(true) - chatInputFix.current = true - - messageChannel - .send({ - type: 'broadcast', - event: 'MESSAGE', - payload: { user_id: userId, isTyping: false, message: '' }, - }) - .catch(() => {}) - } - } - - window.addEventListener('mousemove', setMouseEvent) - window.addEventListener('keydown', onKeyDown) - } - }) - - return () => { - pingIntervalId && clearInterval(pingIntervalId) - - window.removeEventListener('mousemove', setMouseEvent) - window.removeEventListener('keydown', onKeyDown) - - pingChannel && supabaseClient.removeChannel(pingChannel) - messageChannel && supabaseClient.removeChannel(messageChannel) - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [roomId, isInitialStateSynced]) - - if (!roomId) { - return - } - - return ( -
-
-
-
- - -
-
-
- - Latency: {latency.toFixed(1)}ms -
-
- -
-
-
- -
-
-

Chat

- - ↩ - -
-
-

Escape

- - ESC - -
-
- - {Object.entries(users).reduce((acc, [userId, data]) => { - const { x, y, color, message, isTyping, hue } = data - if (x && y) { - acc.push( - - ) - } - return acc - }, [] as ReactElement[])} - - {/* Cursor for local client: Shouldn't show the cursor itself, only the text bubble */} - {Number.isInteger(mousePosition?.x) && Number.isInteger(mousePosition?.y) && ( - - )} -
- ) -} - -export default Room diff --git a/demo/pages/_app.tsx b/demo/pages/_app.tsx deleted file mode 100644 index c55f6089f..000000000 --- a/demo/pages/_app.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import '../styles/globals.css' -import type { AppProps } from 'next/app' -import Head from 'next/head' -import { ThemeProvider } from '../lib/ThemeProvider' - -function MyApp({ Component, pageProps }: AppProps) { - return ( - <> - - Realtime | Supabase - - - - - - - - - - - - - ) -} - -export default MyApp diff --git a/demo/pages/_document.tsx b/demo/pages/_document.tsx deleted file mode 100644 index 9e20a3fd2..000000000 --- a/demo/pages/_document.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import Document, { DocumentContext, Head, Html, Main, NextScript } from 'next/document' - -class MyDocument extends Document { - static async getInitialProps(ctx: DocumentContext) { - const initialProps = await Document.getInitialProps(ctx) - return initialProps - } - - render() { - return ( - - - - - -
- - - - ) - } -} - -export default MyDocument diff --git a/demo/pages/api/log.ts b/demo/pages/api/log.ts deleted file mode 100644 index a8a26317c..000000000 --- a/demo/pages/api/log.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next' - -const LOGFLARE_API_KEY = process.env.LOGFLARE_API_KEY || '' -const LOGFLARE_SOURCE_ID = process.env.LOGFLARE_SOURCE_ID || '' - -const recordLogs = async (req: NextApiRequest, res: NextApiResponse) => { - if (!LOGFLARE_API_KEY || !LOGFLARE_SOURCE_ID) { - return res.status(400).json('Logs are not being recorded') - } - if (req.method !== 'POST') { - return res.status(400).json('Only POST methods are supported') - } - - const body = await req.body - - try { - await fetch(`https://api.logflare.app/api/logs?source=${LOGFLARE_SOURCE_ID}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-KEY': `${LOGFLARE_API_KEY}`, - }, - body: JSON.stringify(body), - }) - res.json('ok') - } catch (e) { - console.error(JSON.stringify(e)) - } -} - -export default recordLogs diff --git a/demo/postcss.config.js b/demo/postcss.config.js deleted file mode 100644 index 33ad091d2..000000000 --- a/demo/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/demo/public/css/fonts.css b/demo/public/css/fonts.css deleted file mode 100644 index 47a1664ed..000000000 --- a/demo/public/css/fonts.css +++ /dev/null @@ -1,72 +0,0 @@ -/* header and body font */ - -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-Book.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-Book.woff) format('woff'); - font-weight: 400; - font-style: normal; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-BookItalic.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-BookItalic.woff) format('woff'); - font-weight: 400; - font-style: italic; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-Medium.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-Medium.woff) format('woff'); - font-weight: 500; - font-style: normal; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-MediumItalic.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-MediumItalic.woff) format('woff'); - font-weight: 500; - font-style: italic; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-Bold.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-Bold.woff) format('woff'); - font-weight: 700; - font-style: 600; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-BoldItalic.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-BoldItalic.woff) format('woff'); - font-style: 600; - font-style: italic; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-Black.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-Black.woff) format('woff'); - font-weight: 800; - font-style: normal; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-BlackItalic.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-BlackItalic.woff) format('woff'); - font-weight: 800; - font-style: italic; -} - -/* mono font */ - -@font-face { - font-family: 'source code pro'; - src: url('/fonts/source-code-pro/SourceCodePro-Regular.eot'); - src: url('/fonts/source-code-pro/SourceCodePro-Regular.woff2') format('woff2'), - url('/fonts/source-code-pro/SourceCodePro-Regular.woff') format('woff'), - url('/fonts/source-code-pro/SourceCodePro-Regular.ttf') format('truetype'), - url('/fonts/source-code-pro/SourceCodePro-Regular.svg#SourceCodePro-Regular') format('svg'); - font-weight: normal; - font-style: normal; - font-display: swap; -} diff --git a/demo/public/favicon.ico b/demo/public/favicon.ico deleted file mode 100644 index 718d6fea4..000000000 Binary files a/demo/public/favicon.ico and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-Black.woff b/demo/public/fonts/custom-font/CustomFont-Black.woff deleted file mode 100644 index 091f927ea..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-Black.woff and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-Black.woff2 b/demo/public/fonts/custom-font/CustomFont-Black.woff2 deleted file mode 100644 index e3c834e57..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-Black.woff2 and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-BlackItalic.woff b/demo/public/fonts/custom-font/CustomFont-BlackItalic.woff deleted file mode 100644 index b5f6a877d..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-BlackItalic.woff and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-BlackItalic.woff2 b/demo/public/fonts/custom-font/CustomFont-BlackItalic.woff2 deleted file mode 100644 index f84036283..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-BlackItalic.woff2 and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-Bold.woff b/demo/public/fonts/custom-font/CustomFont-Bold.woff deleted file mode 100644 index f8d3f551d..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-Bold.woff and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-Bold.woff2 b/demo/public/fonts/custom-font/CustomFont-Bold.woff2 deleted file mode 100644 index 5e7af4594..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-Bold.woff2 and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-BoldItalic.woff b/demo/public/fonts/custom-font/CustomFont-BoldItalic.woff deleted file mode 100644 index 07e8a3507..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-BoldItalic.woff and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-BoldItalic.woff2 b/demo/public/fonts/custom-font/CustomFont-BoldItalic.woff2 deleted file mode 100644 index ac0edd55f..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-BoldItalic.woff2 and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-Book.woff b/demo/public/fonts/custom-font/CustomFont-Book.woff deleted file mode 100644 index 7d53d032f..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-Book.woff and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-Book.woff2 b/demo/public/fonts/custom-font/CustomFont-Book.woff2 deleted file mode 100644 index abd31f7ec..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-Book.woff2 and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-BookItalic.woff b/demo/public/fonts/custom-font/CustomFont-BookItalic.woff deleted file mode 100644 index 427cda875..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-BookItalic.woff and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-BookItalic.woff2 b/demo/public/fonts/custom-font/CustomFont-BookItalic.woff2 deleted file mode 100644 index d326c8672..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-BookItalic.woff2 and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-Medium.woff b/demo/public/fonts/custom-font/CustomFont-Medium.woff deleted file mode 100644 index 3707cb45d..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-Medium.woff and /dev/null differ diff --git a/demo/public/fonts/custom-font/CustomFont-Medium.woff2 b/demo/public/fonts/custom-font/CustomFont-Medium.woff2 deleted file mode 100644 index c07131dde..000000000 Binary files a/demo/public/fonts/custom-font/CustomFont-Medium.woff2 and /dev/null differ diff --git a/demo/public/fonts/source-code-pro/SourceCodePro-Regular.eot b/demo/public/fonts/source-code-pro/SourceCodePro-Regular.eot deleted file mode 100644 index e815e2cc6..000000000 Binary files a/demo/public/fonts/source-code-pro/SourceCodePro-Regular.eot and /dev/null differ diff --git a/demo/public/fonts/source-code-pro/SourceCodePro-Regular.svg b/demo/public/fonts/source-code-pro/SourceCodePro-Regular.svg deleted file mode 100644 index 45766eb08..000000000 --- a/demo/public/fonts/source-code-pro/SourceCodePro-Regular.svg +++ /dev/null @@ -1,4016 +0,0 @@ - - - - -Created by FontForge 20170731 at Thu Jun 9 00:20:48 2016 - By Aleksey,,, -Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name `Source'. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/demo/public/fonts/source-code-pro/SourceCodePro-Regular.ttf b/demo/public/fonts/source-code-pro/SourceCodePro-Regular.ttf deleted file mode 100644 index 2d08f66a4..000000000 Binary files a/demo/public/fonts/source-code-pro/SourceCodePro-Regular.ttf and /dev/null differ diff --git a/demo/public/fonts/source-code-pro/SourceCodePro-Regular.woff b/demo/public/fonts/source-code-pro/SourceCodePro-Regular.woff deleted file mode 100644 index eacf83e53..000000000 Binary files a/demo/public/fonts/source-code-pro/SourceCodePro-Regular.woff and /dev/null differ diff --git a/demo/public/fonts/source-code-pro/SourceCodePro-Regular.woff2 b/demo/public/fonts/source-code-pro/SourceCodePro-Regular.woff2 deleted file mode 100644 index 21411ef40..000000000 Binary files a/demo/public/fonts/source-code-pro/SourceCodePro-Regular.woff2 and /dev/null differ diff --git a/demo/public/img/multiplayer-og.png b/demo/public/img/multiplayer-og.png deleted file mode 100644 index fe97b1cbb..000000000 Binary files a/demo/public/img/multiplayer-og.png and /dev/null differ diff --git a/demo/public/img/supabase-dark.svg b/demo/public/img/supabase-dark.svg deleted file mode 100644 index d1f685ac7..000000000 --- a/demo/public/img/supabase-dark.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/demo/public/img/supabase-light.svg b/demo/public/img/supabase-light.svg deleted file mode 100644 index 60cbc71dc..000000000 --- a/demo/public/img/supabase-light.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/demo/public/vercel.svg b/demo/public/vercel.svg deleted file mode 100644 index fbf0e25a6..000000000 --- a/demo/public/vercel.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - \ No newline at end of file diff --git a/demo/styles/globals.css b/demo/styles/globals.css deleted file mode 100644 index 5cf9a0fb6..000000000 --- a/demo/styles/globals.css +++ /dev/null @@ -1,95 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer utilities { - .btn-primary { - @apply inline-block text-sm border border-green-500 rounded py-1 px-3 bg-green-500; - color: #fff !important; - font-weight: 600; - line-height: 20px; - text-align: center; - } - - .btn-primary-hover { - @apply bg-green-600; - cursor: pointer; - } -} - -html, -body, -#__next, -.main { - height: 100vh; - padding: 0; - margin: 0; - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - @apply bg-scale-100 dark:bg-scale-100; -} - -* { - box-sizing: border-box; -} - -a { - color: inherit; - text-decoration: none; -} - -/* Loader dots */ - -.loader-dots div { - animation-timing-function: cubic-bezier(0, 1, 1, 0); -} - -.loader-dots div:nth-child(1) { - left: 4px; - animation: loader-dots1 0.6s infinite; -} - -.loader-dots div:nth-child(2) { - left: 4px; - animation: loader-dots2 0.6s infinite; -} - -.loader-dots div:nth-child(3) { - left: 16px; - animation: loader-dots2 0.6s infinite; -} - -.loader-dots div:nth-child(4) { - left: 28px; - animation: loader-dots3 0.6s infinite; -} - -@keyframes loader-dots1 { - 0% { - transform: scale(0); - } - - 100% { - transform: scale(1); - } -} - -@keyframes loader-dots3 { - 0% { - transform: scale(1); - } - - 100% { - transform: scale(0); - } -} - -@keyframes loader-dots2 { - 0% { - transform: translate(0, 0); - } - - 100% { - transform: translate(12px, 0); - } -} diff --git a/demo/tailwind.config.js b/demo/tailwind.config.js deleted file mode 100644 index 6204c4a98..000000000 --- a/demo/tailwind.config.js +++ /dev/null @@ -1,147 +0,0 @@ -const ui = require('@supabase/ui/dist/config/ui.config.js') - -const blueGray = { - 50: '#F8FAFC', - 100: '#F1F5F9', - 200: '#E2E8F0', - 300: '#CBD5E1', - 400: '#94A3B8', - 500: '#64748B', - 600: '#475569', - 700: '#334155', - 800: '#1E293B', - 900: '#0F172A', -} - -const coolGray = { - 50: '#F9FAFB', - 100: '#F3F4F6', - 200: '#E5E7EB', - 300: '#D1D5DB', - 400: '#9CA3AF', - 500: '#6B7280', - 600: '#4B5563', - 700: '#374151', - 800: '#1F2937', - 900: '#111827', -} - -module.exports = ui({ - darkMode: 'class', // or 'media' or 'class' - content: [ - // purge styles from app - './pages/**/*.{js,ts,jsx,tsx}', - './components/**/*.{js,ts,jsx,tsx}', - './internals/**/*.{js,ts,jsx,tsx}', - './lib/**/*.{js,ts,jsx,tsx}', - './lib/**/**/*.{js,ts,jsx,tsx}', - // purge styles from supabase ui theme - './node_modules/@supabase/ui/dist/config/default-theme.js', - ], - theme: { - fontFamily: { - sans: ['circular', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'], - mono: ['source code pro', 'Menlo', 'monospace'], - }, - borderColor: (theme) => ({ - ...theme('colors'), - DEFAULT: 'var(--colors-scale5)', - dark: 'var(--colors-scale4)', - }), - divideColor: (theme) => ({ - ...theme('colors'), - DEFAULT: 'var(--colors-scale3)', - dark: 'var(--colors-scale2)', - }), - extend: { - typography: ({ theme }) => ({ - // Removal of backticks in code blocks for tailwind v3.0 - // https://github.com/tailwindlabs/tailwindcss-typography/issues/135 - DEFAULT: { - css: { - 'code::before': { - content: '""', - }, - 'code::after': { - content: '""', - }, - }, - }, - }), - colors: { - /* typography */ - 'typography-body': { - light: 'var(--colors-scale11)', - dark: 'var(--colors-scale11)', - }, - 'typography-body-secondary': { - light: 'var(--colors-scale10)', - dark: 'var(--colors-scale10)', - }, - 'typography-body-strong': { - light: 'var(--colors-scale12)', - dark: 'var(--colors-scale12)', - }, - 'typography-body-faded': { - light: 'var(--colors-scale9)', - dark: 'var(--colors-scale9)', - }, - - /* borders */ - 'border-secondary': { - light: 'var(--colors-scale7)', - dark: 'var(--colors-scale7)', - }, - 'border-secondary-hover': { - light: 'var(--colors-scale9)', - dark: 'var(--colors-scale9)', - }, - - /* app backgrounds */ - 'bg-primary': { - light: 'var(--colors-scale2)', - dark: 'var(--colors-scale2)', - }, - 'bg-secondary': { - light: 'var(--colors-scale2)', - dark: 'var(--colors-scale2)', - }, - 'bg-alt': { - light: 'var(--colors-scale2)', - dark: 'var(--colors-scale2)', - }, - }, - animation: { - gradient: 'gradient 60s ease infinite', - 'ping-once': 'ping-once 1s cubic-bezier(0, 0, 0.2, 1);', - }, - keyframes: { - gradient: { - '0%': { - 'background-position': '0% 50%', - }, - '50%': { - 'background-position': '100% 50%', - }, - '100%': { - 'background-position': '0% 50%', - }, - }, - 'ping-once': { - '75%': { - transform: 'scale(2)', - opacity: 0, - }, - '100%': { - transform: 'scale(2)', - opacity: 0, - }, - }, - }, - }, - }, - variants: { - extend: {}, - }, - plugins: [require('@tailwindcss/typography')], -}) diff --git a/demo/tsconfig.json b/demo/tsconfig.json deleted file mode 100644 index 99710e857..000000000 --- a/demo/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] -} diff --git a/demo/types.ts b/demo/types.ts deleted file mode 100644 index d992d7a4a..000000000 --- a/demo/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface Coordinates { - x: number | undefined - y: number | undefined -} - -export interface Message { - id: number - user_id: string - message: string -} - -export interface Payload { - type: string - event: string - payload?: T -} - -export interface User extends Coordinates { - color: string - hue: string - isTyping?: boolean - message?: string -} diff --git a/demo/utils.ts b/demo/utils.ts deleted file mode 100644 index 382d8586d..000000000 --- a/demo/utils.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const removeFirst = (src: any[], element: any) => { - const index = src.indexOf(element) - if (index === -1) return src - return [...src.slice(0, index), ...src.slice(index + 1)] -} diff --git a/dev/postgres/00-supabase-schema.sql b/dev/postgres/00-supabase-schema.sql deleted file mode 100644 index 4b3d612d1..000000000 --- a/dev/postgres/00-supabase-schema.sql +++ /dev/null @@ -1,14 +0,0 @@ -create role anon nologin noinherit; -create role authenticated nologin noinherit; -create role service_role nologin noinherit bypassrls; - -grant usage on schema public to anon, authenticated, service_role; - -alter default privileges in schema public grant all on tables to anon, authenticated, service_role; -alter default privileges in schema public grant all on functions to anon, authenticated, service_role; -alter default privileges in schema public grant all on sequences to anon, authenticated, service_role; - -create schema if not exists _realtime; -create schema if not exists realtime; - -create publication supabase_realtime with (publish = 'insert, update, delete'); diff --git a/dev/postgres/za-permit-supabase-admin.sh b/dev/postgres/za-permit-supabase-admin.sh new file mode 100755 index 000000000..0b265e3e6 --- /dev/null +++ b/dev/postgres/za-permit-supabase-admin.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Allow peer auth as supabase_admin so the next init .sql can `\connect -` to it without a password +set -euo pipefail + +echo "supabase_map postgres supabase_admin" >> "${PGDATA}/pg_ident.conf" +printf 'local all supabase_admin peer map=supabase_map\n%s' "$(cat "${PGDATA}/pg_hba.conf")" > "${PGDATA}/pg_hba.conf.new" +mv "${PGDATA}/pg_hba.conf.new" "${PGDATA}/pg_hba.conf" +pg_ctl reload -D "${PGDATA}" >/dev/null diff --git a/dev/postgres/zb-supabase-schema.sql b/dev/postgres/zb-supabase-schema.sql new file mode 100644 index 000000000..231a7e8a8 --- /dev/null +++ b/dev/postgres/zb-supabase-schema.sql @@ -0,0 +1,27 @@ +\connect - supabase_admin + +do $$ +begin + if not exists (select from pg_roles where rolname = 'supabase_realtime_admin') then + create user supabase_realtime_admin noinherit createrole login replication password 'postgres'; + end if; +end$$; + +create schema if not exists _realtime; + +alter user supabase_realtime_admin set search_path = public, extensions, realtime; +grant create on database postgres to supabase_realtime_admin; +do $$ +begin + if current_setting('server_version_num')::int >= 150000 then + execute 'grant set on parameter log_min_messages to supabase_realtime_admin'; + end if; +end$$; +grant anon, authenticated, service_role to supabase_realtime_admin; +grant create, usage on schema public to supabase_realtime_admin; +grant usage on schema extensions to supabase_realtime_admin; +grant usage on schema auth to supabase_realtime_admin; +grant execute on all functions in schema auth to supabase_realtime_admin; +grant usage on schema realtime to postgres, anon, authenticated, service_role; +grant all on schema realtime to supabase_realtime_admin with grant option; +grant create, usage on schema _realtime to supabase_realtime_admin; diff --git a/docker-compose.dbs.yml b/docker-compose.dbs.yml deleted file mode 100644 index 7601dfb44..000000000 --- a/docker-compose.dbs.yml +++ /dev/null @@ -1,23 +0,0 @@ -version: '3' - -services: - db: - image: supabase/postgres:14.1.0.105 - container_name: realtime-db - ports: - - "5432:5432" - volumes: - - ./dev/postgres:/docker-entrypoint-initdb.d/ - command: postgres -c config_file=/etc/postgresql/postgresql.conf - environment: - POSTGRES_HOST: /var/run/postgresql - POSTGRES_PASSWORD: postgres - tenant_db: - image: supabase/postgres:14.1.0.105 - container_name: tenant-db - ports: - - "5433:5432" - command: postgres -c config_file=/etc/postgresql/postgresql.conf - environment: - POSTGRES_HOST: /var/run/postgresql - POSTGRES_PASSWORD: postgres diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 88d816d1a..000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,50 +0,0 @@ -services: - db: - image: supabase/postgres:14.1.0.105 - container_name: realtime-db - ports: - - "5432:5432" - volumes: - - ./dev/postgres:/docker-entrypoint-initdb.d/ - command: postgres -c config_file=/etc/postgresql/postgresql.conf - environment: - POSTGRES_HOST: /var/run/postgresql - POSTGRES_PASSWORD: postgres - tenant_db: - image: supabase/postgres:14.1.0.105 - container_name: tenant-db - ports: - - "5433:5432" - command: postgres -c config_file=/etc/postgresql/postgresql.conf - environment: - POSTGRES_HOST: /var/run/postgresql - POSTGRES_PASSWORD: postgres - realtime: - depends_on: - - db - build: . - container_name: realtime-server - ports: - - "4000:4000" - extra_hosts: - - "host.docker.internal:host-gateway" - environment: - PORT: 4000 - DB_HOST: host.docker.internal - DB_PORT: 5432 - DB_USER: postgres - DB_PASSWORD: postgres - DB_NAME: postgres - DB_ENC_KEY: supabaserealtime - DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' - API_JWT_SECRET: dc447559-996d-4761-a306-f47a5eab1623 - SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq - ERL_AFLAGS: -proto_dist inet_tcp - RLIMIT_NOFILE: 1000000 - DNS_NODES: "''" - APP_NAME: realtime - RUN_JANITOR: true - JANITOR_INTERVAL: 60000 - LOG_LEVEL: "info" - SEED_SELF_HOST: true - diff --git a/forum/.formatter.exs b/forum/.formatter.exs new file mode 100644 index 000000000..d2cda26ed --- /dev/null +++ b/forum/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/forum/.gitignore b/forum/.gitignore new file mode 100644 index 000000000..27d8f801f --- /dev/null +++ b/forum/.gitignore @@ -0,0 +1,23 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +forum-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/forum/README.md b/forum/README.md new file mode 100644 index 000000000..88de29474 --- /dev/null +++ b/forum/README.md @@ -0,0 +1,49 @@ +# Forum + +Forum is a scalable, distributed process-group library for Elixir/OTP. + +* **`Forum.Census`** — eventually-consistent counting of group membership across the cluster. Use when you need to know "how many processes are in group X right now" without paying per-join/leave network traffic. + +* Process pids never leave the node they live on. +* Group membership is partitioned locally for concurrency (one partition GenServer per scheduler by default). +* A cluster forms by all nodes starting the same scope name; nodes discover each other via Erlang distribution. + +## Installation + +```elixir +def deps do + [ + {:forum, "~> 1.0"} + ] +end +``` + +--- + +## `Forum.Census` — distributed counts + +Each node holds local-only membership and broadcasts its per-group counts to every peer on a fixed interval (default 5 s). Reads aggregate the local count plus the most recent counts received from peers. The view is **eventually consistent** — a join is reflected on remote nodes after at most one broadcast interval — but the cost on the wire is constant in the number of joins/leaves, not proportional. + +Start it under your supervision tree: + +```elixir +children = [ + {Forum.Census, [:users, partitions: 8, broadcast_interval_in_ms: 5_000]} +] +``` + +Use it: + +```elixir +iex> Forum.Census.join(:users, {:tenant, 123}, self()) +:ok +iex> Forum.Census.local_member_count(:users, {:tenant, 123}) +1 +iex> Forum.Census.member_count(:users, {:tenant, 123}) # cluster-wide +3 +iex> Forum.Census.member_counts(:users) +%{{:tenant, 123} => 3, {:tenant, 456} => 1} +``` + +When to use Census: you want counts, totals, presence-of-anyone signals, dashboards. You don't need precision on the millisecond and you don't want to fan out per-event traffic. + diff --git a/forum/config/config.exs b/forum/config/config.exs new file mode 100644 index 000000000..e17c52707 --- /dev/null +++ b/forum/config/config.exs @@ -0,0 +1,4 @@ +import Config + +# Print nothing during tests unless captured or a test failure happens +config :logger, backends: [], level: :debug diff --git a/forum/lib/forum.ex b/forum/lib/forum.ex new file mode 100644 index 000000000..e9092abe8 --- /dev/null +++ b/forum/lib/forum.ex @@ -0,0 +1,9 @@ +defmodule Forum do + @moduledoc """ + Distributed process group membership tracking. + + Check Forum.Census + """ + + @type group :: any +end diff --git a/forum/lib/forum/adapter.ex b/forum/lib/forum/adapter.ex new file mode 100644 index 000000000..b0b0d6976 --- /dev/null +++ b/forum/lib/forum/adapter.ex @@ -0,0 +1,17 @@ +defmodule Forum.Adapter do + @moduledoc """ + Behaviour module for Forum messaging adapters. + """ + + @doc "Register the current process to receive messages for the given scope" + @callback register(scope :: atom) :: :ok + + @doc "Broadcast a message to all nodes in the given scope" + @callback broadcast(scope :: atom, message :: term) :: any + + @doc "Broadcast a message to specific nodes in the given scope" + @callback broadcast(scope :: atom, [node], message :: term) :: any + + @doc "Send a message to a specific node in the given scope" + @callback send(scope :: atom, node, message :: term) :: any +end diff --git a/forum/lib/forum/adapter/erl_dist.ex b/forum/lib/forum/adapter/erl_dist.ex new file mode 100644 index 000000000..41ded8b3f --- /dev/null +++ b/forum/lib/forum/adapter/erl_dist.ex @@ -0,0 +1,30 @@ +defmodule Forum.Adapter.ErlDist do + @moduledoc false + + import Kernel, except: [send: 2] + + @behaviour Forum.Adapter + + @impl true + def register(scope) do + Process.register(self(), Forum.Supervisor.name(scope)) + :ok + end + + @impl true + def broadcast(scope, message) do + name = Forum.Supervisor.name(scope) + Enum.each(Node.list(), fn node -> :erlang.send({name, node}, message, [:noconnect]) end) + end + + @impl true + def broadcast(scope, nodes, message) do + name = Forum.Supervisor.name(scope) + Enum.each(nodes, fn node -> :erlang.send({name, node}, message, [:noconnect]) end) + end + + @impl true + def send(scope, node, message) do + :erlang.send({Forum.Supervisor.name(scope), node}, message, [:noconnect]) + end +end diff --git a/forum/lib/forum/census.ex b/forum/lib/forum/census.ex new file mode 100644 index 000000000..02d8b2690 --- /dev/null +++ b/forum/lib/forum/census.ex @@ -0,0 +1,149 @@ +defmodule Forum.Census do + alias Forum.Partition + alias Forum.Census.Scope + + @type group :: Forum.group() + @type start_option :: + {:partitions, pos_integer()} | {:broadcast_interval_in_ms, non_neg_integer()} + + @doc "Returns a supervisor child specification for a Forum scope" + def child_spec([scope]) when is_atom(scope), do: child_spec([scope, []]) + def child_spec(scope) when is_atom(scope), do: child_spec([scope, []]) + + def child_spec([scope, opts]) when is_atom(scope) and is_list(opts) do + %{ + id: Forum, + start: {__MODULE__, :start_link, [scope, opts]}, + type: :supervisor + } + end + + @doc """ + Starts the Forum supervision tree for `scope`. + + Options: + + * `:partitions` - number of partitions to use (default: number of schedulers online) + * `:broadcast_interval_in_ms`: - interval in milliseconds to broadcast membership counts to other nodes (default: 5000 ms) + * `:message_module` - module implementing `Forum.Adapter` behaviour (default: `Forum.Adapter.ErlDist`) + """ + @spec start_link(atom, [start_option]) :: Supervisor.on_start() + def start_link(scope, opts \\ []) when is_atom(scope) do + {partitions, opts} = Keyword.pop(opts, :partitions, System.schedulers_online()) + broadcast_interval_in_ms = Keyword.get(opts, :broadcast_interval_in_ms) + + if not (is_integer(partitions) and partitions >= 1) do + raise ArgumentError, + "expected :partitions to be a positive integer, got: #{inspect(partitions)}" + end + + if broadcast_interval_in_ms != nil and + not (is_integer(broadcast_interval_in_ms) and broadcast_interval_in_ms > 0) do + raise ArgumentError, + "expected :broadcast_interval_in_ms to be a positive integer, got: #{inspect(broadcast_interval_in_ms)}" + end + + Forum.Supervisor.start_link(Forum.Census.Scope, scope, partitions, opts) + end + + @doc "Join pid to group in scope" + @spec join(atom, any, pid) :: :ok | {:error, :not_local} + def join(_scope, _group, pid) when is_pid(pid) and node(pid) != node(), do: {:error, :not_local} + + def join(scope, group, pid) when is_atom(scope) and is_pid(pid) do + Partition.join(Forum.Supervisor.partition(scope, group), group, pid) + end + + @doc "Leave pid from group in scope" + @spec leave(atom, group, pid) :: :ok + def leave(scope, group, pid) when is_atom(scope) and is_pid(pid) do + Partition.leave(Forum.Supervisor.partition(scope, group), group, pid) + end + + @doc "Get total members count per group in scope" + @spec member_counts(atom) :: %{group => non_neg_integer} + def member_counts(scope) when is_atom(scope) do + remote_counts = Scope.member_counts(scope) + + scope + |> local_member_counts() + |> Map.merge(remote_counts, fn _k, v1, v2 -> v1 + v2 end) + end + + @doc "Get total member count of group in scope" + @spec member_count(atom, group) :: non_neg_integer + def member_count(scope, group) do + local_member_count(scope, group) + Scope.member_count(scope, group) + end + + @doc "Get total member count of group in scope on specific node" + @spec member_count(atom, group, node) :: non_neg_integer + def member_count(scope, group, node) when node == node(), do: local_member_count(scope, group) + def member_count(scope, group, node), do: Scope.member_count(scope, group, node) + + @doc "Get local members of group in scope" + @spec local_members(atom, group) :: [pid] + def local_members(scope, group) when is_atom(scope) do + Partition.members(Forum.Supervisor.partition(scope, group), group) + end + + @doc "Get local member count of group in scope" + @spec local_member_count(atom, group) :: non_neg_integer + def local_member_count(scope, group) when is_atom(scope) do + Partition.member_count(Forum.Supervisor.partition(scope, group), group) + end + + @doc "Get local members count per group in scope" + @spec local_member_counts(atom) :: %{group => non_neg_integer} + def local_member_counts(scope) when is_atom(scope) do + Enum.reduce(Forum.Supervisor.partitions(scope), %{}, fn partition_name, acc -> + Map.merge(acc, Partition.member_counts(partition_name)) + end) + end + + @doc "Check if pid is a local member of group in scope" + @spec local_member?(atom, group, pid) :: boolean + def local_member?(scope, group, pid) when is_atom(scope) and is_pid(pid) do + Partition.member?(Forum.Supervisor.partition(scope, group), group, pid) + end + + @doc "Get all local groups in scope" + @spec local_groups(atom) :: [group] + def local_groups(scope) when is_atom(scope) do + Enum.flat_map(Forum.Supervisor.partitions(scope), fn partition_name -> + Partition.groups(partition_name) + end) + end + + @doc "Get local group count in scope" + @spec local_group_count(atom) :: non_neg_integer + def local_group_count(scope) when is_atom(scope) do + Enum.sum_by(Forum.Supervisor.partitions(scope), fn partition_name -> + Partition.group_count(partition_name) + end) + end + + @doc "Get groups in scope" + @spec groups(atom) :: [group] + def groups(scope) when is_atom(scope) do + remote_groups = Scope.groups(scope) + + scope + |> local_groups() + |> MapSet.new() + |> MapSet.union(remote_groups) + |> MapSet.to_list() + end + + @doc "Get group count in scope" + @spec group_count(atom) :: non_neg_integer + def group_count(scope) when is_atom(scope) do + remote_groups = Scope.groups(scope) + + scope + |> local_groups() + |> MapSet.new() + |> MapSet.union(remote_groups) + |> MapSet.size() + end +end diff --git a/forum/lib/forum/census/scope.ex b/forum/lib/forum/census/scope.ex new file mode 100644 index 000000000..3cb0a6408 --- /dev/null +++ b/forum/lib/forum/census/scope.ex @@ -0,0 +1,209 @@ +defmodule Forum.Census.Scope do + @moduledoc false + # Responsible to discover and keep track of all Forum peers in the cluster + + use GenServer + require Logger + alias Forum.Census + + @default_broadcast_interval 5_000 + + @spec member_counts(atom) :: %{Forum.group() => non_neg_integer} + def member_counts(scope) do + scope + |> table_name() + |> :ets.select([{{:_, :"$1"}, [], [:"$1"]}]) + |> Enum.reduce(%{}, fn member_counts, acc -> + Map.merge(acc, member_counts, fn _k, v1, v2 -> v1 + v2 end) + end) + end + + @spec member_count(atom, Forum.group()) :: non_neg_integer + def member_count(scope, group) do + scope + |> table_name() + |> :ets.select([{{:_, %{group => :"$1"}}, [], [:"$1"]}]) + |> Enum.sum() + end + + @spec member_count(atom, Forum.group(), node) :: non_neg_integer + def member_count(scope, group, node) do + case :ets.lookup(table_name(scope), node) do + [{^node, member_counts}] -> Map.get(member_counts, group, 0) + [] -> 0 + end + end + + @spec groups(atom) :: MapSet.t(Forum.group()) + def groups(scope) do + scope + |> table_name() + |> :ets.select([{{:_, :"$1"}, [], [:"$1"]}]) + |> Enum.reduce(MapSet.new(), fn member_counts, acc -> + member_counts + |> Map.keys() + |> MapSet.new() + |> MapSet.union(acc) + end) + end + + @typep member_counts :: %{Forum.group() => non_neg_integer} + + defp table_name(scope), do: :"#{scope}_forum_peer_counts" + + defmodule State do + @moduledoc false + @type t :: %__MODULE__{ + scope: atom, + message_module: module, + broadcast_interval: non_neg_integer, + peer_counts_table: :ets.tid(), + peers: %{pid => reference} + } + defstruct [ + :scope, + :message_module, + :broadcast_interval, + :peer_counts_table, + peers: %{} + ] + end + + @spec start_link(atom, Keyword.t()) :: GenServer.on_start() + def start_link(scope, opts \\ []), do: GenServer.start_link(__MODULE__, [scope, opts]) + + @impl true + def init([scope, opts]) do + :ok = :net_kernel.monitor_nodes(true) + + peer_counts_table = + :ets.new(table_name(scope), [:set, :protected, :named_table, read_concurrency: true]) + + broadcast_interval = + Keyword.get(opts, :broadcast_interval_in_ms, @default_broadcast_interval) + + message_module = Keyword.get(opts, :message_module, Forum.Adapter.ErlDist) + + Logger.info("Forum[#{node()}|#{scope}] Starting") + + :ok = message_module.register(scope) + + {:ok, + %State{ + scope: scope, + message_module: message_module, + broadcast_interval: broadcast_interval, + peer_counts_table: peer_counts_table + }, {:continue, :discover}} + end + + @impl true + @spec handle_continue(:discover, State.t()) :: {:noreply, State.t()} + def handle_continue(:discover, state) do + state.message_module.broadcast(state.scope, {:discover, self()}) + Process.send_after(self(), :broadcast_counts, state.broadcast_interval) + {:noreply, state} + end + + @impl true + @spec handle_info( + {:discover, pid} + | {:sync, pid, member_counts} + | :broadcast_counts + | {:nodeup, node} + | {:nodedown, node} + | {:DOWN, reference, :process, pid, term}, + State.t() + ) :: {:noreply, State.t()} + # A remote peer is discovering us + def handle_info({:discover, peer}, %State{} = state) do + Logger.info( + "Forum[#{node()}|#{state.scope}] Received DISCOVER request from node #{node(peer)}" + ) + + state.message_module.send( + state.scope, + node(peer), + {:sync, self(), Census.local_member_counts(state.scope)} + ) + + # We don't do anything if we already know about this peer + if Map.has_key?(state.peers, peer) do + Logger.debug( + "Forum[#{node()}|#{state.scope}] already know peer #{inspect(peer)} from node #{node(peer)}" + ) + + {:noreply, state} + else + Logger.debug( + "Forum[#{node()}|#{state.scope}] discovered peer #{inspect(peer)} from node #{node(peer)}" + ) + + ref = Process.monitor(peer) + new_peers = Map.put(state.peers, peer, ref) + state.message_module.send(state.scope, node(peer), {:discover, self()}) + {:noreply, %State{state | peers: new_peers}} + end + end + + # A remote peer has sent us its local member counts + def handle_info({:sync, peer, member_counts}, state) do + :ets.insert(state.peer_counts_table, {node(peer), member_counts}) + {:noreply, state} + end + + # Periodic broadcast of our local member counts to all known peers + def handle_info(:broadcast_counts, state) do + nodes = + state.peers + |> Map.keys() + |> Enum.map(&node/1) + + state.message_module.broadcast( + state.scope, + nodes, + {:sync, self(), Census.local_member_counts(state.scope)} + ) + + Process.send_after(self(), :broadcast_counts, state.broadcast_interval) + {:noreply, state} + end + + # Do nothing if the node that came up is our own node + def handle_info({:nodeup, node}, state) when node == node(), do: {:noreply, state} + + # Send a discover message to the node that just connected + def handle_info({:nodeup, node}, state) do + :telemetry.execute([:census, state.scope, :node, :up], %{}, %{node: node}) + + Logger.info( + "Forum[#{node()}|#{state.scope}] Node #{node} has joined the cluster, sending discover message" + ) + + state.message_module.send(state.scope, node, {:discover, self()}) + {:noreply, state} + end + + # Do nothing and wait for the DOWN message from monitor + def handle_info({:nodedown, _node}, state), do: {:noreply, state} + + # A remote peer has disconnected/crashed + # We forget about it and remove its member counts + def handle_info({:DOWN, ref, :process, peer, reason}, %State{} = state) do + Logger.info( + "Forum[#{node()}|#{state.scope}] Scope process is DOWN on node #{node(peer)}: #{inspect(reason)}" + ) + + case Map.pop(state.peers, peer) do + {nil, _} -> + {:noreply, state} + + {^ref, new_peers} -> + :ets.delete(state.peer_counts_table, node(peer)) + :telemetry.execute([:census, state.scope, :node, :down], %{}, %{node: node(peer)}) + {:noreply, %State{state | peers: new_peers}} + end + end + + def handle_info(_msg, state), do: {:noreply, state} +end diff --git a/forum/lib/forum/partition.ex b/forum/lib/forum/partition.ex new file mode 100644 index 000000000..1e9d26eca --- /dev/null +++ b/forum/lib/forum/partition.ex @@ -0,0 +1,158 @@ +defmodule Forum.Partition do + @moduledoc false + + use GenServer + require Logger + + defmodule State do + @moduledoc false + @type t :: %__MODULE__{ + name: atom, + scope: atom, + entries_table: atom, + monitors: %{{Forum.group(), pid} => reference} + } + defstruct [:name, :scope, :entries_table, monitors: %{}] + end + + @spec join(atom, Forum.group(), pid) :: :ok + def join(partition_name, group, pid), do: GenServer.call(partition_name, {:join, group, pid}) + + @spec leave(atom, Forum.group(), pid) :: :ok + def leave(partition_name, group, pid), do: GenServer.call(partition_name, {:leave, group, pid}) + + @spec members(atom, Forum.group()) :: [pid] + def members(partition_name, group) do + partition_name + |> Forum.Supervisor.partition_entries_table() + |> :ets.select([{{{group, :"$1"}}, [], [:"$1"]}]) + end + + @spec member_count(atom, Forum.group()) :: non_neg_integer + def member_count(partition_name, group), do: :ets.lookup_element(partition_name, group, 2, 0) + + @spec member_counts(atom) :: %{Forum.group() => non_neg_integer} + def member_counts(partition_name) do + partition_name + |> :ets.tab2list() + |> Map.new() + end + + @spec member?(atom, Forum.group(), pid) :: boolean + def member?(partition_name, group, pid) do + partition_name + |> Forum.Supervisor.partition_entries_table() + |> :ets.lookup({group, pid}) + |> case do + [{{^group, ^pid}}] -> true + [] -> false + end + end + + @spec groups(atom) :: [Forum.group()] + def groups(partition_name), do: :ets.select(partition_name, [{{:"$1", :_}, [], [:"$1"]}]) + + @spec group_count(atom) :: non_neg_integer + def group_count(partition_name), do: :ets.info(partition_name, :size) + + @spec start_link(atom, atom, atom) :: GenServer.on_start() + def start_link(scope, partition_name, partition_entries_table), + do: + GenServer.start_link(__MODULE__, [scope, partition_name, partition_entries_table], + name: partition_name + ) + + @impl true + @spec init(any) :: {:ok, State.t()} + def init([scope, name, entries_table]) do + {:ok, %State{scope: scope, name: name, entries_table: entries_table}, + {:continue, :rebuild_monitors_and_counters}} + end + + @impl true + @spec handle_continue(:rebuild_monitors_and_counters, State.t()) :: {:noreply, State.t()} + def handle_continue(:rebuild_monitors_and_counters, state) do + # Here we delete all counters and rebuild them based on entries table + :ets.delete_all_objects(state.name) + + monitors = + :ets.tab2list(state.entries_table) + |> Enum.reduce(%{}, fn {{group, pid}}, monitors_acc -> + ref = Process.monitor(pid, tag: {:DOWN, group}) + :ets.update_counter(state.name, group, {2, 1}, {group, 0}) + Map.put(monitors_acc, {group, pid}, ref) + end) + + {:noreply, %{state | monitors: monitors}} + end + + @impl true + @spec handle_call({:join, Forum.group(), pid}, GenServer.from(), State.t()) :: + {:reply, :ok, State.t()} + def handle_call({:join, group, pid}, _from, state) do + if :ets.insert_new(state.entries_table, {{group, pid}}) do + case :ets.lookup_element(state.name, group, 2, 0) do + 0 -> + :ets.insert(state.name, {group, 1}) + :telemetry.execute([:forum, state.scope, :group, :occupied], %{}, %{group: group}) + + count when count > 0 -> + :ets.insert(state.name, {group, count + 1}) + end + + ref = Process.monitor(pid, tag: {:DOWN, group}) + monitors = Map.put(state.monitors, {group, pid}, ref) + {:reply, :ok, %{state | monitors: monitors}} + else + {:reply, :ok, state} + end + end + + def handle_call({:leave, group, pid}, _from, state) do + state = remove(group, pid, state) + {:reply, :ok, state} + end + + @impl true + @spec handle_info({{:DOWN, Forum.group()}, reference, :process, pid, term}, State.t()) :: + {:noreply, State.t()} + def handle_info({{:DOWN, group}, _ref, :process, pid, _reason}, state) do + state = remove(group, pid, state) + {:noreply, state} + end + + def handle_info(_, state), do: {:noreply, state} + + defp remove(group, pid, state) do + case :ets.lookup(state.entries_table, {group, pid}) do + [{{^group, ^pid}}] -> + :ets.delete(state.entries_table, {group, pid}) + + # Delete or decrement counter + case :ets.lookup_element(state.name, group, 2, 0) do + 1 -> + :ets.delete(state.name, group) + :telemetry.execute([:forum, state.scope, :group, :vacant], %{}, %{group: group}) + + count when count > 1 -> + :ets.update_counter(state.name, group, {2, -1}) + end + + [] -> + Logger.warning( + "Forum[#{node()}|#{state.scope}] Trying to remove an unknown process #{inspect(pid)}" + ) + + :ok + end + + case Map.pop(state.monitors, {group, pid}) do + {nil, _} -> + state + + {ref, new_monitors} -> + Process.demonitor(ref, [:flush]) + %{state | monitors: new_monitors} + end + end +end diff --git a/forum/lib/forum/supervisor.ex b/forum/lib/forum/supervisor.ex new file mode 100644 index 000000000..e0edf54cf --- /dev/null +++ b/forum/lib/forum/supervisor.ex @@ -0,0 +1,61 @@ +defmodule Forum.Supervisor do + @moduledoc false + use Supervisor + + def name(scope), do: :"#{scope}_forum" + def supervisor_name(scope), do: :"#{scope}_forum_supervisor" + def partition_name(scope, partition), do: :"#{scope}_forum_partition_#{partition}" + def partition_entries_table(partition_name), do: :"#{partition_name}_entries" + + @spec partition(atom, Forum.group()) :: atom + def partition(scope, group) do + case :persistent_term.get(scope, :unknown) do + :unknown -> raise "Forum for scope #{inspect(scope)} is not started" + partition_names -> elem(partition_names, :erlang.phash2(group, tuple_size(partition_names))) + end + end + + @spec partitions(atom) :: [atom] + def partitions(scope) do + case :persistent_term.get(scope, :unknown) do + :unknown -> raise "Forum for scope #{inspect(scope)} is not started" + partition_names -> Tuple.to_list(partition_names) + end + end + + @spec start_link(module, atom, pos_integer(), Keyword.t()) :: Supervisor.on_start() + def start_link(module, scope, partitions, opts \\ []) do + args = [module, scope, partitions, opts] + Supervisor.start_link(__MODULE__, args, name: supervisor_name(scope)) + end + + @impl true + def init([module, scope, partitions, opts]) do + children = + for i <- 0..(partitions - 1) do + partition_name = partition_name(scope, i) + partition_entries_table = partition_entries_table(partition_name) + + ^partition_entries_table = + :ets.new(partition_entries_table, [:set, :public, :named_table, read_concurrency: true]) + + ^partition_name = + :ets.new(partition_name, [:set, :public, :named_table, read_concurrency: true]) + + %{ + id: i, + start: {Forum.Partition, :start_link, [scope, partition_name, partition_entries_table]} + } + end + + partition_names = for i <- 0..(partitions - 1), do: partition_name(scope, i) + + :persistent_term.put(scope, List.to_tuple(partition_names)) + + children = [ + %{id: :scope, start: {module, :start_link, [scope, opts]}} | children + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/forum/mix.exs b/forum/mix.exs new file mode 100644 index 000000000..344ff4465 --- /dev/null +++ b/forum/mix.exs @@ -0,0 +1,33 @@ +defmodule Forum.MixProject do + use Mix.Project + + def project do + [ + app: :forum, + version: "1.0.0", + elixir: "~> 1.18", + start_permanent: Mix.env() == :prod, + elixirc_paths: elixirc_paths(Mix.env()), + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:telemetry, "~> 1.3"}, + {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false} + ] + end +end diff --git a/forum/mix.lock b/forum/mix.lock new file mode 100644 index 000000000..2ba2a6c23 --- /dev/null +++ b/forum/mix.lock @@ -0,0 +1,5 @@ +%{ + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, +} diff --git a/forum/test/forum/census_test.exs b/forum/test/forum/census_test.exs new file mode 100644 index 000000000..f38305397 --- /dev/null +++ b/forum/test/forum/census_test.exs @@ -0,0 +1,470 @@ +defmodule Forum.CensusTest do + use ExUnit.Case, async: true + alias Forum.Census + + setup do + scope = :"test_scope#{System.unique_integer([:positive])}" + + %{scope: scope} + end + + defp spec(scope, opts) do + %{ + id: scope, + start: {Census, :start_link, [scope, opts]}, + type: :supervisor + } + end + + describe "start_link/2" do + test "starts forum with default partitions", %{scope: scope} do + pid = start_supervised!({Census, [scope, []]}) + assert Process.alive?(pid) + assert is_list(Forum.Supervisor.partitions(scope)) + assert length(Forum.Supervisor.partitions(scope)) == System.schedulers_online() + end + + test "starts forum with custom partition count", %{scope: scope} do + pid = start_supervised!(spec(scope, partitions: 3)) + assert Process.alive?(pid) + assert length(Forum.Supervisor.partitions(scope)) == 3 + end + + test "raises on invalid partition count", %{scope: scope} do + assert_raise ArgumentError, ~r/expected :partitions to be a positive integer/, fn -> + Census.start_link(scope, partitions: 0) + end + + assert_raise ArgumentError, ~r/expected :partitions to be a positive integer/, fn -> + Census.start_link(scope, partitions: -1) + end + + assert_raise ArgumentError, ~r/expected :partitions to be a positive integer/, fn -> + Census.start_link(scope, partitions: :invalid) + end + end + + test "raises on invalid broadcast_interval_in_ms", %{scope: scope} do + assert_raise ArgumentError, + ~r/expected :broadcast_interval_in_ms to be a positive integer/, + fn -> + Census.start_link(scope, broadcast_interval_in_ms: 0) + end + + assert_raise ArgumentError, + ~r/expected :broadcast_interval_in_ms to be a positive integer/, + fn -> + Census.start_link(scope, broadcast_interval_in_ms: -1) + end + + assert_raise ArgumentError, + ~r/expected :broadcast_interval_in_ms to be a positive integer/, + fn -> + Census.start_link(scope, broadcast_interval_in_ms: :invalid) + end + end + end + + describe "join/3 and leave/3" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "can join a group", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + assert :ok = Census.join(scope, :group1, pid) + assert Census.local_member?(scope, :group1, pid) + end + + test "can leave a group", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + assert :ok = Census.join(scope, :group1, pid) + assert Census.local_member?(scope, :group1, pid) + + assert :ok = Census.leave(scope, :group1, pid) + refute Census.local_member?(scope, :group1, pid) + end + + test "joining same group twice is idempotent", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + assert :ok = Census.join(scope, :group1, pid) + assert :ok = Census.join(scope, :group1, pid) + assert Census.local_member_count(scope, :group1) == 1 + end + + test "multiple processes can join same group", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + + assert :ok = Census.join(scope, :group1, pid1) + assert :ok = Census.join(scope, :group1, pid2) + + members = Census.local_members(scope, :group1) + assert length(members) == 2 + assert pid1 in members + assert pid2 in members + end + + test "process can join multiple groups", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + + assert :ok = Census.join(scope, :group1, pid) + assert :ok = Census.join(scope, :group2, pid) + + assert Census.local_member?(scope, :group1, pid) + assert Census.local_member?(scope, :group2, pid) + end + + test "automatically removes member when process dies", %{scope: scope} do + pid = spawn(fn -> Process.sleep(:infinity) end) + assert :ok = Census.join(scope, :group1, pid) + assert Census.local_member?(scope, :group1, pid) + + Process.exit(pid, :kill) + Process.sleep(50) + + refute Census.local_member?(scope, :group1, pid) + assert Census.local_member_count(scope, :group1) == 0 + end + end + + describe "local_members/2" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns empty list for non-existent group", %{scope: scope} do + assert Census.local_members(scope, :nonexistent) == [] + end + + test "returns all members of a group", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + pid3 = spawn_link(fn -> Process.sleep(:infinity) end) + + Census.join(scope, :group1, pid1) + Census.join(scope, :group1, pid2) + Census.join(scope, :group2, pid3) + + members = Census.local_members(scope, :group1) + assert length(members) == 2 + assert pid1 in members + assert pid2 in members + refute pid3 in members + end + end + + describe "local_member_count/2" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns 0 for non-existent group", %{scope: scope} do + assert Census.local_member_count(scope, :nonexistent) == 0 + end + + test "returns correct count", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + + assert Census.local_member_count(scope, :group1) == 0 + + Census.join(scope, :group1, pid1) + assert Census.local_member_count(scope, :group1) == 1 + + Census.join(scope, :group1, pid2) + assert Census.local_member_count(scope, :group1) == 2 + + Census.leave(scope, :group1, pid1) + assert Census.local_member_count(scope, :group1) == 1 + end + end + + describe "local_member_counts/1" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns empty map when no groups exist", %{scope: scope} do + assert Census.local_member_counts(scope) == %{} + end + + test "returns counts for all groups", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + pid3 = spawn_link(fn -> Process.sleep(:infinity) end) + + Census.join(scope, :group1, pid1) + Census.join(scope, :group1, pid2) + Census.join(scope, :group2, pid3) + + assert Census.local_member_counts(scope) == %{ + group1: 2, + group2: 1 + } + end + end + + describe "local_member?/3" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns false for non-member", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + refute Census.local_member?(scope, :group1, pid) + end + + test "returns true for member", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + Census.join(scope, :group1, pid) + assert Census.local_member?(scope, :group1, pid) + end + + test "returns false after leaving", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + + Census.join(scope, :group1, pid) + Census.leave(scope, :group1, pid) + + refute Census.local_member?(scope, :group1, pid) + end + end + + describe "local_groups/1" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns empty list when no groups exist", %{scope: scope} do + assert Census.local_groups(scope) == [] + end + + test "returns all groups with members", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + + Census.join(scope, :group1, pid1) + Census.join(scope, :group2, pid2) + Census.join(scope, :group3, pid1) + + groups = Census.local_groups(scope) + assert :group1 in groups + assert :group2 in groups + assert :group3 in groups + assert length(groups) == 3 + end + + test "removes group from list when last member leaves", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + Census.join(scope, :group1, pid) + assert :group1 in Census.local_groups(scope) + + Census.leave(scope, :group1, pid) + refute :group1 in Census.local_groups(scope) + end + end + + describe "local_group_count/1" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns 0 when no groups exist", %{scope: scope} do + assert Census.local_group_count(scope) == 0 + end + + test "returns correct count of groups", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + Census.join(scope, :group1, pid1) + Census.join(scope, :group2, pid2) + Census.join(scope, :group3, pid2) + Census.join(scope, :group3, pid1) + assert Census.local_group_count(scope) == 3 + Census.leave(scope, :group2, pid2) + assert Census.local_group_count(scope) == 2 + end + end + + describe "member_counts/1" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns local counts when no peers", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + + Census.join(scope, :group1, pid1) + Census.join(scope, :group1, pid2) + + counts = Census.member_counts(scope) + assert counts[:group1] == 2 + end + end + + describe "partition distribution" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 4)) + :ok + end + + test "distributes groups across partitions", %{scope: scope} do + # Create multiple processes and verify they're split against different partitions + pids = for _ <- 1..20, do: spawn_link(fn -> Process.sleep(:infinity) end) + + Enum.each(pids, fn pid -> + Census.join(scope, pid, pid) + end) + + # Check that multiple partitions are being used + partition_names = Forum.Supervisor.partitions(scope) + + Enum.map(partition_names, fn partition_name -> + assert Forum.Partition.member_counts(partition_name) > 1 + end) + end + + test "same group always maps to same partition", %{scope: scope} do + partition1 = Forum.Supervisor.partition(scope, :my_group) + partition2 = Forum.Supervisor.partition(scope, :my_group) + partition3 = Forum.Supervisor.partition(scope, :my_group) + + assert partition1 == partition2 + assert partition2 == partition3 + end + end + + @aux_mod (quote do + defmodule PeerAux do + def start(scope) do + spawn(fn -> + {:ok, _} = Census.start_link(scope, broadcast_interval_in_ms: 50) + + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + Census.join(scope, :group1, pid1) + Census.join(scope, :group2, pid2) + Census.join(scope, :group3, pid2) + + Process.sleep(:infinity) + end) + end + end + end) + + describe "distributed tests" do + setup do + scope = :"broadcast_scope#{System.unique_integer([:positive])}" + supervisor_pid = start_supervised!(spec(scope, partitions: 2, broadcast_interval_in_ms: 50)) + {:ok, peer, node} = Peer.start_disconnected(aux_mod: @aux_mod) + + ref = + :telemetry_test.attach_event_handlers(self(), [ + [:census, scope, :node, :up], + [:census, scope, :node, :down] + ]) + + %{scope: scope, supervisor_pid: supervisor_pid, peer: peer, node: node, telemetry_ref: ref} + end + + test "node up", %{scope: scope, peer: peer, node: node, telemetry_ref: telemetry_ref} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + Census.join(scope, :group1, pid1) + Census.join(scope, :group1, pid2) + Census.join(scope, :group2, pid2) + + true = Node.connect(node) + :peer.call(peer, PeerAux, :start, [scope]) + + assert_receive {[:census, ^scope, :node, :up], ^telemetry_ref, %{}, %{node: ^node}} + + # Wait for at least one broadcast interval + Process.sleep(150) + assert Census.group_count(scope) == 3 + groups = Census.groups(scope) + + assert length(groups) == 3 + assert :group1 in groups + assert :group2 in groups + assert :group3 in groups + + assert Census.member_counts(scope) == %{group1: 3, group2: 2, group3: 1} + assert Census.member_count(scope, :group1) == 3 + assert Census.member_count(scope, :group3, node) == 1 + assert Census.member_count(scope, :group1, node()) == 2 + end + + test "node down", %{scope: scope, peer: peer, node: node, telemetry_ref: telemetry_ref} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + Census.join(scope, :group1, pid1) + Census.join(scope, :group1, pid2) + Census.join(scope, :group2, pid2) + + true = Node.connect(node) + :peer.call(peer, PeerAux, :start, [scope]) + assert_receive {[:census, ^scope, :node, :up], ^telemetry_ref, %{}, %{node: ^node}} + # Wait for remote scope to communicate with local + Process.sleep(150) + + true = Node.disconnect(node) + + assert_receive {[:census, ^scope, :node, :down], ^telemetry_ref, %{}, %{node: ^node}} + + assert Census.member_counts(scope) == %{group1: 2, group2: 1} + assert Census.member_count(scope, :group1) == 2 + end + + test "scope restart can recover", %{ + scope: scope, + supervisor_pid: supervisor_pid, + peer: peer, + node: node, + telemetry_ref: telemetry_ref + } do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + Census.join(scope, :group1, pid1) + Census.join(scope, :group1, pid2) + Census.join(scope, :group2, pid2) + + true = Node.connect(node) + :peer.call(peer, PeerAux, :start, [scope]) + assert_receive {[:census, ^scope, :node, :up], ^telemetry_ref, %{}, %{node: ^node}} + + # Wait for remote scope to communicate with local + Process.sleep(150) + + [ + {1, _, :worker, [Forum.Partition]}, + {0, _, :worker, [Forum.Partition]}, + {:scope, scope_pid, :worker, [Forum.Census.Scope]} + ] = Supervisor.which_children(supervisor_pid) + + # Restart the scope process + Process.monitor(scope_pid) + Process.exit(scope_pid, :kill) + assert_receive {:DOWN, _ref, :process, ^scope_pid, :killed} + # Wait for recovery and communication + Process.sleep(200) + assert Census.group_count(scope) == 3 + groups = Census.groups(scope) + assert length(groups) == 3 + assert :group1 in groups + assert :group2 in groups + assert :group3 in groups + assert Census.member_counts(scope) == %{group1: 3, group2: 2, group3: 1} + end + end +end diff --git a/forum/test/forum/partition_test.exs b/forum/test/forum/partition_test.exs new file mode 100644 index 000000000..bc1cbf3f9 --- /dev/null +++ b/forum/test/forum/partition_test.exs @@ -0,0 +1,240 @@ +defmodule Forum.PartitionTest do + use ExUnit.Case, async: true + alias Forum.Partition + + @scope __MODULE__ + + setup do + partition_name = Forum.Supervisor.partition_name(@scope, System.unique_integer([:positive])) + entries_table = Forum.Supervisor.partition_entries_table(partition_name) + + ^partition_name = + :ets.new(partition_name, [:set, :public, :named_table, read_concurrency: true]) + + ^entries_table = + :ets.new(entries_table, [:set, :public, :named_table, read_concurrency: true]) + + spec = %{ + id: partition_name, + start: {Partition, :start_link, [@scope, partition_name, entries_table]}, + type: :supervisor, + restart: :temporary + } + + pid = start_supervised!(spec) + + ref = + :telemetry_test.attach_event_handlers(self(), [ + [:forum, @scope, :group, :occupied], + [:forum, @scope, :group, :vacant] + ]) + + {:ok, partition_name: partition_name, partition_pid: pid, ref: ref} + end + + test "members/2 returns empty list for non-existent group", %{partition_name: partition} do + assert Partition.members(partition, :nonexistent) == [] + end + + test "member_count/2 returns 0 for non-existent group", %{partition_name: partition} do + assert Partition.member_count(partition, :nonexistent) == 0 + end + + test "member?/3 returns false for non-member", %{partition_name: partition} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + refute Partition.member?(partition, :group1, pid) + end + + test "join and query member", %{partition_name: partition, ref: ref} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + + assert :ok = Partition.join(partition, :group9, pid) + + assert Partition.member?(partition, :group9, pid) + assert Partition.member_count(partition, :group9) == 1 + assert pid in Partition.members(partition, :group9) + assert_receive {[:forum, @scope, :group, :occupied], ^ref, %{}, %{group: :group9}} + + refute_receive {_, ^ref, _, _} + end + + test "join multiple times and query member", %{partition_name: partition, ref: ref} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + + assert :ok = Partition.join(partition, :group1, pid) + assert :ok = Partition.join(partition, :group1, pid) + assert :ok = Partition.join(partition, :group1, pid) + + assert Partition.member?(partition, :group1, pid) + assert Partition.member_count(partition, :group1) == 1 + assert pid in Partition.members(partition, :group1) + + assert_receive {[:forum, @scope, :group, :occupied], ^ref, %{}, %{group: :group1}} + refute_receive {_, ^ref, _, _} + end + + test "occupied event only when first member joins", %{partition_name: partition, ref: ref} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid1) + Partition.join(partition, :group1, pid2) + + assert_receive {[:forum, @scope, :group, :occupied], ^ref, %{}, %{group: :group1}} + + refute_receive {_, ^ref, _, _} + end + + test "leave removes member", %{partition_name: partition, ref: ref} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid) + assert Partition.member?(partition, :group1, pid) + + Partition.leave(partition, :group1, pid) + refute Partition.member?(partition, :group1, pid) + assert_receive {[:forum, @scope, :group, :occupied], ^ref, %{}, %{group: :group1}} + + assert_receive {[:forum, @scope, :group, :vacant], ^ref, %{}, %{group: :group1}} + + refute_receive {_, ^ref, _, _} + end + + test "vacant event only when no members left", %{partition_name: partition, ref: ref} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid1) + Partition.join(partition, :group1, pid2) + + assert_receive {[:forum, @scope, :group, :occupied], ^ref, %{}, %{group: :group1}} + refute_receive {_, ^ref, _, _} + + Partition.leave(partition, :group1, pid1) + + refute_receive {_, ^ref, _, _} + + Partition.leave(partition, :group1, pid2) + + assert_receive {[:forum, @scope, :group, :vacant], ^ref, %{}, %{group: :group1}} + refute_receive {_, ^ref, _, _} + end + + test "leave multiple times removes member", %{partition_name: partition, ref: ref} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid) + assert Partition.member?(partition, :group1, pid) + + Partition.leave(partition, :group1, pid) + Partition.leave(partition, :group1, pid) + Partition.leave(partition, :group1, pid) + refute Partition.member?(partition, :group1, pid) + assert_receive {[:forum, @scope, :group, :occupied], ^ref, %{}, %{group: :group1}} + + assert_receive {[:forum, @scope, :group, :vacant], ^ref, %{}, %{group: :group1}} + + refute_receive {_, ^ref, _, _} + end + + test "member_counts returns counts for all groups", %{partition_name: partition} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + pid3 = spawn_link(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid1) + Partition.join(partition, :group1, pid2) + Partition.join(partition, :group2, pid3) + + counts = Partition.member_counts(partition) + assert map_size(counts) == 2 + assert counts[:group1] == 2 + assert counts[:group2] == 1 + end + + test "groups returns all groups", %{partition_name: partition} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid1) + Partition.join(partition, :group2, pid2) + + groups = Partition.groups(partition) + assert :group1 in groups + assert :group2 in groups + end + + test "group_counts returns number of groups", %{partition_name: partition} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + pid3 = spawn_link(fn -> Process.sleep(:infinity) end) + pid4 = spawn_link(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid1) + Partition.join(partition, :group1, pid2) + Partition.join(partition, :group2, pid3) + Partition.join(partition, :group3, pid4) + + assert Partition.group_count(partition) == 3 + end + + test "process death removes member from group", %{partition_name: partition} do + pid = spawn(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid) + assert Partition.member?(partition, :group1, pid) + + Process.exit(pid, :kill) + Process.sleep(50) + + refute Partition.member?(partition, :group1, pid) + assert Partition.member_count(partition, :group1) == 0 + end + + test "partition recovery monitors processes again", %{ + partition_name: partition, + partition_pid: partition_pid + } do + pid1 = spawn(fn -> Process.sleep(:infinity) end) + pid2 = spawn(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid1) + Partition.join(partition, :group2, pid2) + + monitors = Process.info(partition_pid, [:monitors])[:monitors] |> Enum.map(&elem(&1, 1)) + assert length(monitors) + assert monitors |> Enum.member?(pid1) + assert monitors |> Enum.member?(pid2) + + assert %{{:group1, ^pid1} => _ref1, {:group2, ^pid2} => _ref2} = + :sys.get_state(partition_pid).monitors + + Process.monitor(partition_pid) + Process.exit(partition_pid, :kill) + assert_receive {:DOWN, _ref, :process, ^partition_pid, :killed} + + spec = %{ + id: :recover, + start: + {Partition, :start_link, + [@scope, partition, Forum.Supervisor.partition_entries_table(partition)]}, + type: :supervisor + } + + partition_pid = start_supervised!(spec) + + assert %{{:group1, ^pid1} => _ref1, {:group2, ^pid2} => _ref2} = + :sys.get_state(partition_pid).monitors + + monitors = Process.info(partition_pid, [:monitors])[:monitors] |> Enum.map(&elem(&1, 1)) + assert length(monitors) + assert monitors |> Enum.member?(pid1) + assert monitors |> Enum.member?(pid2) + + assert Partition.member_count(partition, :group1) == 1 + assert Partition.member_count(partition, :group2) == 1 + + assert Partition.member?(partition, :group1, pid1) + assert Partition.member?(partition, :group2, pid2) + end +end diff --git a/forum/test/support/peer.ex b/forum/test/support/peer.ex new file mode 100644 index 000000000..b2667a0a1 --- /dev/null +++ b/forum/test/support/peer.ex @@ -0,0 +1,89 @@ +defmodule Peer do + @moduledoc """ + Uses the gist https://gist.github.com/ityonemo/177cbc96f8c8722bfc4d127ff9baec62 to start a node for testing + """ + + @doc """ + Starts a node for testing. + + Can receive an auxiliary module to be evaluated in the node so you are able to setup functions within the test context and outside of the normal code context + + e.g. + ``` + @aux_mod (quote do + defmodule Aux do + def checker(res), do: res + end + end) + + Code.eval_quoted(@aux_mod) + test "clustered call" do + {:ok, node} = Clustered.start(@aux_mod) + assert ok = :rpc.call(node, Aux, :checker, [:ok]) + end + ``` + """ + @spec start(Keyword.t()) :: {:ok, :peer.server_ref(), node} + def start(opts \\ []) do + {:ok, peer, node} = start_disconnected(opts) + + true = Node.connect(node) + + {:ok, peer, node} + end + + @doc """ + Similar to `start/2` but the node is not connected automatically + """ + @spec start_disconnected(Keyword.t()) :: {:ok, :peer.server_ref(), node} + def start_disconnected(opts \\ []) do + extra_config = Keyword.get(opts, :extra_config, []) + name = Keyword.get(opts, :name, :peer.random_name()) + aux_mod = Keyword.get(opts, :aux_mod, nil) + + true = :erlang.set_cookie(:cookie) + + {:ok, pid, node} = + ExUnit.Callbacks.start_supervised(%{ + id: {:peer, name}, + start: + {:peer, :start_link, + [ + %{ + name: name, + host: ~c"127.0.0.1", + longnames: true, + connection: :standard_io + } + ]} + }) + + :peer.call(pid, :erlang, :set_cookie, [:cookie]) + + :ok = :peer.call(pid, :code, :add_paths, [:code.get_path()]) + + for {app_name, _, _} <- Application.loaded_applications(), + {key, value} <- Application.get_all_env(app_name) do + :ok = :peer.call(pid, Application, :put_env, [app_name, key, value]) + end + + # Override with extra config + for {app_name, key, value} <- extra_config do + :ok = :peer.call(pid, Application, :put_env, [app_name, key, value]) + end + + {:ok, _} = :peer.call(pid, Application, :ensure_all_started, [:mix]) + :ok = :peer.call(pid, Mix, :env, [Mix.env()]) + + Enum.map( + [:logger, :runtime_tools, :mix, :os_mon, :forum], + fn app -> {:ok, _} = :peer.call(pid, Application, :ensure_all_started, [app]) end + ) + + if aux_mod do + {{:module, _, _, _}, []} = :peer.call(pid, Code, :eval_quoted, [aux_mod]) + end + + {:ok, pid, node} + end +end diff --git a/forum/test/test_helper.exs b/forum/test/test_helper.exs new file mode 100644 index 000000000..7fc87df68 --- /dev/null +++ b/forum/test/test_helper.exs @@ -0,0 +1,3 @@ +ExUnit.start(capture_log: true) + +:net_kernel.start([:"forum@127.0.0.1"]) diff --git a/lib/extensions/extensions.ex b/lib/extensions/extensions.ex index aaf28820e..07b832a62 100644 --- a/lib/extensions/extensions.ex +++ b/lib/extensions/extensions.ex @@ -10,9 +10,14 @@ defmodule Realtime.Extensions do _, acc -> acc end) - %{ - default: apply(db_settings, :default, []), - required: apply(db_settings, :required, []) - } + if db_settings do + %{ + default: apply(db_settings, :default, []), + required: apply(db_settings, :required, []), + optional: apply(db_settings, :optional, []) + } + else + %{default: %{}, required: [], optional: []} + end end end diff --git a/lib/extensions/postgres_cdc_rls/cdc_rls.ex b/lib/extensions/postgres_cdc_rls/cdc_rls.ex index 57bf17352..3520bdb2b 100644 --- a/lib/extensions/postgres_cdc_rls/cdc_rls.ex +++ b/lib/extensions/postgres_cdc_rls/cdc_rls.ex @@ -6,11 +6,13 @@ defmodule Extensions.PostgresCdcRls do @behaviour Realtime.PostgresCdc use Realtime.Logs - alias RealtimeWeb.Endpoint alias Extensions.PostgresCdcRls, as: Rls + alias Realtime.GenCounter + alias Realtime.GenRpc + alias RealtimeWeb.Endpoint alias Rls.Subscriptions - alias Realtime.Rpc + @impl true @spec handle_connect(map()) :: {:ok, {pid(), pid()}} | nil def handle_connect(args) do case get_manager_conn(args["id"]) do @@ -26,22 +28,88 @@ defmodule Extensions.PostgresCdcRls do end end - def handle_after_connect({manager_pid, conn}, settings, params) do - publication = settings["publication"] - opts = [conn, publication, params, manager_pid, self()] - conn_node = node(conn) + @impl true + def handle_after_connect({manager_pid, conn}, settings, params_list, tenant) do + with {:ok, subscription_list} <- subscription_list(params_list) do + pool_size = Map.get(settings, "subcriber_pool_size", 4) + publication = settings["publication"] + create_subscription(conn, tenant, publication, pool_size, subscription_list, manager_pid, self()) + end + end + + @database_timeout_reason "Too many database timeouts" + + def create_subscription(conn, tenant, publication, pool_size, subscription_list, manager_pid, caller) + when node(conn) == node() do + with_rate_counter(tenant, pool_size, fn rate_counter -> + case Subscriptions.create(conn, publication, subscription_list, manager_pid, caller) do + {:error, %DBConnection.ConnectionError{}} -> + GenCounter.add(rate_counter.id) + {:error, @database_timeout_reason} + + {:error, {:exit, _}} -> + GenCounter.add(rate_counter.id) + {:error, @database_timeout_reason} + + response -> + response + end + end) + end + + def create_subscription(conn, tenant, publication, pool_size, subscription_list, manager_pid, caller) do + with_rate_counter(tenant, pool_size, fn rate_counter -> + args = [conn, tenant, publication, pool_size, subscription_list, manager_pid, caller] + + case GenRpc.call(node(conn), __MODULE__, :create_subscription, args, timeout: 15_000, tenant_id: tenant) do + {:error, @database_timeout_reason} -> + GenCounter.add(rate_counter.id) + {:error, @database_timeout_reason} + + response -> + response + end + end) + end - if conn_node !== node() do - Rpc.call(conn_node, Subscriptions, :create, opts, timeout: 15_000) + defp with_rate_counter(tenant, pool_size, fun) do + with {:ok, %{limit: %{triggered: false}} = rate_counter} <- rate_counter(tenant, pool_size) do + fun.(rate_counter) else - apply(Subscriptions, :create, opts) + {:ok, _} -> + {:error, @database_timeout_reason} + + {:error, reason} -> + log_error("RateCounterError", reason) + {:error, @database_timeout_reason} end end + defp rate_counter(tenant_id, pool_size) do + rate_counter_args = Realtime.Tenants.subscription_errors_per_second_rate(tenant_id, pool_size) + Realtime.RateCounter.get(rate_counter_args) + rescue + e -> {:error, e} + end + + defp subscription_list(params_list) do + Enum.reduce_while(params_list, {:ok, []}, fn params, {:ok, acc} -> + case Subscriptions.parse_subscription_params(params[:params]) do + {:ok, subscription_params} -> + {:cont, {:ok, [%{id: params.id, claims: params.claims, subscription_params: subscription_params} | acc]}} + + {:error, reason} -> + {:halt, {:error, {:malformed_subscription_params, reason}}} + end + end) + end + + @impl true def handle_subscribe(_, tenant, metadata) do Endpoint.subscribe("realtime:postgres:" <> tenant, metadata) end + @impl true @doc """ Stops the Supervision tree for a tenant. @@ -50,7 +118,9 @@ defmodule Extensions.PostgresCdcRls do @spec handle_stop(String.t(), non_neg_integer()) :: :ok def handle_stop(tenant, timeout) when is_binary(tenant) do - case :syn.whereis_name({__MODULE__, tenant}) do + scope = Realtime.Syn.PostgresCdc.scope(tenant) + + case :syn.whereis_name({scope, tenant}) do :undefined -> Logger.warning("Database supervisor not found for tenant #{tenant}") :ok @@ -64,13 +134,13 @@ defmodule Extensions.PostgresCdcRls do def start_distributed(%{"region" => region, "id" => tenant} = args) do platform_region = Realtime.Nodes.platform_region_translator(region) - launch_node = Realtime.Nodes.launch_node(tenant, platform_region, node()) + launch_node = Realtime.Nodes.launch_node(platform_region, node(), tenant) Logger.warning( "Starting distributed postgres extension #{inspect(lauch_node: launch_node, region: region, platform_region: platform_region)}" ) - case Rpc.call(launch_node, __MODULE__, :start, [args], timeout: 30_000, tenant: tenant) do + case GenRpc.call(launch_node, __MODULE__, :start, [args], timeout: 30_000, tenant_id: tenant) do {:ok, _pid} = ok -> ok @@ -90,8 +160,6 @@ defmodule Extensions.PostgresCdcRls do @spec start(map()) :: {:ok, pid} | {:error, :already_started | :reserved} def start(%{"id" => tenant} = args) when is_binary(tenant) do - args = Map.merge(args, %{"subs_pool_size" => Map.get(args, "subcriber_pool_size", 4)}) - Logger.debug("Starting #{__MODULE__} extension with args: #{inspect(args, pretty: true)}") DynamicSupervisor.start_child( @@ -99,14 +167,16 @@ defmodule Extensions.PostgresCdcRls do %{ id: tenant, start: {Rls.WorkerSupervisor, :start_link, [args]}, - restart: :transient + restart: :temporary } ) end @spec get_manager_conn(String.t()) :: {:error, nil | :wait} | {:ok, pid(), pid()} def get_manager_conn(id) do - case :syn.lookup(__MODULE__, id) do + scope = Realtime.Syn.PostgresCdc.scope(id) + + case :syn.lookup(scope, id) do {_, %{manager: nil, subs_pool: nil}} -> {:error, :wait} {_, %{manager: manager, subs_pool: conn}} -> {:ok, manager, conn} _ -> {:error, nil} @@ -115,12 +185,15 @@ defmodule Extensions.PostgresCdcRls do @spec supervisor_id(String.t(), String.t()) :: {atom(), String.t(), map()} def supervisor_id(tenant, region) do - {__MODULE__, tenant, %{region: region, manager: nil, subs_pool: nil}} + scope = Realtime.Syn.PostgresCdc.scope(tenant) + {scope, tenant, %{region: region, manager: nil, subs_pool: nil}} end @spec update_meta(String.t(), pid(), pid()) :: {:ok, {pid(), term()}} | {:error, term()} def update_meta(tenant, manager_pid, subs_pool) do - :syn.update_registry(__MODULE__, tenant, fn pid, meta -> + scope = Realtime.Syn.PostgresCdc.scope(tenant) + + :syn.update_registry(scope, tenant, fn pid, meta -> if node(pid) == node(manager_pid) do %{meta | manager: manager_pid, subs_pool: subs_pool} else @@ -130,6 +203,4 @@ defmodule Extensions.PostgresCdcRls do end end) end - - def syn_topic(tenant_id), do: "cdc_rls:#{tenant_id}" end diff --git a/lib/extensions/postgres_cdc_rls/db_settings.ex b/lib/extensions/postgres_cdc_rls/db_settings.ex index e4242f658..071ef0700 100644 --- a/lib/extensions/postgres_cdc_rls/db_settings.ex +++ b/lib/extensions/postgres_cdc_rls/db_settings.ex @@ -17,10 +17,17 @@ defmodule Extensions.PostgresCdcRls.DbSettings do [ {"region", &is_binary/1, false}, {"db_host", &is_binary/1, true}, + {"db_port", &is_binary/1, true}, {"db_name", &is_binary/1, true}, {"db_user", &is_binary/1, true}, - {"db_port", &is_binary/1, true}, {"db_password", &is_binary/1, true} ] end + + def optional do + [ + {"db_user_realtime", &is_binary/1, true}, + {"db_pass_realtime", &is_binary/1, true} + ] + end end diff --git a/lib/extensions/postgres_cdc_rls/message_dispatcher.ex b/lib/extensions/postgres_cdc_rls/message_dispatcher.ex index 8e7ae7f5f..bbf9b69a5 100644 --- a/lib/extensions/postgres_cdc_rls/message_dispatcher.ex +++ b/lib/extensions/postgres_cdc_rls/message_dispatcher.ex @@ -7,23 +7,11 @@ defmodule Extensions.PostgresCdcRls.MessageDispatcher do """ alias Phoenix.Socket.Broadcast - alias Realtime.GenCounter - alias Realtime.RateCounter - alias Realtime.Tenants - - def dispatch([_ | _] = topic_subscriptions, _from, payload) do - {sub_ids, payload} = Map.pop(payload, :subscription_ids) - - [{_pid, {:subscriber_fastlane, _fastlane_pid, _serializer, _ids, _join_topic, tenant_id, _is_new_api}} | _] = - topic_subscriptions - - # Ensure RateCounter is started - rate = Tenants.db_events_per_second_rate(tenant_id) - RateCounter.new(rate) + def dispatch([_ | _] = topic_subscriptions, _from, {type, payload, sub_ids}) do _ = Enum.reduce(topic_subscriptions, %{}, fn - {_pid, {:subscriber_fastlane, fastlane_pid, serializer, ids, join_topic, _tenant, is_new_api}}, cache -> + {_pid, {:subscriber_fastlane, fastlane_pid, serializer, ids, join_topic, is_new_api}}, cache -> for {bin_id, id} <- ids, reduce: [] do acc -> if MapSet.member?(sub_ids, bin_id) do @@ -39,17 +27,12 @@ defmodule Extensions.PostgresCdcRls.MessageDispatcher do %Broadcast{ topic: join_topic, event: "postgres_changes", - payload: %{ids: valid_ids, data: payload} + payload: %{ids: valid_ids, data: Jason.Fragment.new(payload)} } else - %Broadcast{ - topic: join_topic, - event: payload.type, - payload: payload - } + %Broadcast{topic: join_topic, event: type, payload: Jason.Fragment.new(payload)} end - GenCounter.add(rate.id) broadcast_message(cache, fastlane_pid, new_payload, serializer) _ -> diff --git a/lib/extensions/postgres_cdc_rls/replication_poller.ex b/lib/extensions/postgres_cdc_rls/replication_poller.ex index 65f4a33f1..86fe4e257 100644 --- a/lib/extensions/postgres_cdc_rls/replication_poller.ex +++ b/lib/extensions/postgres_cdc_rls/replication_poller.ex @@ -1,66 +1,151 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do @moduledoc """ - Polls the write ahead log, applies row level sucurity policies for each subscriber - and broadcast records to the `MessageDispatcher`. + Polls the write-ahead log via a temporary logical replication slot, applies row + level security policies for each subscriber, and broadcasts records to the + `MessageDispatcher`. + + ## Lifecycle + + On start the poller connects to the tenant's database and fetches the + publication's tables via `Subscriptions.fetch_publication_tables/2`. Only if + the publication has tables does it call `Replications.prepare_replication/2` + to create the temporary slot and kick off the poll loop; if the publication is + empty it stays idle without creating a slot (an unconsumed slot would retain + WAL) and waits for tables to appear. + + ## Poll loop + + Each `:poll` calls `Replications.list_changes/5`, which drains the slot and + fans changes out to subscriber nodes. Reschedule cadence depends on activity: + + * rows processed → poll again immediately + * raw slot changes present but nothing for subscribers → poll after `poll_interval_ms` (+ jitter) + * fully idle → back off to `poll_interval_ms * @idle_multiplier`. + + When the publication is empty, `:poll` is a no-op — there are no tables to + decode, so the slot is not advanced. + + ## Reacting to publication changes + + Every `@check_oids_interval` ms the poller re-fetches the publication's oids: + + * tables appear (empty → non-empty): re-run `prepare_replication/1` to + recreate the slot if it was dropped, then resume polling. + * tables vanish (non-empty → empty): cancel pending polls and drop the + replication slot via `Replications.drop_replication_slot/2`. If the drop + fails for any reason other than `:slot_not_found`, the poller stops with + `{:shutdown, :drop_replication_slot_failed}`; because the slot is + temporary, Postgres releases it automatically when the DB connection + ends. + + This mirrors `SubscriptionManager`'s own `:check_oids` loop, which manages + subscriptions when the publication's tables change. """ use GenServer use Realtime.Logs + @idle_multiplier 5 + @max_retries 6 + @check_oids_interval 60_000 + + # Column order returned by realtime.list_changes/4 (see Replications.list_changes/5 + # and the SQL function in + # lib/realtime/tenants/repo/migrations/20260326120000_list_changes_with_slot_count.ex). + # generate_record/1 below pattern-matches positionally on this order; the runtime + # check in handle_list_changes_result/4 fails loudly if the SQL ever changes. + @expected_columns ~w(type schema table columns record old_record commit_timestamp subscription_ids errors slot_changes_count) + import Realtime.Helpers alias DBConnection.Backoff alias Extensions.PostgresCdcRls.MessageDispatcher alias Extensions.PostgresCdcRls.Replications + alias Extensions.PostgresCdcRls.Subscriptions alias Realtime.Adapters.Changes.DeletedRecord alias Realtime.Adapters.Changes.NewRecord alias Realtime.Adapters.Changes.UpdatedRecord alias Realtime.Database + alias Realtime.RateCounter + alias Realtime.Tenants + + alias RealtimeWeb.TenantBroadcaster def start_link(opts), do: GenServer.start_link(__MODULE__, opts) @impl true def init(args) do + Process.flag(:fullsweep_after, 20) tenant_id = args["id"] Logger.metadata(external_id: tenant_id, project: tenant_id) + %Realtime.Api.Tenant{} = tenant = Tenants.Cache.get_tenant_by_external_id(tenant_id) + rate_counter_args = Tenants.db_events_per_second_rate(tenant) + extension = Realtime.PostgresCdc.filter_settings("postgres_cdc_rls", tenant.extensions) + + RateCounter.new(rate_counter_args) + + start_time = Realtime.Telemetry.start([:realtime, :replication, :poller], %{tenant: tenant_id}) + state = %{ backoff: Backoff.new(backoff_min: 100, backoff_max: 5_000, backoff_type: :rand_exp), - db_host: args["db_host"], - db_port: args["db_port"], - db_name: args["db_name"], - db_user: args["db_user"], - db_pass: args["db_password"], - max_changes: args["poll_max_changes"], - max_record_bytes: args["poll_max_record_bytes"], - poll_interval_ms: args["poll_interval_ms"], + max_changes: extension["poll_max_changes"], + max_record_bytes: extension["poll_max_record_bytes"], + poll_interval_ms: extension["poll_interval_ms"], poll_ref: nil, - publication: args["publication"], + publication: extension["publication"], retry_ref: nil, retry_count: 0, - slot_name: args["slot_name"] <> slot_name_suffix(), - tenant_id: tenant_id + slot_name: extension["slot_name"] <> slot_name_suffix(), + tenant_id: tenant_id, + rate_counter_args: rate_counter_args, + subscribers_nodes_table: args["subscribers_nodes_table"], + start_time: start_time, + oids: %{}, + check_oid_ref: nil } {:ok, _} = Registry.register(__MODULE__.Registry, tenant_id, %{}) - {:ok, state, {:continue, {:connect, args}}} + {:ok, state, {:continue, {:connect, tenant}}} + end + + @impl true + def terminate(reason, %{start_time: start_time, tenant_id: tenant_id}) do + if reason in [:normal, :shutdown] or match?({:shutdown, _}, reason) do + Realtime.Telemetry.stop([:realtime, :replication, :poller], start_time, %{tenant: tenant_id, reason: reason}) + else + Realtime.Telemetry.exception([:realtime, :replication, :poller], start_time, :exit, reason, [], %{ + tenant: tenant_id + }) + end end + def terminate(_reason, _state), do: :ok + @impl true - def handle_continue({:connect, args}, state) do - realtime_rls_settings = Database.from_settings(args, "realtime_rls") - {:ok, conn} = Database.connect_db(realtime_rls_settings) - state = Map.put(state, :conn, conn) - {:noreply, state, {:continue, :prepare}} + def handle_continue({:connect, tenant}, state) do + with {:ok, realtime_rls_settings} <- Database.from_tenant(tenant, "realtime_rls"), + {:ok, conn} <- Database.connect_db(realtime_rls_settings) do + {:noreply, Map.put(state, :conn, conn), {:continue, :prepare}} + else + {:error, reason} -> + log_error("ReplicationPollerConnectionFailed", reason) + {:stop, {:shutdown, reason}, state} + end end def handle_continue(:prepare, state) do - {:noreply, prepare_replication(state)} + prepare_replication(state) end @impl true + def handle_info(:poll, %{oids: oids, poll_ref: poll_ref} = state) when map_size(oids) == 0 do + cancel_timer(poll_ref) + {:noreply, %{state | poll_ref: nil}} + end + def handle_info( :poll, %{ @@ -74,7 +159,9 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do max_record_bytes: max_record_bytes, max_changes: max_changes, conn: conn, - tenant_id: tenant_id + tenant_id: tenant_id, + subscribers_nodes_table: subscribers_nodes_table, + rate_counter_args: rate_counter_args } = state ) do cancel_timer(poll_ref) @@ -84,28 +171,43 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do {time, list_changes} = :timer.tc(Replications, :list_changes, args) record_list_changes_telemetry(time, tenant_id) - case handle_list_changes_result(list_changes, tenant_id) do - {:ok, row_count} -> - Backoff.reset(backoff) + case handle_list_changes_result(list_changes, subscribers_nodes_table, tenant_id, rate_counter_args) do + {:ok, {processed_count, slot_changes_count}} -> + backoff = Backoff.reset(backoff) pool_ref = - if row_count > 0 do - send(self(), :poll) - nil - else - Process.send_after(self(), :poll, poll_interval_ms) + cond do + processed_count > 0 -> + send(self(), :poll) + nil + + slot_changes_count > 0 -> + jitter = Enum.random(50..100) + Process.send_after(self(), :poll, poll_interval_ms + jitter) + + true -> + Process.send_after(self(), :poll, poll_interval_ms * @idle_multiplier) end - {:noreply, %{state | backoff: backoff, poll_ref: pool_ref}} + {:noreply, %{state | backoff: backoff, poll_ref: pool_ref, retry_count: 0}} - {:error, %Postgrex.Error{postgres: %{code: :object_in_use, message: msg}}} -> + {:error, %Postgrex.Error{postgres: %{code: :object_in_use, message: msg}} = slot_error} -> log_error("ReplicationSlotBeingUsed", msg) [_, db_pid] = Regex.run(~r/PID\s(\d*)$/, msg) db_pid = String.to_integer(db_pid) - {:ok, diff} = Replications.get_pg_stat_activity_diff(conn, db_pid) + Realtime.Telemetry.execute([:realtime, :replication, :poller, :query, :exception], %{}, %{ + tenant: tenant_id, + reason: :object_in_use + }) + + case Replications.get_pg_stat_activity_diff(conn, db_pid) do + {:ok, diff} -> + Logger.warning("Database PID #{db_pid} found in pg_stat_activity with state_change diff of #{diff}") - Logger.warning("Database PID #{db_pid} found in pg_stat_activity with state_change diff of #{diff}") + {:error, reason} -> + log_error("PgStatActivityQueryFailed", reason) + end if retry_count > 3 do case Replications.terminate_backend(conn, slot_name) do @@ -115,25 +217,78 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do end end - {timeout, backoff} = Backoff.backoff(backoff) - retry_ref = Process.send_after(self(), :retry, timeout) - - {:noreply, %{state | backoff: backoff, retry_ref: retry_ref, retry_count: retry_count + 1}} + retry_or_stop(state, slot_error) {:error, reason} -> log_error("PoolingReplicationError", reason) - {timeout, backoff} = Backoff.backoff(backoff) - retry_ref = Process.send_after(self(), :retry, timeout) + Realtime.Telemetry.execute([:realtime, :replication, :poller, :query, :exception], %{}, %{ + tenant: tenant_id, + reason: reason + }) - {:noreply, %{state | backoff: backoff, retry_ref: retry_ref, retry_count: retry_count + 1}} + retry_or_stop(state, reason) end end @impl true def handle_info(:retry, %{retry_ref: retry_ref} = state) do cancel_timer(retry_ref) - {:noreply, prepare_replication(state)} + prepare_replication(state) + end + + def handle_info(:check_oids, %{conn: conn, publication: publication} = state) do + case Subscriptions.fetch_publication_tables(conn, publication) do + {:ok, new_oids} -> + check_oids(new_oids, state) + + {:error, reason} -> + log_error("CheckOidsError", reason) + cancel_timer(state.check_oid_ref) + {:noreply, %{state | check_oid_ref: schedule_check_oids()}} + end + end + + defp check_oids(new_oids, %{conn: conn, oids: old_oids} = state) do + case {map_size(old_oids), map_size(new_oids)} do + {0, n} when n > 0 -> + Logger.info("ReplicationPoller's publication went from 0 to #{n} tables, starting replication") + # prepare_replication/1 cancels check_oid_ref and reschedules it on success. + prepare_replication(%{state | oids: new_oids}) + + {n, 0} when n > 0 -> + Logger.info("ReplicationPoller's publication went from #{n} to 0 tables, stopping replication") + cancel_timer(state.poll_ref) + # Cancel any pending :retry too: a retry left over from a prior + # list_changes/5 error would otherwise fire after the slot is dropped and + # re-run prepare_replication/1, recreating work (and possibly the slot). + cancel_timer(state.retry_ref) + + case Replications.drop_replication_slot(conn, state.slot_name) do + {:error, reason} when reason != :slot_not_found -> + # The slot is a temporary logical replication slot tied to this connection, + # so stopping the process releases it without leaking WAL. + log_error("DropReplicationSlotFailed", reason) + {:stop, {:shutdown, :drop_replication_slot_failed}, state} + + _ -> + cancel_timer(state.check_oid_ref) + + {:noreply, + %{ + state + | oids: new_oids, + poll_ref: nil, + retry_ref: nil, + retry_count: 0, + check_oid_ref: schedule_check_oids() + }} + end + + _ -> + cancel_timer(state.check_oid_ref) + {:noreply, %{state | oids: new_oids, check_oid_ref: schedule_check_oids()}} + end end def slot_name_suffix do @@ -147,21 +302,72 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do defp convert_errors(_), do: nil - defp prepare_replication(%{backoff: backoff, conn: conn, slot_name: slot_name, retry_count: retry_count} = state) do - case Replications.prepare_replication(conn, slot_name) do - {:ok, _} -> - send(self(), :poll) - state + defp prepare_replication( + %{ + conn: conn, + slot_name: slot_name, + tenant_id: tenant_id, + publication: publication, + check_oid_ref: check_oid_ref + } = state + ) do + # Always fetch fresh publication information. An empty publication fails the + # map_size guard and falls through to the idle branch in `else`. + with {:ok, oids} when map_size(oids) > 0 <- Subscriptions.fetch_publication_tables(conn, publication), + {:ok, _} <- Replications.prepare_replication(conn, slot_name) do + send(self(), :poll) + + cancel_timer(check_oid_ref) + # A successful prepare ends the failure streak: drop any pending retry and + # reset the backoff/retry_count so the next :poll error starts fresh rather + # than inheriting an inflated backoff or prematurely hitting @max_retries. + cancel_timer(state.retry_ref) + + {:noreply, + %{ + state + | oids: oids, + check_oid_ref: schedule_check_oids(), + retry_ref: nil, + retry_count: 0, + backoff: Backoff.reset(state.backoff) + }} + else + {:ok, oids} -> + # Empty publication: don't create a slot (it would retain WAL with nothing + # to consume it). Wait for :check_oids to observe tables appearing. + cancel_timer(check_oid_ref) + {:noreply, %{state | oids: oids, check_oid_ref: schedule_check_oids()}} {:error, error} -> log_error("PoolingReplicationPreparationError", error) - {timeout, backoff} = Backoff.backoff(backoff) - retry_ref = Process.send_after(self(), :retry, timeout) - %{state | backoff: backoff, retry_ref: retry_ref, retry_count: retry_count + 1} + Realtime.Telemetry.execute([:realtime, :replication, :poller, :prepare, :exception], %{}, %{ + tenant: tenant_id, + reason: error + }) + + retry_or_stop(state, error) end end + # Schedules another :retry with exponential backoff, or stops the poller once + # @max_retries consecutive failures have been reached. Stopping with a + # :shutdown reason means the :transient child is NOT restarted (same as the + # DB-connection-failure path), so the tenant's CDC workers wind down cleanly. + defp retry_or_stop(%{retry_count: retry_count} = state, reason) when retry_count >= @max_retries do + log_error("ReplicationPollerMaxRetriesReached", reason) + {:stop, {:shutdown, :max_retries_reached}, state} + end + + defp retry_or_stop(%{backoff: backoff, retry_count: retry_count} = state, _reason) do + {timeout, backoff} = Backoff.backoff(backoff) + retry_ref = Process.send_after(self(), :retry, timeout) + {:noreply, %{state | backoff: backoff, retry_ref: retry_ref, retry_count: retry_count + 1}} + end + + defp schedule_check_oids, do: Process.send_after(self(), :check_oids, @check_oids_interval) + defp record_list_changes_telemetry(time, tenant_id) do Realtime.Telemetry.execute( [:realtime, :replication, :poller, :query, :stop], @@ -173,95 +379,171 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do defp handle_list_changes_result( {:ok, %Postgrex.Result{ - columns: ["wal", "is_rls_enabled", "subscription_ids", "errors"] = columns, - rows: [_ | _] = rows, - num_rows: rows_count + columns: columns, + rows: [_ | _] = rows }}, - tenant_id + subscribers_nodes_table, + tenant_id, + rate_counter_args ) do - for row <- rows, - change <- columns |> Enum.zip(row) |> generate_record() |> List.wrap() do - topic = "realtime:postgres:" <> tenant_id + expected_columns = @expected_columns + ^expected_columns = columns + + # The DB function always returns at least one row (sentinel row with wal=null). + # All rows carry the same slot_changes_count in the last column. + slot_changes_count = rows |> List.first() |> List.last() + + # The sentinel only appears when there are no real rows (see list_changes SQL). + # So either all rows are real, or the sole row is the sentinel — check once. + real_rows = + case rows do + [[nil | _] | _] -> [] + _ -> rows + end + + case RateCounter.get(rate_counter_args) do + {:ok, %{limit: %{triggered: true}}} -> + if real_rows != [] do + Realtime.Telemetry.execute( + [:realtime, :replication, :poller, :changes, :skip], + %{count: length(real_rows)}, + %{tenant: tenant_id, reason: :rate_limited} + ) + end - RealtimeWeb.TenantBroadcaster.pubsub_broadcast(tenant_id, topic, change, MessageDispatcher) + :ok + + _ -> + topic = "realtime:postgres:" <> tenant_id + + for row <- real_rows, + change <- row |> generate_record() |> List.wrap() do + Realtime.GenCounter.add(rate_counter_args.id, MapSet.size(change.subscription_ids)) + + payload = Jason.encode!(change) + + case collect_subscription_nodes(subscribers_nodes_table, change.subscription_ids) do + {:ok, nodes} -> + for {node, subscription_ids} <- nodes do + TenantBroadcaster.pubsub_direct_broadcast( + node, + tenant_id, + topic, + # Send only the subscription IDs relevant to this node + {change.type, payload, MapSet.new(subscription_ids)}, + MessageDispatcher, + :postgres_changes + ) + end + + {:error, :node_not_found} -> + TenantBroadcaster.pubsub_broadcast( + tenant_id, + topic, + {change.type, payload, change.subscription_ids}, + MessageDispatcher, + :postgres_changes + ) + end + end end - {:ok, rows_count} + {:ok, {length(real_rows), slot_changes_count}} end - defp handle_list_changes_result({:ok, _}, _), do: {:ok, 0} - defp handle_list_changes_result({:error, reason}, _), do: {:error, reason} + defp handle_list_changes_result({:ok, _}, _, _, _), do: {:ok, {0, 0}} + defp handle_list_changes_result({:error, reason}, _, _, _), do: {:error, reason} + + defp collect_subscription_nodes(subscribers_nodes_table, subscription_ids) do + Enum.reduce_while(subscription_ids, {:ok, %{}}, fn subscription_id, {:ok, acc} -> + case :ets.lookup_element(subscribers_nodes_table, subscription_id, 2, :not_found) do + :not_found -> + {:halt, {:error, :node_not_found}} + + node -> + updated_acc = + Map.update(acc, node, [subscription_id], fn existing_ids -> [subscription_id | existing_ids] end) + + {:cont, {:ok, updated_acc}} + end + end) + rescue + _ -> {:error, :node_not_found} + end def generate_record([ - {"wal", - %{ - "type" => "INSERT" = type, - "schema" => schema, - "table" => table - } = wal}, - {"is_rls_enabled", _}, - {"subscription_ids", subscription_ids}, - {"errors", errors} + "INSERT" = type, + schema, + table, + columns, + record, + _old_record, + commit_timestamp, + subscription_ids, + errors, + _slot_changes_count ]) when is_list(subscription_ids) do %NewRecord{ - columns: Map.get(wal, "columns", []), - commit_timestamp: Map.get(wal, "commit_timestamp"), + columns: Jason.Fragment.new(columns), + commit_timestamp: commit_timestamp, errors: convert_errors(errors), schema: schema, table: table, type: type, subscription_ids: MapSet.new(subscription_ids), - record: Map.get(wal, "record", %{}) + record: Jason.Fragment.new(record) } end def generate_record([ - {"wal", - %{ - "type" => "UPDATE" = type, - "schema" => schema, - "table" => table - } = wal}, - {"is_rls_enabled", _}, - {"subscription_ids", subscription_ids}, - {"errors", errors} + "UPDATE" = type, + schema, + table, + columns, + record, + old_record, + commit_timestamp, + subscription_ids, + errors, + _slot_changes_count ]) when is_list(subscription_ids) do %UpdatedRecord{ - columns: Map.get(wal, "columns", []), - commit_timestamp: Map.get(wal, "commit_timestamp"), + columns: Jason.Fragment.new(columns), + commit_timestamp: commit_timestamp, errors: convert_errors(errors), schema: schema, table: table, type: type, subscription_ids: MapSet.new(subscription_ids), - old_record: Map.get(wal, "old_record", %{}), - record: Map.get(wal, "record", %{}) + old_record: Jason.Fragment.new(old_record), + record: Jason.Fragment.new(record) } end def generate_record([ - {"wal", - %{ - "type" => "DELETE" = type, - "schema" => schema, - "table" => table - } = wal}, - {"is_rls_enabled", _}, - {"subscription_ids", subscription_ids}, - {"errors", errors} + "DELETE" = type, + schema, + table, + columns, + _record, + old_record, + commit_timestamp, + subscription_ids, + errors, + _slot_changes_count ]) when is_list(subscription_ids) do %DeletedRecord{ - columns: Map.get(wal, "columns", []), - commit_timestamp: Map.get(wal, "commit_timestamp"), + columns: Jason.Fragment.new(columns), + commit_timestamp: commit_timestamp, errors: convert_errors(errors), schema: schema, table: table, type: type, subscription_ids: MapSet.new(subscription_ids), - old_record: Map.get(wal, "old_record", %{}) + old_record: Jason.Fragment.new(old_record) } end diff --git a/lib/extensions/postgres_cdc_rls/replications.ex b/lib/extensions/postgres_cdc_rls/replications.ex index 16b4f997d..b36c89bce 100644 --- a/lib/extensions/postgres_cdc_rls/replications.ex +++ b/lib/extensions/postgres_cdc_rls/replications.ex @@ -32,6 +32,9 @@ defmodule Extensions.PostgresCdcRls.Replications do query(conn, "select active_pid from pg_replication_slots where slot_name = $1", [slot_name]) case slots do + {:ok, %Postgrex.Result{rows: [[nil]]}} -> + {:error, :slot_not_found} + {:ok, %Postgrex.Result{rows: [[backend]]}} -> case query(conn, "select pg_terminate_backend($1)", [backend]) do {:ok, _resp} -> {:ok, :terminated} @@ -46,6 +49,20 @@ defmodule Extensions.PostgresCdcRls.Replications do end end + @spec drop_replication_slot(pid(), String.t()) :: + {:ok, :dropped} | {:error, :slot_not_found | Postgrex.Error.t()} + def drop_replication_slot(conn, slot_name) do + case query( + conn, + "select pg_drop_replication_slot(slot_name) from pg_replication_slots where slot_name = $1", + [slot_name] + ) do + {:ok, %Postgrex.Result{num_rows: 0}} -> {:error, :slot_not_found} + {:ok, _} -> {:ok, :dropped} + {:error, error} -> {:error, error} + end + end + @spec get_pg_stat_activity_diff(pid(), integer()) :: {:ok, integer()} | {:error, Postgrex.Error.t()} def get_pg_stat_activity_diff(conn, db_pid) do @@ -61,18 +78,28 @@ defmodule Extensions.PostgresCdcRls.Replications do ) case query do - {:ok, %{rows: [[diff]]}} -> - {:ok, diff} - - {:error, error} -> - {:error, error} + {:ok, %{rows: [[diff]]}} -> {:ok, diff} + {:ok, _} -> {:error, :pid_not_found} + {:error, error} -> {:error, error} end end def list_changes(conn, slot_name, publication, max_changes, max_record_bytes) do query( conn, - "select * from realtime.list_changes($1, $2, $3, $4)", + """ + SELECT wal->>'type' as type, + wal->>'schema' as schema, + wal->>'table' as table, + COALESCE(wal->>'columns', '[]') as columns, + COALESCE(wal->>'record', '{}') as record, + COALESCE(wal->>'old_record', '{}') as old_record, + wal->>'commit_timestamp' as commit_timestamp, + subscription_ids, + errors, + slot_changes_count + FROM realtime.list_changes($1, $2, $3, $4) + """, [ publication, slot_name, diff --git a/lib/extensions/postgres_cdc_rls/subscription_manager.ex b/lib/extensions/postgres_cdc_rls/subscription_manager.ex index 2dba9912e..c6ab756ce 100644 --- a/lib/extensions/postgres_cdc_rls/subscription_manager.ex +++ b/lib/extensions/postgres_cdc_rls/subscription_manager.ex @@ -10,6 +10,8 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do alias Realtime.Database alias Realtime.Helpers + alias Realtime.GenRpc + alias Realtime.Telemetry alias Rls.Subscriptions @@ -17,6 +19,7 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do @max_delete_records 1000 @check_oids_interval 60_000 @check_no_users_interval 60_000 + @check_active_pids_interval 120_000 @stop_after 60_000 * 10 defmodule State do @@ -24,23 +27,27 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do defstruct [ :id, :publication, - :subscribers_tid, + :subscribers_pids_table, + :subscribers_nodes_table, :conn, :delete_queue, :no_users_ref, no_users_ts: nil, oids: %{}, check_oid_ref: nil, + check_active_pids_ref: nil, check_region_interval: nil ] @type t :: %__MODULE__{ id: String.t(), publication: String.t(), - subscribers_tid: :ets.tid(), + subscribers_pids_table: :ets.tid(), + subscribers_nodes_table: :ets.tid(), conn: Postgrex.conn(), oids: map(), check_oid_ref: reference() | nil, + check_active_pids_ref: reference() | nil, delete_queue: %{ ref: reference(), queue: :queue.queue() @@ -67,49 +74,78 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do @impl true def handle_continue({:connect, args}, _) do - %{"id" => id, "publication" => publication, "subscribers_tid" => subscribers_tid} = args + %{ + "id" => id, + "subscribers_pids_table" => subscribers_pids_table, + "subscribers_nodes_table" => subscribers_nodes_table + } = args + + %Realtime.Api.Tenant{} = tenant = Realtime.Tenants.Cache.get_tenant_by_external_id(id) + extension = Realtime.PostgresCdc.filter_settings("postgres_cdc_rls", tenant.extensions) + extension = Map.merge(extension, %{"subs_pool_size" => Map.get(extension, "subcriber_pool_size", 4)}) + + publication = extension["publication"] + + with {:ok, subscription_manager_settings} <- Database.from_settings(extension, "realtime_subscription_manager"), + {:ok, subscription_manager_pub_settings} <- + Database.from_settings(extension, "realtime_subscription_manager_pub"), + {:ok, conn} <- Database.connect_db(subscription_manager_settings), + {:ok, conn_pub} <- Database.connect_db(subscription_manager_pub_settings), + {:ok, oids} <- Subscriptions.fetch_publication_tables(conn, publication) do + # The subscribers ETS tables are owned by the WorkerSupervisor, so they survive a + # SubscriptionManager-only restart. An empty pids table means a cold start (fresh + # WorkerSupervisor): clear any stale DB rows. + # A non-empty table means a warm manager restart: the DB + ETS state is + # still valid, so re-adopt it by rebuilding the monitors (the only thing lost with the + # previous manager) instead of wiping everyone out. + case :ets.info(subscribers_pids_table, :size) do + 0 -> + Subscriptions.delete_all_if_table_exists(conn) - subscription_manager_settings = Database.from_settings(args, "realtime_subscription_manager") - - subscription_manager_pub_settings = - Database.from_settings(args, "realtime_subscription_manager_pub") - - {:ok, conn} = Database.connect_db(subscription_manager_settings) - {:ok, conn_pub} = Database.connect_db(subscription_manager_pub_settings) - {:ok, _} = Subscriptions.maybe_delete_all(conn) - - Rls.update_meta(id, self(), conn_pub) - - oids = Subscriptions.fetch_publication_tables(conn, publication) - - check_region_interval = Map.get(args, :check_region_interval, rebalance_check_interval_in_ms()) - send_region_check_message(check_region_interval) - - state = %State{ - id: id, - conn: conn, - publication: publication, - subscribers_tid: subscribers_tid, - oids: oids, - delete_queue: %{ - ref: check_delete_queue(), - queue: :queue.new() - }, - no_users_ref: check_no_users(), - check_region_interval: check_region_interval - } + _ -> + readopt_monitors(subscribers_pids_table) + end - send(self(), :check_oids) - {:noreply, state} + Rls.update_meta(id, self(), conn_pub) + + check_region_interval = Map.get(args, :check_region_interval, rebalance_check_interval_in_ms()) + send_region_check_message(check_region_interval) + + state = + %State{ + id: id, + conn: conn, + publication: publication, + subscribers_pids_table: subscribers_pids_table, + subscribers_nodes_table: subscribers_nodes_table, + oids: oids, + delete_queue: %{ + ref: check_delete_queue(), + queue: :queue.new() + }, + no_users_ref: check_no_users(), + check_active_pids_ref: check_active_pids(), + check_region_interval: check_region_interval + } + + send(self(), :check_oids) + {:noreply, state} + else + {:error, reason} -> + log_error("SubscriptionManagerConnectionFailed", reason) + {:stop, {:shutdown, reason}, nil} + end end @impl true def handle_info({:subscribed, {pid, id}}, state) do - case :ets.match(state.subscribers_tid, {pid, id, :"$1", :_}) do - [] -> :ets.insert(state.subscribers_tid, {pid, id, Process.monitor(pid), node(pid)}) + case :ets.match(state.subscribers_pids_table, {pid, id, :"$1", :_}) do + [] -> :ets.insert(state.subscribers_pids_table, {pid, id, Process.monitor(pid), node(pid)}) _ -> :ok end + :ets.insert(state.subscribers_nodes_table, {UUID.string_to_binary!(id), node(pid)}) + {:noreply, %{state | no_users_ts: nil}} end @@ -121,20 +157,30 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do oids = case Subscriptions.fetch_publication_tables(conn, publication) do - ^old_oids -> + {:ok, ^old_oids} -> old_oids - new_oids -> + {:ok, new_oids} -> Logger.warning("Found new oids #{inspect(new_oids, pretty: true)}") + Subscriptions.delete_all(conn) fn {pid, _id, ref, _node}, _acc -> Process.demonitor(ref, [:flush]) send(pid, :postgres_subscribe) end - |> :ets.foldl([], state.subscribers_tid) + |> :ets.foldl([], state.subscribers_pids_table) + + :ets.delete_all_objects(state.subscribers_pids_table) + :ets.delete_all_objects(state.subscribers_nodes_table) new_oids + + {:error, reason} -> + # A fetch error must not be mistaken for a publication change: keep the + # current oids and subscribers untouched, just reschedule the next check. + log_error("CheckOidsError", reason) + old_oids end {:noreply, %{state | oids: oids, check_oid_ref: check_oids()}} @@ -142,19 +188,25 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do def handle_info( {:DOWN, _ref, :process, pid, _reason}, - %State{subscribers_tid: tid, delete_queue: %{queue: q}} = state + %State{ + subscribers_pids_table: subscribers_pids_table, + subscribers_nodes_table: subscribers_nodes_table, + delete_queue: %{queue: q} + } = state ) do q1 = - case :ets.take(tid, pid) do + case :ets.take(subscribers_pids_table, pid) do [] -> q values -> for {_pid, id, _ref, _node} <- values, reduce: q do acc -> - id - |> UUID.string_to_binary!() - |> :queue.in(acc) + bin_id = UUID.string_to_binary!(id) + + :ets.delete(subscribers_nodes_table, bin_id) + + :queue.in(bin_id, acc) end end @@ -187,11 +239,19 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do {:noreply, %{state | delete_queue: %{ref: ref, queue: q1}}} end - def handle_info(:check_no_users, %{subscribers_tid: tid, no_users_ts: ts} = state) do + def handle_info(:check_no_users, %{subscribers_pids_table: tid, no_users_ts: ts} = state) do Helpers.cancel_timer(state.no_users_ref) + subscribers = :ets.info(tid, :size) + + Realtime.Telemetry.execute( + [:realtime, :subscriptions, :manager, :subscribers], + %{count: subscribers}, + %{tenant: state.id} + ) + ts_new = - case {:ets.info(tid, :size), ts != nil && ts + @stop_after < now()} do + case {subscribers, ts != nil && ts + @stop_after < now()} do {0, true} -> Logger.info("Stop tenant #{state.id} because of no connected users") Rls.handle_stop(state.id, 15_000) @@ -223,6 +283,34 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do end end + def handle_info( + :check_active_pids, + %State{check_active_pids_ref: ref, delete_queue: delete_queue, id: id} = state + ) do + Helpers.cancel_timer(ref) + + ids = + state.subscribers_pids_table + |> subscribers_by_node() + |> not_alive_pids_dist() + |> pop_not_alive_pids(state.subscribers_pids_table, state.subscribers_nodes_table, id) + + new_delete_queue = + if length(ids) > 0 do + q = + Enum.reduce(ids, delete_queue.queue, fn id, acc -> + if :queue.member(id, acc), do: acc, else: :queue.in(id, acc) + end) + + Helpers.cancel_timer(delete_queue.ref) + %{ref: check_delete_queue(1_000), queue: q} + else + delete_queue + end + + {:noreply, %{state | check_active_pids_ref: check_active_pids(), delete_queue: new_delete_queue}} + end + def handle_info(msg, state) do log_error("UnhandledProcessMessage", msg) @@ -231,8 +319,97 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do ## Internal functions + # Warm restart: re-adopt the subscribers that survived in ETS. + # + # The previous manager's monitors died with it, so the new one re-monitors every surviving pid + # (refreshing the ref stored in ETS, which the :check_oids path uses to demonitor). A pid that + # died during the downtime makes Process.monitor/1 deliver :DOWN immediately, so the existing + # :DOWN handler cleans it up — self-healing. + # + # We deliberately do not reconcile the DB against ETS here: orphan DB rows are hygiene rather + # than correctness (the poller falls back to a cluster-wide broadcast on :node_not_found instead + # of dropping changes), and any DB-vs-ETS diff would scale with the tenant's total subscription + # count on its own database right at restart. Orphans are cleared by the cold-start / OID-change + # wipe instead. + @spec readopt_monitors(:ets.tid()) :: :ok + defp readopt_monitors(subscribers_pids_table) do + subscribers_pids_table + |> :ets.tab2list() + |> Enum.each(fn {pid, id, old_ref, node} -> + new_ref = Process.monitor(pid) + :ets.delete_object(subscribers_pids_table, {pid, id, old_ref, node}) + :ets.insert(subscribers_pids_table, {pid, id, new_ref, node}) + end) + end + + @spec pop_not_alive_pids([pid()], :ets.tid(), :ets.tid(), binary()) :: [Ecto.UUID.t()] + def pop_not_alive_pids(pids, subscribers_pids_table, subscribers_nodes_table, tenant_id) do + Enum.reduce(pids, [], fn pid, acc -> + case :ets.lookup(subscribers_pids_table, pid) do + [] -> + Telemetry.execute( + [:realtime, :subscriptions, :manager, :dead_pid], + %{quantity: 1}, + %{tenant: tenant_id, reason: :not_found} + ) + + acc + + results -> + for {^pid, postgres_id, _ref, _node} <- results do + Telemetry.execute( + [:realtime, :subscriptions, :manager, :dead_pid], + %{quantity: 1}, + %{tenant: tenant_id, reason: :phantom} + ) + + :ets.delete(subscribers_pids_table, pid) + bin_id = UUID.string_to_binary!(postgres_id) + + :ets.delete(subscribers_nodes_table, bin_id) + bin_id + end ++ acc + end + end) + end + + @spec subscribers_by_node(:ets.tid()) :: %{node() => MapSet.t(pid())} + def subscribers_by_node(tid) do + fn {pid, _postgres_id, _ref, node}, acc -> + set = if Map.has_key?(acc, node), do: MapSet.put(acc[node], pid), else: MapSet.new([pid]) + + Map.put(acc, node, set) + end + |> :ets.foldl(%{}, tid) + end + + @spec not_alive_pids_dist(%{node() => MapSet.t(pid())}) :: [pid()] | [] + def not_alive_pids_dist(pids) do + Enum.reduce(pids, [], fn {node, pids}, acc -> + if node == node() do + acc ++ not_alive_pids(pids) + else + case GenRpc.call(node, __MODULE__, :not_alive_pids, [pids], timeout: 15_000) do + {:error, :rpc_error, _} = error -> + log_error("UnableToCheckProcessesOnRemoteNode", error) + acc + + pids -> + acc ++ pids + end + end + end) + end + + @spec not_alive_pids(MapSet.t(pid())) :: [pid()] | [] + def not_alive_pids(pids) do + Enum.reduce(pids, [], fn pid, acc -> if Process.alive?(pid), do: acc, else: [pid | acc] end) + end + defp check_oids, do: Process.send_after(self(), :check_oids, @check_oids_interval) + defp check_active_pids, do: Process.send_after(self(), :check_active_pids, @check_active_pids_interval) + defp now, do: System.system_time(:millisecond) defp check_no_users, do: Process.send_after(self(), :check_no_users, @check_no_users_interval) diff --git a/lib/extensions/postgres_cdc_rls/subscriptions.ex b/lib/extensions/postgres_cdc_rls/subscriptions.ex index c8c0eda5f..a82eb179b 100644 --- a/lib/extensions/postgres_cdc_rls/subscriptions.ex +++ b/lib/extensions/postgres_cdc_rls/subscriptions.ex @@ -4,16 +4,57 @@ defmodule Extensions.PostgresCdcRls.Subscriptions do """ use Realtime.Logs - import Postgrex, only: [transaction: 2, query: 3, rollback: 2] + import Postgrex, only: [transaction: 3, query: 3, rollback: 2] @type conn() :: Postgrex.conn() + @type filter :: {binary, binary, binary} + @type subscription_params :: + {action_filter :: binary, schema :: binary, table :: binary, [filter], selected_columns :: [binary] | nil} + @type subscription_list :: [ + %{id: binary, claims: map, subscription_params: subscription_params} + ] @filter_types ["eq", "neq", "lt", "lte", "gt", "gte", "in"] - @spec create(conn(), String.t(), [map()], pid(), pid()) :: + @spec create(conn(), String.t(), subscription_list, pid(), pid()) :: {:ok, Postgrex.Result.t()} - | {:error, Exception.t() | :malformed_subscription_params | {:subscription_insert_failed, map()}} - def create(conn, publication, params_list, manager, caller) do + | {:error, Exception.t() | {:exit, term} | {:subscription_insert_failed, String.t()}} + + def create(conn, publication, subscription_list, manager, caller) do + opts = [timeout: 10_000] + + transaction( + conn, + fn conn -> + Enum.map(subscription_list, fn %{id: id, claims: claims, subscription_params: params} -> + case query(conn, publication, id, claims, params) do + {:ok, %{num_rows: num} = result} when num > 0 -> + send(manager, {:subscribed, {caller, id}}) + result + + {:ok, _} -> + msg = + "Unable to subscribe to changes with given parameters. Please check Realtime is enabled for the given connect parameters: [#{params_to_log(params)}]" + + rollback(conn, {:subscription_insert_failed, msg}) + + {:error, exception} -> + msg = + "Unable to subscribe to changes with given parameters. An exception happened so please check your connect parameters: [#{params_to_log(params)}]. Exception: #{Exception.message(exception)}" + + rollback(conn, {:subscription_insert_failed, msg}) + end + end) + end, + opts + ) + rescue + e in DBConnection.ConnectionError -> {:error, e} + catch + :exit, reason -> {:error, {:exit, reason}} + end + + defp query(conn, publication, id, claims, subscription_params) do sql = "with sub_tables as ( select rr.entity @@ -25,77 +66,72 @@ defmodule Extensions.PostgresCdcRls.Subscriptions do ) rr where pub.pubname = $1 - and pub.schemaname like (case $2 when '*' then '%' else $2 end) - and pub.tablename like (case $3 when '*' then '%' else $3 end) + and pub.schemaname like (case $2 when '*' then '%' else $2 end) escape '' + and pub.tablename like (case $3 when '*' then '%' else $3 end) escape '' ) insert into realtime.subscription as x( subscription_id, entity, filters, - claims + claims, + action_filter, + selected_columns ) select $4::text::uuid, sub_tables.entity, $6, - $5 + $5, + $7, + $8 from sub_tables on conflict - (subscription_id, entity, filters) + -- coalesce needed: NULL != NULL in unique constraints; NULL selected_columns means all columns + (subscription_id, entity, filters, action_filter, coalesce(selected_columns, '{}')) do update set claims = excluded.claims, created_at = now() returning id" - - transaction(conn, fn conn -> - Enum.map(params_list, fn %{id: id, claims: claims, params: params} -> - case parse_subscription_params(params) do - {:ok, [schema, table, filters]} -> - case query(conn, sql, [publication, schema, table, id, claims, filters]) do - {:ok, %{num_rows: num} = result} when num > 0 -> - send(manager, {:subscribed, {caller, id}}) - result - - {:ok, _} -> - msg = - "Unable to subscribe to changes with given parameters. Please check Realtime is enabled for the given connect parameters: [#{params_to_log(params)}]" - - rollback(conn, msg) - - {:error, exception} -> - msg = - "Unable to subscribe to changes with given parameters. An exception happened so please check your connect parameters: [#{params_to_log(params)}]. Exception: #{Exception.message(exception)}" - - rollback(conn, msg) - end - - {:error, reason} -> - rollback(conn, reason) - end - end) - end) + {action_filter, schema, table, filters, selected_columns} = subscription_params + query(conn, sql, [publication, schema, table, id, claims, filters, action_filter, selected_columns]) end - defp params_to_log(map) do - map - |> Map.to_list() + defp params_to_log({action_filter, schema, table, filters, selected_columns}) do + [event: action_filter, schema: schema, table: table, filters: filters, select: selected_columns] |> Enum.map_join(", ", fn {k, v} -> "#{k}: #{to_log(v)}" end) end - @spec delete(conn(), String.t()) :: any() + @spec delete(conn(), String.t()) :: {:ok, Postgrex.Result.t()} | {:error, any()} def delete(conn, id) do Logger.debug("Delete subscription") sql = "delete from realtime.subscription where subscription_id = $1" - # TODO: connection can be not available - {:ok, _} = query(conn, sql, [id]) + + case query(conn, sql, [id]) do + {:error, reason} -> + log_error("SubscriptionDeletionFailed", reason) + {:error, reason} + + result -> + result + end + catch + :exit, reason -> + log_error("SubscriptionDeletionFailed", {:exit, reason}) + {:error, {:exit, reason}} end - @spec delete_all(conn()) :: {:ok, Postgrex.Result.t()} | {:error, Exception.t()} + @spec delete_all(conn()) :: :ok def delete_all(conn) do Logger.debug("Delete all subscriptions") - query(conn, "delete from realtime.subscription;", []) + + case query(conn, "delete from realtime.subscription;", []) do + {:ok, _} -> :ok + {:error, reason} -> log_error("SubscriptionDeletionFailed", reason) + end + catch + :exit, reason -> log_error("SubscriptionDeletionFailed", {:exit, reason}) end @spec delete_multi(conn(), [Ecto.UUID.t()]) :: @@ -106,11 +142,11 @@ defmodule Extensions.PostgresCdcRls.Subscriptions do query(conn, sql, [ids]) end - @spec maybe_delete_all(conn()) :: {:ok, Postgrex.Result.t()} | {:error, Exception.t()} - def maybe_delete_all(conn) do - query( - conn, - "do $$ + @spec delete_all_if_table_exists(conn()) :: :ok + def delete_all_if_table_exists(conn) do + case query( + conn, + "do $$ begin if exists ( select 1 @@ -122,17 +158,24 @@ defmodule Extensions.PostgresCdcRls.Subscriptions do delete from realtime.subscription; end if; end $$", - [] - ) + [] + ) do + {:ok, _} -> :ok + {:error, reason} -> log_error("SubscriptionCleanupFailed", reason) + end + catch + :exit, reason -> log_error("SubscriptionCleanupFailed", {:exit, reason}) end @spec fetch_publication_tables(conn(), String.t()) :: - %{ - {<<_::1>>} => [integer()], - {String.t()} => [integer()], - {String.t(), String.t()} => [integer()] - } - | %{} + {:ok, + %{ + {<<_::1>>} => [integer()], + {String.t()} => [integer()], + {String.t(), String.t()} => [integer()] + } + | %{}} + | {:error, term()} def fetch_publication_tables(conn, publication) do sql = "select schemaname, tablename, format('%I.%I', schemaname, tablename)::regclass as oid @@ -140,23 +183,25 @@ defmodule Extensions.PostgresCdcRls.Subscriptions do case query(conn, sql, [publication]) do {:ok, %{columns: ["schemaname", "tablename", "oid"], rows: rows}} -> - Enum.reduce(rows, %{}, fn [schema, table, oid], acc -> - if String.contains?(table, " ") do - log_error( - "TableHasSpacesInName", - "Table name cannot have spaces: \"#{schema}\".\"#{table}\"" - ) - end + oids = + Enum.reduce(rows, %{}, fn [schema, table, oid], acc -> + Map.put(acc, {schema, table}, [oid]) + |> Map.update({schema}, [oid], &[oid | &1]) + |> Map.update({"*"}, [oid], &[oid | &1]) + end) + |> Enum.reduce(%{}, fn {k, v}, acc -> Map.put(acc, k, Enum.sort(v)) end) - Map.put(acc, {schema, table}, [oid]) - |> Map.update({schema}, [oid], &[oid | &1]) - |> Map.update({"*"}, [oid], &[oid | &1]) - end) - |> Enum.reduce(%{}, fn {k, v}, acc -> Map.put(acc, k, Enum.sort(v)) end) + {:ok, oids} - _ -> - %{} + {:error, reason} -> + {:error, reason} + + other -> + {:error, {:unexpected_result, other}} end + catch + :exit, reason -> + {:error, {:exit, reason}} end @doc """ @@ -164,81 +209,214 @@ defmodule Extensions.PostgresCdcRls.Subscriptions do We currently support the following filters: 'eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in' + Multiple filters can be combined with commas and are applied as AND conditions: + `"col1=eq.val,col2=gt.5"` means `col1 = val AND col2 > 5`. + ## Examples - iex> params = %{"schema" => "public", "table" => "messages", "filter" => "subject=eq.hey"} - iex> Extensions.PostgresCdcRls.Subscriptions.parse_subscription_params(params) - {:ok, ["public", "messages", [{"subject", "eq", "hey"}]]} + iex> parse_subscription_params(%{"schema" => "public", "table" => "messages", "filter" => "subject=eq.hey"}) + {:ok, {"*", "public", "messages", [{"subject", "eq", "hey"}], nil}} `in` filter: - iex> params = %{"schema" => "public", "table" => "messages", "filter" => "subject=in.(hidee,ho)"} - iex> Extensions.PostgresCdcRls.Subscriptions.parse_subscription_params(params) - {:ok, ["public", "messages", [{"subject", "in", "{hidee,ho}"}]]} + iex> parse_subscription_params(%{"schema" => "public", "table" => "messages", "filter" => "subject=in.(hidee,ho)"}) + {:ok, {"*", "public", "messages", [{"subject", "in", "{hidee,ho}"}], nil}} + + AND composition — multiple filters separated by commas: + + iex> parse_subscription_params(%{"schema" => "public", "table" => "messages", "filter" => "id=gt.0,id=lt.100"}) + {:ok, {"*", "public", "messages", [{"id", "gt", "0"}, {"id", "lt", "100"}], nil}} + + empty or whitespace-only filter string is treated as no filter: + + iex> parse_subscription_params(%{"schema" => "public", "table" => "messages", "filter" => ""}) + {:ok, {"*", "public", "messages", [], nil}} + + iex> parse_subscription_params(%{"schema" => "public", "table" => "messages", "filter" => " "}) + {:ok, {"*", "public", "messages", [], nil}} + + no filter: + + iex> parse_subscription_params(%{"schema" => "public", "table" => "messages"}) + {:ok, {"*", "public", "messages", [], nil}} + + only schema: + + iex> parse_subscription_params(%{"schema" => "public"}) + {:ok, {"*", "public", "*", [], nil}} + + only table: + + iex> parse_subscription_params(%{"table" => "messages"}) + {:ok, {"*", "public", "messages", [], nil}} An unsupported filter will respond with an error tuple: - iex> params = %{"schema" => "public", "table" => "messages", "filter" => "subject=like.hey"} - iex> Extensions.PostgresCdcRls.Subscriptions.parse_subscription_params(params) + iex> parse_subscription_params(%{"schema" => "public", "table" => "messages", "filter" => "subject=like.hey"}) {:error, ~s(Error parsing `filter` params: ["like", "hey"])} Catch `undefined` filters: - iex> params = %{"schema" => "public", "table" => "messages", "filter" => "undefined"} - iex> Extensions.PostgresCdcRls.Subscriptions.parse_subscription_params(params) + iex> parse_subscription_params(%{"schema" => "public", "table" => "messages", "filter" => "undefined"}) {:error, ~s(Error parsing `filter` params: ["undefined"])} + Catch `missing params`: + + iex> parse_subscription_params(%{}) + {:error, ~s(No subscription params provided. Please provide at least a `schema` or `table` to subscribe to: %{})} + """ - @spec parse_subscription_params(map()) :: {:ok, list} | {:error, binary()} + @spec parse_subscription_params(map()) :: {:ok, subscription_params} | {:error, binary()} def parse_subscription_params(params) do - case params do - %{"schema" => schema, "table" => table, "filter" => filter} -> - with [col, rest] <- String.split(filter, "=", parts: 2), - [filter_type, value] when filter_type in @filter_types <- - String.split(rest, ".", parts: 2), - {:ok, formatted_value} <- format_filter_value(filter_type, value) do - {:ok, [schema, table, [{col, filter_type, formatted_value}]]} - else - {:error, msg} -> - {:error, "Error parsing `filter` params: #{msg}"} + action_filter = action_filter(params) + + with {:ok, selected_columns} <- parse_select(params) do + case params do + %{"schema" => schema, "table" => table, "filter" => filter} + when is_binary(schema) and is_binary(table) and is_binary(filter) -> + case parse_filters(filter) do + {:ok, filters} -> + case reject_select_on_wildcard(schema, table, selected_columns) do + :ok -> {:ok, {action_filter, schema, table, filters, selected_columns}} + error -> error + end + + {:error, reason} -> + {:error, "Error parsing `filter` params: #{reason}"} + end - e -> - {:error, "Error parsing `filter` params: #{inspect(e)}"} - end + %{"schema" => schema, "table" => table} + when is_binary(schema) and is_binary(table) and not is_map_key(params, "filter") -> + case reject_select_on_wildcard(schema, table, selected_columns) do + :ok -> {:ok, {action_filter, schema, table, [], selected_columns}} + error -> error + end - %{"schema" => schema, "table" => table} -> - {:ok, [schema, table, []]} + %{"schema" => schema} + when is_binary(schema) and not is_map_key(params, "table") and + not is_map_key(params, "filter") -> + case reject_select_on_wildcard(schema, "*", selected_columns) do + :ok -> {:ok, {action_filter, schema, "*", [], selected_columns}} + error -> error + end - %{"schema" => schema} -> - {:ok, [schema, "*", []]} + %{"table" => table} + when is_binary(table) and not is_map_key(params, "schema") and + not is_map_key(params, "filter") -> + case reject_select_on_wildcard("public", table, selected_columns) do + :ok -> {:ok, {action_filter, "public", table, [], selected_columns}} + error -> error + end - %{"table" => table} -> - {:ok, ["public", table, []]} + map when is_map_key(map, "user_token") or is_map_key(map, "auth_token") -> + {:error, + "No subscription params provided. Please provide at least a `schema` or `table` to subscribe to: "} - map when is_map_key(map, "user_token") or is_map_key(map, "auth_token") -> - {:error, - "No subscription params provided. Please provide at least a `schema` or `table` to subscribe to: "} + error -> + {:error, + "No subscription params provided. Please provide at least a `schema` or `table` to subscribe to: #{inspect(error)}"} + end + end + end - error -> - {:error, - "No subscription params provided. Please provide at least a `schema` or `table` to subscribe to: #{inspect(error)}"} + defp parse_select(%{"select" => cols}) when is_list(cols) do + case Enum.filter(cols, &is_binary/1) do + [] -> {:ok, nil} + valid -> {:ok, valid} end end - defp format_filter_value(filter, value) do - case filter do - "in" -> - case Regex.run(~r/^\((.*)\)$/, value) do - nil -> - {:error, "`in` filter value must be wrapped by parentheses"} + defp parse_select(%{"select" => str}) when is_binary(str) do + {:error, "Error parsing `select` params: expected a list of column name strings, e.g. select: [\"col1\", \"col2\"]"} + end + + defp parse_select(_), do: {:ok, nil} - [_, new_value] -> - {:ok, "{#{new_value}}"} + defp reject_select_on_wildcard(_schema, _table, nil), do: :ok + + defp reject_select_on_wildcard(schema, table, _selected_columns) + when schema == "*" or table == "*" do + {:error, "Column selection is not supported for wildcard subscriptions. Provide an explicit schema and table name."} + end + + defp reject_select_on_wildcard(_schema, _table, _selected_columns), do: :ok + + defp action_filter(%{"event" => "*"}), do: "*" + + defp action_filter(%{"event" => event}) when is_binary(event) do + case String.upcase(event) do + "INSERT" -> "INSERT" + "UPDATE" -> "UPDATE" + "DELETE" -> "DELETE" + _ -> "*" + end + end + + defp action_filter(_), do: "*" + + defp parse_filters(filter) when is_binary(filter) do + case String.trim(filter) do + "" -> {:ok, []} + trimmed -> scan(trimmed, trimmed, 0, 0, 0, []) + end + end + + # Reached end of binary — parse the final segment + defp scan(<<>>, orig, start, len, _depth, acc) do + case parse_segment(binary_part(orig, start, len)) do + {:ok, parsed} -> {:ok, Enum.reverse([parsed | acc])} + {:error, _} = e -> e + end + end + + defp scan(<<"(", rest::binary>>, orig, start, len, depth, acc) do + scan(rest, orig, start, len + 1, depth + 1, acc) + end + + defp scan(<<")", rest::binary>>, orig, start, len, depth, acc) do + scan(rest, orig, start, len + 1, max(0, depth - 1), acc) + end + + # Comma at depth 0 — segment boundary + defp scan(<<",", rest::binary>>, orig, start, len, 0, acc) do + case parse_segment(binary_part(orig, start, len)) do + {:ok, parsed} -> scan(rest, orig, start + len + 1, 0, 0, [parsed | acc]) + {:error, _} = e -> e + end + end + + defp scan(<<_::8, rest::binary>>, orig, start, len, depth, acc) do + scan(rest, orig, start, len + 1, depth, acc) + end + + defp parse_segment(segment) do + case String.trim(segment) do + "" -> + {:error, "filter must not contain empty segments (check for extra commas)"} + + trimmed -> + with [col, rest] <- String.split(trimmed, "=", parts: 2), + [filter_type, value] when filter_type in @filter_types <- + String.split(rest, ".", parts: 2), + {:ok, formatted_value} <- format_filter_value(filter_type, value) do + {:ok, {col, filter_type, formatted_value}} + else + {:error, msg} -> {:error, msg} + e -> {:error, inspect(e)} end + end + end - _ -> - {:ok, value} + defp format_filter_value("in", value) do + size = byte_size(value) + + if size >= 2 and binary_part(value, 0, 1) == "(" and binary_part(value, size - 1, 1) == ")" do + {:ok, "{" <> binary_part(value, 1, size - 2) <> "}"} + else + {:error, "`in` filter value must be wrapped by parentheses"} end end + + defp format_filter_value(_filter, value), do: {:ok, value} end diff --git a/lib/extensions/postgres_cdc_rls/subscriptions_checker.ex b/lib/extensions/postgres_cdc_rls/subscriptions_checker.ex deleted file mode 100644 index ed2b42eb5..000000000 --- a/lib/extensions/postgres_cdc_rls/subscriptions_checker.ex +++ /dev/null @@ -1,195 +0,0 @@ -defmodule Extensions.PostgresCdcRls.SubscriptionsChecker do - @moduledoc false - use GenServer - use Realtime.Logs - - alias Extensions.PostgresCdcRls, as: Rls - - alias Realtime.Database - alias Realtime.Helpers - alias Realtime.Rpc - alias Realtime.Telemetry - - alias Rls.Subscriptions - - @timeout 120_000 - @max_delete_records 1000 - - defmodule State do - @moduledoc false - defstruct [:id, :conn, :check_active_pids, :subscribers_tid, :delete_queue] - - @type t :: %__MODULE__{ - id: String.t(), - conn: Postgrex.conn(), - check_active_pids: reference(), - subscribers_tid: :ets.tid(), - delete_queue: %{ - ref: reference(), - queue: :queue.queue() - } - } - end - - @spec start_link(GenServer.options()) :: GenServer.on_start() - def start_link(opts) do - GenServer.start_link(__MODULE__, opts) - end - - ## Callbacks - - @impl true - def init(args) do - %{"id" => id} = args - Logger.metadata(external_id: id, project: id) - {:ok, nil, {:continue, {:connect, args}}} - end - - @impl true - def handle_continue({:connect, args}, _) do - %{"id" => id, "subscribers_tid" => subscribers_tid} = args - - realtime_subscription_checker_settings = - Database.from_settings(args, "realtime_subscription_checker") - - {:ok, conn} = Database.connect_db(realtime_subscription_checker_settings) - - state = %State{ - id: id, - conn: conn, - check_active_pids: check_active_pids(), - subscribers_tid: subscribers_tid, - delete_queue: %{ - ref: nil, - queue: :queue.new() - } - } - - {:noreply, state} - end - - @impl true - def handle_info( - :check_active_pids, - %State{check_active_pids: ref, subscribers_tid: tid, delete_queue: delete_queue, id: id} = - state - ) do - Helpers.cancel_timer(ref) - - ids = - tid - |> subscribers_by_node() - |> not_alive_pids_dist() - |> pop_not_alive_pids(tid, id) - - new_delete_queue = - if length(ids) > 0 do - q = - Enum.reduce(ids, delete_queue.queue, fn id, acc -> - if :queue.member(id, acc), do: acc, else: :queue.in(id, acc) - end) - - %{ - ref: check_delete_queue(), - queue: q - } - else - delete_queue - end - - {:noreply, %{state | check_active_pids: check_active_pids(), delete_queue: new_delete_queue}} - end - - def handle_info(:check_delete_queue, %State{delete_queue: %{ref: ref, queue: q}} = state) do - Helpers.cancel_timer(ref) - - new_queue = - if :queue.is_empty(q) do - q - else - {ids, q1} = Helpers.queue_take(q, @max_delete_records) - Logger.warning("Delete #{length(ids)} phantom subscribers from db") - - case Subscriptions.delete_multi(state.conn, ids) do - {:ok, _} -> - q1 - - {:error, reason} -> - log_error("UnableToDeletePhantomSubscriptions", reason) - - q - end - end - - new_ref = if :queue.is_empty(new_queue), do: ref, else: check_delete_queue() - - {:noreply, %{state | delete_queue: %{ref: new_ref, queue: new_queue}}} - end - - ## Internal functions - - @spec pop_not_alive_pids([pid()], :ets.tid(), binary()) :: [Ecto.UUID.t()] - def pop_not_alive_pids(pids, tid, tenant_id) do - Enum.reduce(pids, [], fn pid, acc -> - case :ets.lookup(tid, pid) do - [] -> - Telemetry.execute( - [:realtime, :subscriptions_checker, :pid_not_found], - %{quantity: 1}, - %{tenant_id: tenant_id} - ) - - acc - - results -> - for {^pid, postgres_id, _ref, _node} <- results do - Telemetry.execute( - [:realtime, :subscriptions_checker, :phantom_pid_detected], - %{quantity: 1}, - %{tenant_id: tenant_id} - ) - - :ets.delete(tid, pid) - UUID.string_to_binary!(postgres_id) - end ++ acc - end - end) - end - - @spec subscribers_by_node(:ets.tid()) :: %{node() => MapSet.t(pid())} - def subscribers_by_node(tid) do - fn {pid, _postgres_id, _ref, node}, acc -> - set = if Map.has_key?(acc, node), do: MapSet.put(acc[node], pid), else: MapSet.new([pid]) - - Map.put(acc, node, set) - end - |> :ets.foldl(%{}, tid) - end - - @spec not_alive_pids_dist(%{node() => MapSet.t(pid())}) :: [pid()] | [] - def not_alive_pids_dist(pids) do - Enum.reduce(pids, [], fn {node, pids}, acc -> - if node == node() do - acc ++ not_alive_pids(pids) - else - case Rpc.call(node, __MODULE__, :not_alive_pids, [pids], timeout: 15_000) do - {:badrpc, _} = error -> - log_error("UnableToCheckProcessesOnRemoteNode", error) - acc - - pids -> - acc ++ pids - end - end - end) - end - - @spec not_alive_pids(MapSet.t(pid())) :: [pid()] | [] - def not_alive_pids(pids) do - Enum.reduce(pids, [], fn pid, acc -> if Process.alive?(pid), do: acc, else: [pid | acc] end) - end - - defp check_delete_queue, do: Process.send_after(self(), :check_delete_queue, 1000) - - defp check_active_pids, do: Process.send_after(self(), :check_active_pids, @timeout) -end diff --git a/lib/extensions/postgres_cdc_rls/supervisor.ex b/lib/extensions/postgres_cdc_rls/supervisor.ex index 21e124190..fc3701aeb 100644 --- a/lib/extensions/postgres_cdc_rls/supervisor.ex +++ b/lib/extensions/postgres_cdc_rls/supervisor.ex @@ -15,7 +15,7 @@ defmodule Extensions.PostgresCdcRls.Supervisor do def init(_args) do load_migrations_modules() - :syn.add_node_to_scopes([PostgresCdcRls]) + :syn.add_node_to_scopes(Realtime.Syn.PostgresCdc.scopes()) children = [ { diff --git a/lib/extensions/postgres_cdc_rls/worker_supervisor.ex b/lib/extensions/postgres_cdc_rls/worker_supervisor.ex index 37f88014e..d276b4cae 100644 --- a/lib/extensions/postgres_cdc_rls/worker_supervisor.ex +++ b/lib/extensions/postgres_cdc_rls/worker_supervisor.ex @@ -5,8 +5,7 @@ defmodule Extensions.PostgresCdcRls.WorkerSupervisor do alias Extensions.PostgresCdcRls alias PostgresCdcRls.ReplicationPoller alias PostgresCdcRls.SubscriptionManager - alias PostgresCdcRls.SubscriptionsChecker - alias Realtime.Api + alias Realtime.Tenants.Cache alias Realtime.PostgresCdc.Exception def start_link(args) do @@ -17,28 +16,37 @@ defmodule Extensions.PostgresCdcRls.WorkerSupervisor do @impl true def init(%{"id" => tenant} = args) when is_binary(tenant) do Logger.metadata(external_id: tenant, project: tenant) - unless Api.get_tenant_by_external_id(tenant, :primary), do: raise(Exception) + unless Cache.get_tenant_by_external_id(tenant), do: raise(Exception) - tid_args = Map.merge(args, %{"subscribers_tid" => :ets.new(__MODULE__, [:public, :bag])}) + subscribers_pids_table = :ets.new(__MODULE__, [:public, :bag]) + subscribers_nodes_table = :ets.new(__MODULE__, [:public, :set]) + + tid_args = + Map.merge(args, %{ + "subscribers_pids_table" => subscribers_pids_table, + "subscribers_nodes_table" => subscribers_nodes_table + }) children = [ %{ id: ReplicationPoller, - start: {ReplicationPoller, :start_link, [args]}, - restart: :transient + start: {ReplicationPoller, :start_link, [tid_args]}, + restart: :transient, + significant: true }, %{ id: SubscriptionManager, start: {SubscriptionManager, :start_link, [tid_args]}, - restart: :transient - }, - %{ - id: SubscriptionsChecker, - start: {SubscriptionsChecker, :start_link, [tid_args]}, - restart: :transient + restart: :transient, + significant: true } ] - Supervisor.init(children, strategy: :rest_for_one, max_restarts: 10, max_seconds: 60) + Supervisor.init(children, + strategy: :one_for_one, + auto_shutdown: :any_significant, + max_restarts: 10, + max_seconds: 60 + ) end end diff --git a/lib/mix/tasks/realtime.export_tenant_db_catalog.ex b/lib/mix/tasks/realtime.export_tenant_db_catalog.ex new file mode 100644 index 000000000..af09123c4 --- /dev/null +++ b/lib/mix/tasks/realtime.export_tenant_db_catalog.ex @@ -0,0 +1,92 @@ +defmodule Mix.Tasks.Realtime.ExportTenantDbCatalog do + @shortdoc "Regenerate priv/repo/tenant_db_catalog_17.json" + + @moduledoc """ + Writes the catalog snapshot at `priv/repo/tenant_db_catalog_17.json` used by + `RealtimeWeb.Dashboard.TenantMigrations` to detect drifted DB state. + + pg-delta requires Postgres 15+, so the snapshot is generated against PG17 and + the dashboard reconciles every tenant against it. The major version is kept in + the filename so other versions can be added later if needed. + + Usage: + + mix realtime.export_tenant_db_catalog + mix realtime.export_tenant_db_catalog --pgdelta-path /path/to/pgdelta + + The target tenant DB is expected to already have all tenant migrations applied, + so make sure it is in a good state before generating it: + + mise task run db-rm + mise task run db-start + mix setup + + The target DB is read from `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USER` / `DB_PASSWORD` env vars. + + Requires `pgdelta` on `$PATH` or pass `--pgdelta-path` to force a custom path. + """ + use Mix.Task + + @catalog_path "priv/repo/tenant_db_catalog_17.json" + @catalog_filter ~s({"*/schema": "realtime"}) + + @impl Mix.Task + def run(args) do + {opts, _, _} = OptionParser.parse(args, strict: [pgdelta_path: :string]) + + url = build_url_from_env() + Mix.shell().info("[export_tenant_db_catalog] target: #{redact(url)}") + + pgdelta = pgdelta_bin!(opts[:pgdelta_path]) + Mix.shell().info("[export_tenant_db_catalog] pgdelta: #{pgdelta}") + + output = Path.expand(@catalog_path, File.cwd!()) + args = ["catalog-export", "--target", url, "--output", output, "--filter", @catalog_filter] + + case System.cmd(pgdelta, args, stderr_to_stdout: true) do + {output_str, 0} -> + validate_snapshot!(output) + Mix.shell().info(output_str) + + {output_str, code} -> + Mix.raise("pgdelta catalog-export exited #{code}:\n#{output_str}") + end + end + + defp pgdelta_bin!(nil), do: System.find_executable("pgdelta") || Mix.raise("pgdelta not found on $PATH") + + defp pgdelta_bin!(path) do + path = Path.expand(path) + System.find_executable(path) || Mix.raise("pgdelta not found or not executable at #{path}") + end + + defp build_url_from_env do + host = System.get_env("DB_HOST", "127.0.0.1") + port = System.get_env("DB_PORT", "5433") + name = System.get_env("DB_NAME", "postgres") + user = System.get_env("DB_USER", "supabase_admin") + password = System.get_env("DB_PASSWORD", "postgres") + + "postgresql://#{URI.encode_www_form(user)}:#{URI.encode_www_form(password)}@#{host}:#{port}/#{name}" + end + + defp validate_snapshot!(path) do + with {:ok, content} <- File.read(path), + {:ok, _} <- Jason.decode(content) do + :ok + else + _ -> Mix.raise("catalog snapshot at #{path} is invalid") + end + end + + defp redact(url) do + case URI.parse(url) do + %URI{userinfo: nil} = u -> + URI.to_string(u) + + %URI{userinfo: userinfo} = u -> + user = userinfo |> String.split(":", parts: 2) |> hd() + URI.to_string(%{u | userinfo: "#{user}:***"}) + end + end +end diff --git a/lib/realtime/adapters/postgres/decoder.ex b/lib/realtime/adapters/postgres/decoder.ex index e5ea161e3..065cbd62f 100644 --- a/lib/realtime/adapters/postgres/decoder.ex +++ b/lib/realtime/adapters/postgres/decoder.ex @@ -132,41 +132,27 @@ defmodule Realtime.Adapters.Postgres.Decoder do end end - require Logger - @pg_epoch DateTime.from_iso8601("2000-01-01T00:00:00Z") - alias Messages.{ - Begin, - Commit, - Origin, - Relation, - Relation.Column, - Insert, - Update, - Delete, - Truncate, - Type, - Unsupported - } + alias Messages.Begin + alias Messages.Commit + alias Messages.Origin + alias Messages.Relation + alias Messages.Relation.Column + alias Messages.Insert + alias Messages.Type + alias Messages.Unsupported alias Realtime.Adapters.Postgres.OidDatabase @doc """ Parses logical replication messages from Postgres - - ## Examples - - iex> decode_message(<<73, 0, 0, 96, 0, 78, 0, 2, 116, 0, 0, 0, 3, 98, 97, 122, 116, 0, 0, 0, 3, 53, 54, 48>>) - %Realtime.Adapters.Postgres.Decoder.Messages.Insert{relation_id: 24576, tuple_data: {"baz", "560"}} - """ - def decode_message(message) when is_binary(message) do - # Logger.debug("Message before conversion " <> message) - decode_message_impl(message) + def decode_message(message, relations) when is_binary(message) do + decode_message_impl(message, relations) end - defp decode_message_impl(<<"B", lsn::binary-8, timestamp::integer-64, xid::integer-32>>) do + defp decode_message_impl(<<"B", lsn::binary-8, timestamp::integer-64, xid::integer-32>>, _relations) do %Begin{ final_lsn: decode_lsn(lsn), commit_timestamp: pgtimestamp_to_timestamp(timestamp), @@ -174,7 +160,10 @@ defmodule Realtime.Adapters.Postgres.Decoder do } end - defp decode_message_impl(<<"C", _flags::binary-1, lsn::binary-8, end_lsn::binary-8, timestamp::integer-64>>) do + defp decode_message_impl( + <<"C", _flags::binary-1, lsn::binary-8, end_lsn::binary-8, timestamp::integer-64>>, + _relations + ) do %Commit{ flags: [], lsn: decode_lsn(lsn), @@ -184,14 +173,14 @@ defmodule Realtime.Adapters.Postgres.Decoder do end # TODO: Verify this is correct with real data from Postgres - defp decode_message_impl(<<"O", lsn::binary-8, name::binary>>) do + defp decode_message_impl(<<"O", lsn::binary-8, name::binary>>, _relations) do %Origin{ origin_commit_lsn: decode_lsn(lsn), name: name } end - defp decode_message_impl(<<"R", id::integer-32, rest::binary>>) do + defp decode_message_impl(<<"R", id::integer-32, rest::binary>>, _relations) do [ namespace | [name | [<>]] @@ -215,81 +204,22 @@ defmodule Realtime.Adapters.Postgres.Decoder do } end - defp decode_message_impl(<<"I", relation_id::integer-32, "N", number_of_columns::integer-16, tuple_data::binary>>) do - {<<>>, decoded_tuple_data} = decode_tuple_data(tuple_data, number_of_columns) - - %Insert{ - relation_id: relation_id, - tuple_data: decoded_tuple_data - } - end - - defp decode_message_impl(<<"U", relation_id::integer-32, "N", number_of_columns::integer-16, tuple_data::binary>>) do - {<<>>, decoded_tuple_data} = decode_tuple_data(tuple_data, number_of_columns) - - %Update{ - relation_id: relation_id, - tuple_data: decoded_tuple_data - } - end - defp decode_message_impl( - <<"U", relation_id::integer-32, key_or_old::binary-1, number_of_columns::integer-16, tuple_data::binary>> - ) - when key_or_old == "O" or key_or_old == "K" do - {<<"N", new_number_of_columns::integer-16, new_tuple_binary::binary>>, old_decoded_tuple_data} = - decode_tuple_data(tuple_data, number_of_columns) + <<"I", relation_id::integer-32, "N", number_of_columns::integer-16, tuple_data::binary>>, + relations + ) do + relation = relations |> get_in([relation_id, :columns]) - {<<>>, decoded_tuple_data} = decode_tuple_data(new_tuple_binary, new_number_of_columns) - - base_update_msg = %Update{ - relation_id: relation_id, - tuple_data: decoded_tuple_data - } + if relation do + {<<>>, decoded_tuple_data} = decode_tuple_data(tuple_data, number_of_columns, relation) - case key_or_old do - "K" -> Map.put(base_update_msg, :changed_key_tuple_data, old_decoded_tuple_data) - "O" -> Map.put(base_update_msg, :old_tuple_data, old_decoded_tuple_data) + %Insert{relation_id: relation_id, tuple_data: decoded_tuple_data} + else + %Unsupported{} end end - defp decode_message_impl( - <<"D", relation_id::integer-32, key_or_old::binary-1, number_of_columns::integer-16, tuple_data::binary>> - ) - when key_or_old == "K" or key_or_old == "O" do - {<<>>, decoded_tuple_data} = decode_tuple_data(tuple_data, number_of_columns) - - base_delete_msg = %Delete{ - relation_id: relation_id - } - - case key_or_old do - "K" -> Map.put(base_delete_msg, :changed_key_tuple_data, decoded_tuple_data) - "O" -> Map.put(base_delete_msg, :old_tuple_data, decoded_tuple_data) - end - end - - defp decode_message_impl(<<"T", number_of_relations::integer-32, options::integer-8, column_ids::binary>>) do - truncated_relations = - for relation_id_bin <- column_ids |> :binary.bin_to_list() |> Enum.chunk_every(4), - do: relation_id_bin |> :binary.list_to_bin() |> :binary.decode_unsigned() - - decoded_options = - case options do - 0 -> [] - 1 -> [:cascade] - 2 -> [:restart_identity] - 3 -> [:cascade, :restart_identity] - end - - %Truncate{ - number_of_relations: number_of_relations, - options: decoded_options, - truncated_relations: truncated_relations - } - end - - defp decode_message_impl(<<"Y", data_type_id::integer-32, namespace_and_name::binary>>) do + defp decode_message_impl(<<"Y", data_type_id::integer-32, namespace_and_name::binary>>, _relations) do [namespace, name_with_null] = :binary.split(namespace_and_name, <<0>>) name = String.slice(name_with_null, 0..-2//1) @@ -300,32 +230,57 @@ defmodule Realtime.Adapters.Postgres.Decoder do } end - defp decode_message_impl(binary), do: %Unsupported{data: binary} + defp decode_message_impl(binary, _relations), do: %Unsupported{data: binary} - defp decode_tuple_data(binary, columns_remaining, accumulator \\ []) + defp decode_tuple_data(binary, columns_remaining, relations, accumulator \\ []) - defp decode_tuple_data(remaining_binary, 0, accumulator) when is_binary(remaining_binary), + defp decode_tuple_data(remaining_binary, 0, _relations, accumulator) when is_binary(remaining_binary), do: {remaining_binary, accumulator |> Enum.reverse() |> List.to_tuple()} - defp decode_tuple_data(<<"n", rest::binary>>, columns_remaining, accumulator), - do: decode_tuple_data(rest, columns_remaining - 1, [nil | accumulator]) + defp decode_tuple_data(<<"n", rest::binary>>, columns_remaining, [_ | relations], accumulator), + do: decode_tuple_data(rest, columns_remaining - 1, relations, [nil | accumulator]) - defp decode_tuple_data(<<"u", rest::binary>>, columns_remaining, accumulator), - do: decode_tuple_data(rest, columns_remaining - 1, [:unchanged_toast | accumulator]) + defp decode_tuple_data(<<"u", rest::binary>>, columns_remaining, [_ | relations], accumulator), + do: decode_tuple_data(rest, columns_remaining - 1, relations, [:unchanged_toast | accumulator]) + @start_date "2000-01-01T00:00:00Z" defp decode_tuple_data( - <<"t", column_length::integer-32, rest::binary>>, + <<"b", column_length::integer-32, rest::binary>>, columns_remaining, + [%Column{type: type} | relations], accumulator - ), - do: - decode_tuple_data( - :erlang.binary_part(rest, {byte_size(rest), -(byte_size(rest) - column_length)}), - columns_remaining - 1, - [ - :erlang.binary_part(rest, {0, column_length}) | accumulator - ] - ) + ) do + data = :erlang.binary_part(rest, {0, column_length}) + remainder = :erlang.binary_part(rest, {byte_size(rest), -(byte_size(rest) - column_length)}) + + data = + case type do + "bool" -> + data == <<1>> + + "jsonb" -> + <<1, rest::binary>> = data + rest + + "timestamp" -> + <> = data + + @start_date + |> NaiveDateTime.from_iso8601!() + |> NaiveDateTime.add(microseconds, :microsecond) + + "text" -> + data + + "uuid" -> + UUID.binary_to_string!(data) + + "bytea" -> + data + end + + decode_tuple_data(remainder, columns_remaining - 1, relations, [data | accumulator]) + end defp decode_columns(binary, accumulator \\ []) defp decode_columns(<<>>, accumulator), do: Enum.reverse(accumulator) @@ -345,7 +300,6 @@ defmodule Realtime.Adapters.Postgres.Decoder do name: name, flags: decoded_flags, type: OidDatabase.name_for_type_id(data_type_id), - # type: data_type_id, type_modifier: type_modifier } | accumulator diff --git a/lib/realtime/api.ex b/lib/realtime/api.ex index 23e28feab..b8d0d2b12 100644 --- a/lib/realtime/api.ex +++ b/lib/realtime/api.ex @@ -6,15 +6,22 @@ defmodule Realtime.Api do import Ecto.Query + alias Ecto.Changeset + alias Extensions.PostgresCdcRls alias Realtime.Api.Extensions + alias Realtime.Api.FeatureFlag alias Realtime.Api.Tenant + alias Realtime.FeatureFlags alias Realtime.GenCounter + alias Realtime.GenRpc + alias Realtime.Nodes alias Realtime.RateCounter alias Realtime.Repo alias Realtime.Repo.Replica alias Realtime.Tenants + alias Realtime.Tenants.Cache alias Realtime.Tenants.Connect - alias RealtimeWeb.SocketDisconnect + alias RealtimeWeb.UserSocket defguard requires_disconnect(changeset) when changeset.valid? == true and @@ -109,33 +116,49 @@ defmodule Realtime.Api do """ def create_tenant(attrs) do Logger.debug("create_tenant #{inspect(attrs, pretty: true)}") + tenant_id = Map.get(attrs, :external_id) || Map.get(attrs, "external_id") - %Tenant{} - |> Tenant.changeset(attrs) - |> Repo.insert() + if master_region?() do + %Tenant{} + |> Tenant.changeset(attrs) + |> Repo.insert() + |> case do + {:ok, tenant} -> + Cache.global_cache_update(tenant) + {:ok, tenant} + + error -> + error + end + else + call(:create_tenant, [attrs], tenant_id: tenant_id) + end end @doc """ Updates a tenant. - - ## Examples - - iex> update_tenant(tenant, %{field: new_value}) - {:ok, %Tenant{}} - - iex> update_tenant(tenant, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ - def update_tenant(%Tenant{} = tenant, attrs) do + @spec update_tenant_by_external_id(binary(), map()) :: {:ok, Tenant.t()} | {:error, term()} + def update_tenant_by_external_id(tenant_id, attrs) when is_binary(tenant_id) do + if master_region?() do + tenant_id + |> get_tenant_by_external_id(use_replica?: false) + |> update_tenant(attrs) + else + call(:update_tenant_by_external_id, [tenant_id, attrs], tenant_id: tenant_id) + end + end + + defp update_tenant(%Tenant{} = tenant, attrs) do changeset = Tenant.changeset(tenant, attrs) updated = Repo.update(changeset) case updated do {:ok, tenant} -> - maybe_invalidate_cache(changeset) + maybe_update_cache(tenant, changeset) maybe_trigger_disconnect(changeset) maybe_restart_db_connection(changeset) + maybe_restart_rate_counters(changeset) Logger.debug("Tenant updated: #{inspect(tenant, pretty: true)}") {:error, error} -> @@ -145,63 +168,116 @@ defmodule Realtime.Api do updated end - @doc """ - Deletes a tenant. + @spec delete_tenant_by_external_id(String.t()) :: boolean() + def delete_tenant_by_external_id(id) do + if master_region?() do + query = from(t in Tenant, where: t.external_id == ^id) + {num, _} = Repo.delete_all(query) + num > 0 + else + call(:delete_tenant_by_external_id, [id], tenant_id: id) + end + end - ## Examples + @spec get_tenant_by_external_id(String.t(), Keyword.t()) :: Tenant.t() | nil + def get_tenant_by_external_id(external_id, opts \\ []) do + use_replica? = Keyword.get(opts, :use_replica?, true) - iex> delete_tenant(tenant) - {:ok, %Tenant{}} + cond do + use_replica? -> + Replica.replica().get_by(Tenant, external_id: external_id) |> Replica.replica().preload(:extensions) - iex> delete_tenant(tenant) - {:error, %Ecto.Changeset{}} + !use_replica? and master_region?() -> + Repo.get_by(Tenant, external_id: external_id) |> Repo.preload(:extensions) - """ - def delete_tenant(%Tenant{} = tenant), do: Repo.delete(tenant) - - @spec delete_tenant_by_external_id(String.t()) :: boolean() - def delete_tenant_by_external_id(id) do - from(t in Tenant, where: t.external_id == ^id) - |> Repo.delete_all() - |> case do - {num, _} when num > 0 -> - true - - _ -> - false + true -> + call(:get_tenant_by_external_id, [external_id, opts], tenant_id: external_id) end end - @spec get_tenant_by_external_id(String.t(), atom()) :: Tenant.t() | nil - def get_tenant_by_external_id(external_id, repo \\ :replica) - when repo in [:primary, :replica] do - repo = - case repo do - :primary -> Repo - :replica -> Replica.replica() - end + @spec list_feature_flags() :: [FeatureFlag.t()] + def list_feature_flags do + Replica.replica().all(from f in FeatureFlag, order_by: [asc: f.name]) + end - Tenant - |> repo.get_by(external_id: external_id) - |> repo.preload(:extensions) + @spec get_feature_flag(String.t()) :: FeatureFlag.t() | nil + def get_feature_flag(name) when is_binary(name), + do: Replica.replica().get_by(FeatureFlag, name: name) + + @spec upsert_feature_flag(map()) :: {:ok, FeatureFlag.t()} | {:error, Ecto.Changeset.t()} + def upsert_feature_flag(attrs) do + if master_region?() do + %FeatureFlag{} + |> FeatureFlag.changeset(attrs) + |> Repo.insert(on_conflict: {:replace, [:enabled, :updated_at]}, conflict_target: :name, returning: true) + |> tap(fn + {:ok, flag} -> FeatureFlags.Cache.global_update_cache(flag) + _ -> :ok + end) + else + call(:upsert_feature_flag, [attrs]) + end end - def list_extensions(type \\ "postgres_cdc_rls") do - from(e in Extensions, - where: e.type == ^type, - select: e - ) - |> Replica.replica().all() + @spec delete_feature_flag(FeatureFlag.t()) :: {:ok, FeatureFlag.t()} | {:error, Ecto.Changeset.t()} + def delete_feature_flag(%FeatureFlag{} = flag) do + if master_region?() do + flag + |> Repo.delete() + |> tap(fn + {:ok, deleted} -> FeatureFlags.Cache.global_invalidate_cache(deleted) + _ -> :ok + end) + else + call(:delete_feature_flag, [flag]) + end + end + + defp list_extensions(type) do + query = from(e in Extensions, where: e.type == ^type, select: e) + replica = Replica.replica() + replica.all(query) end def rename_settings_field(from, to) do - for extension <- list_extensions("postgres_cdc_rls") do - {value, settings} = Map.pop(extension.settings, from) - new_settings = Map.put(settings, to, value) + if master_region?() do + for extension <- list_extensions("postgres_cdc_rls") do + {value, settings} = Map.pop(extension.settings, from) + new_settings = Map.put(settings, to, value) + + extension + |> Changeset.cast(%{settings: new_settings}, [:settings]) + |> Repo.update() + end + else + call(:rename_settings_field, [from, to]) + end + end - extension - |> Ecto.Changeset.cast(%{settings: new_settings}, [:settings]) - |> Repo.update!() + @spec preload_counters(nil | Realtime.Api.Tenant.t(), any()) :: nil | Realtime.Api.Tenant.t() + @doc """ + Updates the migrations_ran field for a tenant. + """ + @spec update_migrations_ran(binary(), integer()) :: {:ok, Tenant.t()} | {:error, term()} + def update_migrations_ran(external_id, count) do + if master_region?() do + case get_tenant_by_external_id(external_id, use_replica?: false) do + nil -> + {:error, :tenant_not_found} + + tenant -> + tenant + |> Tenant.changeset(%{migrations_ran: count}) + |> Repo.update() + |> tap(fn result -> + case result do + {:ok, tenant} -> Cache.global_cache_update(tenant) + _ -> :ok + end + end) + end + else + call(:update_migrations_ran, [external_id, count], tenant_id: external_id) end end @@ -224,26 +300,87 @@ defmodule Realtime.Api do |> Map.put(:events_per_second_now, current) end - defp maybe_invalidate_cache( - %Ecto.Changeset{changes: changes, valid?: true, data: %{external_id: external_id}} = changeset - ) - when changes != %{} and requires_restarting_db_connection(changeset) do - Tenants.Cache.distributed_invalidate_tenant_cache(external_id) + @field_to_rate_counter_key %{ + max_events_per_second: [ + &Tenants.events_per_second_key/1, + &Tenants.db_events_per_second_key/1 + ], + max_joins_per_second: [ + &Tenants.joins_per_second_key/1 + ], + max_presence_events_per_second: [ + &Tenants.presence_events_per_second_key/1 + ], + extensions: [ + &Tenants.connect_errors_per_second_key/1, + &Tenants.subscription_errors_per_second_key/1, + &Tenants.authorization_errors_per_second_key/1 + ] + } + + defp maybe_restart_rate_counters(changeset) do + tenant_id = Changeset.fetch_field!(changeset, :external_id) + + Enum.each(@field_to_rate_counter_key, fn {field, key_fns} -> + if Changeset.changed?(changeset, field) do + Enum.each(key_fns, fn key_fn -> + tenant_id + |> key_fn.() + |> RateCounter.publish_update() + end) + end + end) + end + + defp maybe_update_cache(tenant, %Changeset{changes: changes, valid?: true}) when changes != %{} do + Tenants.Cache.global_cache_update(tenant) end - defp maybe_invalidate_cache(_changeset), do: nil + defp maybe_update_cache(_tenant, _changeset), do: :ok - defp maybe_trigger_disconnect(%Ecto.Changeset{data: %{external_id: external_id}} = changeset) + defp maybe_trigger_disconnect(%Changeset{data: %{external_id: external_id}} = changeset) when requires_disconnect(changeset) do - SocketDisconnect.distributed_disconnect(external_id) + UserSocket.disconnect(external_id) end defp maybe_trigger_disconnect(_changeset), do: nil - defp maybe_restart_db_connection(%Ecto.Changeset{data: %{external_id: external_id}} = changeset) + defp maybe_restart_db_connection(%Changeset{data: %{external_id: external_id}} = changeset) when requires_restarting_db_connection(changeset) do Connect.shutdown(external_id) + + try do + PostgresCdcRls.handle_stop(external_id, 5_000) + catch + kind, reason -> + Logger.warning("Failed to stop CDC processes for tenant #{external_id}: #{inspect(kind)} #{inspect(reason)}") + + :ok + end end defp maybe_restart_db_connection(_changeset), do: nil + + defp master_region? do + region = Application.get_env(:realtime, :region) + master_region = Application.get_env(:realtime, :master_region) || region + region == master_region + end + + defp call(operation, args, opts \\ []) do + master_region = Application.get_env(:realtime, :master_region) + + with {:ok, master_node} <- Nodes.node_from_region(master_region, self()), + {:ok, result} <- wrapped_call(master_node, operation, args, opts) do + result + end + end + + defp wrapped_call(master_node, operation, args, opts) do + case GenRpc.call(master_node, __MODULE__, operation, args, opts) do + {:error, :rpc_error, reason} -> {:error, reason} + {:error, reason} -> {:error, reason} + result -> {:ok, result} + end + end end diff --git a/lib/realtime/api/extensions.ex b/lib/realtime/api/extensions.ex index 4ecb1a0f0..89bc89061 100644 --- a/lib/realtime/api/extensions.ex +++ b/lib/realtime/api/extensions.ex @@ -11,6 +11,7 @@ defmodule Realtime.Api.Extensions do @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id @derive {Jason.Encoder, only: [:type, :inserted_at, :updated_at, :settings]} + schema "extensions" do field(:type, :string) field(:settings, :map) @@ -19,34 +20,35 @@ defmodule Realtime.Api.Extensions do end def changeset(extension, attrs) do - {attrs1, required_settings} = + {new_attrs, required_settings, optional_settings} = case attrs["type"] do nil -> - {attrs, []} + {attrs, [], []} type -> - %{default: default, required: required} = Realtime.Extensions.db_settings(type) + %{default: default, required: required, optional: optional} = Realtime.Extensions.db_settings(type) { %{attrs | "settings" => Map.merge(default, attrs["settings"])}, - required + required, + optional } end extension - |> cast(attrs1, [:type, :tenant_external_id, :settings]) + |> cast(new_attrs, [:type, :tenant_external_id, :settings]) |> validate_required([:type, :settings]) |> unique_constraint([:tenant_external_id, :type]) |> validate_required_settings(required_settings) - |> encrypt_settings(required_settings) + |> validate_optional_settings(optional_settings) + |> encrypt_settings(required_settings ++ optional_settings) end - def encrypt_settings(changeset, required) do + def encrypt_settings(changeset, fields) do update_change(changeset, :settings, fn settings -> - Enum.reduce(required, settings, fn + Enum.reduce(fields, settings, fn {field, _, true}, acc -> - encrypted = Crypto.encrypt!(settings[field]) - %{acc | field => encrypted} + if is_nil(acc[field]), do: acc, else: Map.put(acc, field, Crypto.encrypt!(acc[field])) _, acc -> acc @@ -72,4 +74,23 @@ defmodule Realtime.Api.Extensions do end) end) end + + def validate_optional_settings(changeset, optional) do + validate_change(changeset, :settings, fn + _, value -> + Enum.reduce(optional, [], fn {field, checker, _}, acc -> + case value[field] do + nil -> + acc + + data -> + if checker.(data) do + acc + else + [{:settings, "#{field} is invalid"} | acc] + end + end + end) + end) + end end diff --git a/lib/realtime/api/feature_flag.ex b/lib/realtime/api/feature_flag.ex new file mode 100644 index 000000000..b8a6d836d --- /dev/null +++ b/lib/realtime/api/feature_flag.ex @@ -0,0 +1,29 @@ +defmodule Realtime.Api.FeatureFlag do + @moduledoc """ + Ecto schema for a global feature flag. + + Flags have a name (unique) and a boolean enabled state. Per-tenant overrides + are stored separately on the `Realtime.Api.Tenant` schema as a JSONB map, + not as associations on this record. + """ + + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{} + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "feature_flags" do + field :name, :string + field :enabled, :boolean, default: false + timestamps() + end + + def changeset(flag, attrs) do + flag + |> cast(attrs, [:name, :enabled]) + |> validate_required([:name]) + |> unique_constraint(:name) + end +end diff --git a/lib/realtime/api/message.ex b/lib/realtime/api/message.ex index 90ebc5bc9..1c7bb5b63 100644 --- a/lib/realtime/api/message.ex +++ b/lib/realtime/api/message.ex @@ -8,6 +8,8 @@ defmodule Realtime.Api.Message do @primary_key {:id, Ecto.UUID, autogenerate: true} @schema_prefix "realtime" + @type t :: %__MODULE__{} + @timestamps_opts [type: :naive_datetime_usec] schema "messages" do field(:topic, :string) field(:extension, Ecto.Enum, values: [:broadcast, :presence]) @@ -35,11 +37,11 @@ defmodule Realtime.Api.Message do end defp put_timestamp(changeset, field) do - changeset |> put_change(field, NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)) + put_change(changeset, field, NaiveDateTime.utc_now(:microsecond)) end defp maybe_put_timestamp(changeset, field) do - case Map.get(changeset.data, field) do + case get_field(changeset, field) do nil -> put_timestamp(changeset, field) _ -> changeset end diff --git a/lib/realtime/api/tenant.ex b/lib/realtime/api/tenant.ex index cf609cafc..03755a1c5 100644 --- a/lib/realtime/api/tenant.ex +++ b/lib/realtime/api/tenant.ex @@ -19,7 +19,7 @@ defmodule Realtime.Api.Tenant do field(:postgres_cdc_default, :string) field(:max_concurrent_users, :integer) field(:max_events_per_second, :integer) - field(:max_presence_events_per_second, :integer, default: 10_000) + field(:max_presence_events_per_second, :integer, default: 1000) field(:max_payload_size_in_kb, :integer, default: 3000) field(:max_bytes_per_second, :integer) field(:max_channels_per_client, :integer) @@ -30,6 +30,10 @@ defmodule Realtime.Api.Tenant do field(:private_only, :boolean, default: false) field(:migrations_ran, :integer, default: 0) field(:broadcast_adapter, Ecto.Enum, values: [:phoenix, :gen_rpc], default: :gen_rpc) + field(:max_client_presence_events_per_window, :integer) + field(:client_presence_window_ms, :integer) + field(:presence_enabled, :boolean, default: false) + field(:feature_flags, :map, default: %{}) has_many(:extensions, Realtime.Api.Extensions, foreign_key: :tenant_external_id, @@ -76,12 +80,17 @@ defmodule Realtime.Api.Tenant do :suspend, :private_only, :migrations_ran, - :broadcast_adapter - ]) - |> validate_required([ - :external_id, - :jwt_secret + :broadcast_adapter, + :max_client_presence_events_per_window, + :client_presence_window_ms, + :presence_enabled, + :feature_flags ]) + |> validate_required([:external_id]) + |> check_constraint(:jwt_secret, + name: :jwt_secret_or_jwt_jwks_required, + message: "either jwt_secret or jwt_jwks must be provided" + ) |> unique_constraint([:external_id]) |> encrypt_jwt_secret() |> maybe_set_default(:max_bytes_per_second, :tenant_max_bytes_per_second) @@ -102,7 +111,8 @@ defmodule Realtime.Api.Tenant do end end - def encrypt_jwt_secret(changeset) do - update_change(changeset, :jwt_secret, &Crypto.encrypt!/1) - end + def encrypt_jwt_secret(%Ecto.Changeset{valid?: true} = changeset), + do: update_change(changeset, :jwt_secret, &Crypto.encrypt!/1) + + def encrypt_jwt_secret(changeset), do: changeset end diff --git a/lib/realtime/application.ex b/lib/realtime/application.ex index 0f4c9ae50..35297b057 100644 --- a/lib/realtime/application.ex +++ b/lib/realtime/application.ex @@ -4,6 +4,7 @@ defmodule Realtime.Application do @moduledoc false use Application + require Cachex.Spec require Logger alias Realtime.Repo.Replica @@ -13,16 +14,29 @@ defmodule Realtime.Application do defmodule JwtSecretError, do: defexception([:message]) defmodule JwtClaimValidatorsError, do: defexception([:message]) + defmodule RegionMappingError, do: defexception([:message]) + + defp check_for_local_ipv6_host() do + hostname = Node.self() |> Atom.to_string() + + if String.contains?(hostname, "fd00:ec2::172:2") do + Logger.error("Invalid hostname #{hostname}") + :timer.sleep(5000) + :erlang.halt(222) + end + end def start(_type, _args) do + check_for_local_ipv6_host() opentelemetry_setup() + Realtime.LogFilter.setup() primary_config = :logger.get_primary_config() # add the region to logs :ok = :logger.set_primary_config( :metadata, - Enum.into([region: System.get_env("REGION")], primary_config.metadata) + Enum.into([region: System.get_env("REGION"), cluster: System.get_env("CLUSTER")], primary_config.metadata) ) topologies = Application.get_env(:libcluster, :topologies) || [] @@ -36,6 +50,8 @@ defmodule Realtime.Application do message: "JWT claim validators is not a valid JSON object" end + setup_region_mapping() + :ok = :gen_event.swap_sup_handler( :erl_signal_server, @@ -43,39 +59,75 @@ defmodule Realtime.Application do {Realtime.SignalHandler, %{handler_mod: :erl_signal_handler}} ) - Realtime.PromEx.set_metrics_tags() :ets.new(Realtime.Tenants.Connect, [:named_table, :set, :public]) - :syn.set_event_handler(Realtime.SynHandler) - :ok = :syn.add_node_to_scopes([:users, RegionNodes, Realtime.Tenants.Connect]) + set_persist_storage(RealtimeWeb.UserSocket, :realtime, :websocket_max_heap_size) + set_persist_storage(RealtimeWeb.UserSocket, :realtime, :measure_traffic_interval_in_ms) + set_persist_storage(RealtimeWeb.UserSocket, :realtime, :connect_error_backoff_ms) + set_persist_storage(RealtimeWeb.RealtimeChannel, :realtime, :channel_error_backoff_ms) - region = Application.get_env(:realtime, :region) - :syn.join(RegionNodes, region, self(), node: node()) + :syn.set_event_handler(Realtime.SynHandler) + :ok = :syn.add_node_to_scopes([RegionNodes, Realtime.Tenants.Connect]) + region = Application.get_env(:realtime, :region) + broadcast_pool_size = Application.get_env(:realtime, :broadcast_pool_size, 10) + presence_pool_size = Application.get_env(:realtime, :presence_pool_size, 10) + presence_broadcast_period = Application.get_env(:realtime, :presence_broadcast_period, 1_500) + presence_permdown_period = Application.get_env(:realtime, :presence_permdown_period, 1_200_000) migration_partition_slots = Application.get_env(:realtime, :migration_partition_slots) connect_partition_slots = Application.get_env(:realtime, :connect_partition_slots) no_channel_timeout_in_ms = Application.get_env(:realtime, :no_channel_timeout_in_ms) + master_region = Application.get_env(:realtime, :master_region) || region + user_scope_shards = Application.fetch_env!(:realtime, :users_scope_shards) + user_scope_broadast_interval_in_ms = Application.get_env(:realtime, :users_scope_broadcast_interval_in_ms, 10_000) + + :syn.join(RegionNodes, region, self(), node: node()) + + zta_children = + case Application.get_env(:realtime, :dashboard_auth) do + :zta -> [{NimbleZTA.Cloudflare, name: Realtime.ZTA, identity_key: System.fetch_env!("CF_TEAM_DOMAIN")}] + _ -> [] + end children = [ Realtime.ErlSysMon, Realtime.GenCounter, Realtime.PromEx, + Realtime.TenantPromEx, {Realtime.Telemetry.Logger, handler_id: "telemetry-logger"}, - Realtime.Repo, RealtimeWeb.Telemetry, {Cluster.Supervisor, [topologies, [name: Realtime.ClusterSupervisor]]}, - {Phoenix.PubSub, name: Realtime.PubSub, pool_size: 10}, - {Cachex, name: Realtime.RateCounter}, + {Phoenix.PubSub, + name: Realtime.PubSub, pool_size: 10, adapter: pubsub_adapter(), broadcast_pool_size: broadcast_pool_size}, + {Forum.Census, + [ + :users, + [ + partitions: user_scope_shards, + broadcast_interval_in_ms: user_scope_broadast_interval_in_ms, + message_module: Realtime.ForumPubSubAdapter + ] + ]}, + Supervisor.child_spec({Cachex, name: Realtime.RateCounter}, id: Realtime.RateCounter), + Supervisor.child_spec({Cachex, name: Realtime.Nodes.Cache}, id: Realtime.Nodes.Cache), + Supervisor.child_spec( + {Cachex, + name: Realtime.LogThrottle, + expiration: + Cachex.Spec.expiration( + interval: Application.get_env(:realtime, :log_throttle_janitor_interval_ms, :timer.minutes(10)) + )}, + id: Realtime.LogThrottle + ), Realtime.Tenants.Cache, + Realtime.FeatureFlags.Cache, Realtime.RateCounter.DynamicSupervisor, Realtime.Latency, {Registry, keys: :duplicate, name: Realtime.Registry}, {Registry, keys: :unique, name: Realtime.Registry.Unique}, {Registry, keys: :unique, name: Realtime.Tenants.Connect.Registry}, {Registry, keys: :unique, name: Extensions.PostgresCdcRls.ReplicationPoller.Registry}, - {Registry, - keys: :duplicate, partitions: System.schedulers_online() * 2, name: RealtimeWeb.SocketDisconnect.Registry}, {Task.Supervisor, name: Realtime.TaskSupervisor}, {Task.Supervisor, name: Realtime.Tenants.Migrations.TaskSupervisor}, {PartitionSupervisor, @@ -95,14 +147,16 @@ defmodule Realtime.Application do partitions: connect_partition_slots}, {RealtimeWeb.RealtimeChannel.Tracker, check_interval_in_ms: no_channel_timeout_in_ms}, RealtimeWeb.Endpoint, - RealtimeWeb.Presence - ] ++ extensions_supervisors() ++ janitor_tasks() + {RealtimeWeb.Presence, + log_level: :info, + pool_size: presence_pool_size, + broadcast_period: presence_broadcast_period, + permdown_period: presence_permdown_period} + ] ++ extensions_supervisors() ++ janitor_tasks() ++ metrics_pusher_children() ++ zta_children - children = - case Replica.replica() do - Realtime.Repo -> children - replica -> List.insert_at(children, 2, replica) - end + database_connections = if master_region == region, do: [Realtime.Repo], else: [Replica.replica()] + + children = database_connections ++ children # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options @@ -147,9 +201,47 @@ defmodule Realtime.Application do end end + defp metrics_pusher_children do + if Application.get_env(:realtime, :metrics_pusher_enabled) do + [Realtime.MetricsPusher] + else + [] + end + end + defp opentelemetry_setup do :opentelemetry_cowboy.setup() OpentelemetryPhoenix.setup(adapter: :cowboy2) OpentelemetryEcto.setup([:realtime, :repo], db_statement: :enabled) end + + defp pubsub_adapter, do: Realtime.GenRpcPubSub + + defp setup_region_mapping do + case Application.get_env(:realtime, :region_mapping) do + nil -> + :ok + + mapping_json when is_binary(mapping_json) -> + case Jason.decode(mapping_json) do + {:ok, mapping} when is_map(mapping) -> + if Enum.all?(mapping, fn {k, v} -> is_binary(k) and is_binary(v) end) do + Application.put_env(:realtime, :region_mapping, mapping) + else + raise RegionMappingError, + message: "REGION_MAPPING must contain only string keys and values" + end + + {:ok, _} -> + raise RegionMappingError, + message: "REGION_MAPPING must be a JSON object" + + {:error, %Jason.DecodeError{} = error} -> + raise RegionMappingError, + message: "Failed to parse REGION_MAPPING: #{Exception.message(error)}" + end + end + end + + defp set_persist_storage(mod, app, key), do: :persistent_term.put({mod, key}, Application.fetch_env!(app, key)) end diff --git a/lib/realtime/context_cache.ex b/lib/realtime/context_cache.ex deleted file mode 100644 index afacf4ce1..000000000 --- a/lib/realtime/context_cache.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule Realtime.ContextCache do - @moduledoc """ - Read through cache for hot database paths. - """ - - require Logger - - def apply_fun(context, {fun, arity}, args) do - cache = cache_name(context) - cache_key = {{fun, arity}, args} - - case Cachex.fetch(cache, cache_key, fn {{_fun, _arity}, args} -> {:commit, {:cached, apply(context, fun, args)}} end) do - {:commit, {:cached, value}} -> value - {:ok, {:cached, value}} -> value - end - end - - defp cache_name(context) do - Module.concat(context, Cache) - end -end diff --git a/lib/realtime/database.ex b/lib/realtime/database.ex index ec663c7e0..d2bda70ee 100644 --- a/lib/realtime/database.ex +++ b/lib/realtime/database.ex @@ -44,7 +44,7 @@ defmodule Realtime.Database do @doc """ Creates a database connection struct from the given tenant. """ - @spec from_tenant(Tenant.t(), binary(), :stop | :exp | :rand | :rand_exp) :: t() + @spec from_tenant(Tenant.t(), binary(), :stop | :exp | :rand | :rand_exp) :: {:ok, t()} | {:error, :nxdomain} def from_tenant(%Tenant{} = tenant, application_name, backoff \\ :rand_exp) do tenant |> then(&Realtime.PostgresCdc.filter_settings(@cdc, &1.extensions)) @@ -54,33 +54,57 @@ defmodule Realtime.Database do @doc """ Creates a database connection struct from the given settings. """ - @spec from_settings(map(), binary(), :stop | :exp | :rand | :rand_exp) :: t() + @spec from_settings(map(), binary(), :stop | :exp | :rand | :rand_exp) :: {:ok, t()} | {:error, :nxdomain} def from_settings(settings, application_name, backoff \\ :rand_exp) do pool = pool_size_by_application_name(application_name, settings) settings = settings - |> Map.take(["db_host", "db_port", "db_name", "db_user", "db_password"]) + |> Map.take([ + "db_host", + "db_port", + "db_name", + "db_user", + "db_password", + "db_user_realtime", + "db_pass_realtime" + ]) |> Enum.map(fn {k, v} -> {k, Crypto.decrypt!(v)} end) |> Map.new() |> then(&Map.merge(settings, &1)) - {:ok, addrtype} = detect_ip_version(settings["db_host"]) - ssl = if default_ssl_param(settings), do: [verify: :verify_none], else: false + {username, password} = connection_credentials(application_name, settings) + + with {:ok, addrtype} <- detect_ip_version(settings["db_host"]) do + ssl = if default_ssl_param(settings), do: [verify: :verify_none], else: false + + {:ok, + %__MODULE__{ + hostname: settings["db_host"], + port: String.to_integer(settings["db_port"]), + database: settings["db_name"], + username: username, + password: password, + pool_size: pool, + queue_target: settings["db_queue_target"] || 5_000, + application_name: application_name, + backoff_type: backoff, + socket_options: [addrtype], + ssl: ssl + }} + end + end - %__MODULE__{ - hostname: settings["db_host"], - port: String.to_integer(settings["db_port"]), - database: settings["db_name"], - username: settings["db_user"], - password: settings["db_password"], - pool_size: pool, - queue_target: settings["db_queue_target"] || 5_000, - application_name: application_name, - backoff_type: backoff, - socket_options: [addrtype], - ssl: ssl - } + defp connection_credentials("realtime_migrations" = _application_name, settings) do + {settings["db_user"], settings["db_password"]} + end + + # Runtime connections prefer the least-privilege role, falling back to db_user. + defp connection_credentials(_application_name, settings) do + case settings["db_user_realtime"] do + nil -> {settings["db_user"], settings["db_password"]} + user -> {user, settings["db_pass_realtime"]} + end end @available_connection_factor 0.95 @@ -88,8 +112,7 @@ defmodule Realtime.Database do @doc """ Checks if the Tenant CDC extension information is properly configured and that we're able to query against the tenant database. """ - - @spec check_tenant_connection(Tenant.t() | nil) :: {:error, atom()} | {:ok, pid()} + @spec check_tenant_connection(Tenant.t() | nil) :: {:error, atom()} | {:ok, pid(), non_neg_integer()} def check_tenant_connection(nil), do: {:error, :tenant_not_found} def check_tenant_connection(tenant) do @@ -97,46 +120,92 @@ defmodule Realtime.Database do |> then(&PostgresCdc.filter_settings(@cdc, &1.extensions)) |> then(fn settings -> required_pool = tenant_pool_requirements(settings) - check_settings = from_settings(settings, "realtime_connect", :stop) - check_settings = Map.put(check_settings, :max_restarts, 0) - - with {:ok, conn} <- connect_db(check_settings) do - query = - "select (current_setting('max_connections')::int - count(*))::int from pg_stat_activity where application_name != 'realtime_connect'" - - case Postgrex.query(conn, query, []) do - {:ok, %{rows: [[available_connections]]}} -> - requirement = ceil(required_pool * @available_connection_factor) - - if requirement < available_connections do - {:ok, conn} - else - log_error( - "DatabaseLackOfConnections", - "Only #{available_connections} available connections. At least #{requirement} connections are required." - ) - - {:error, :tenant_db_too_many_connections} - end - - {:error, e} -> - Process.exit(conn, :kill) - log_error("UnableToConnectToTenantDatabase", e) - {:error, e} + + with {:ok, base_settings} <- from_settings(settings, "realtime_connect", :stop), + check_settings = %{base_settings | max_restarts: 0}, + {:ok, conn} <- connect_db(check_settings), + {:ok, [available_connections, migrations_ran]} <- query_connection_info(conn) do + requirement = ceil(required_pool * @available_connection_factor) + + if requirement < available_connections do + {:ok, conn, migrations_ran} + else + msg = "Only #{available_connections} available connections. At least #{requirement} connections are required." + log_error("DatabaseLackOfConnections", msg) + GenServer.stop(conn) + {:error, :tenant_db_too_many_connections} end + else + {:error, e} -> + log_error("UnableToConnectToTenantDatabase", e) + {:error, e} end end) end + @migrations_table_exists_query """ + SELECT to_regclass('realtime.schema_migrations') IS NOT NULL + """ + + @migrations_count_query """ + SELECT count(*)::int FROM realtime.schema_migrations + """ + + @connections_query """ + SELECT (current_setting('max_connections')::int - count(*))::int + FROM pg_stat_activity + WHERE application_name != 'realtime_connect' + """ + + defp query_connection_info(conn) do + %{rows: [[available_connections]]} = Postgrex.query!(conn, @connections_query, []) + %{rows: [[table_exists]]} = Postgrex.query!(conn, @migrations_table_exists_query, []) + + %{rows: [[migrations_ran]]} = + if table_exists, do: Postgrex.query!(conn, @migrations_count_query, []), else: %{rows: [[0]]} + + {:ok, [available_connections, migrations_ran]} + rescue + e -> + GenServer.stop(conn) + {:error, e} + end + + @slot_lag_query """ + SELECT + COALESCE(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn), 0) > + (pg_size_bytes(current_setting('max_slot_wal_keep_size')) / 2) + FROM pg_replication_slots + WHERE slot_name = $1 + AND current_setting('max_slot_wal_keep_size') != '-1' + """ + + @doc """ + Checks if a replication slot's WAL lag exceeds 50% of max_slot_wal_keep_size. + + Returns :ok when the slot is within safe bounds, max_slot_wal_keep_size is disabled (-1), + or the slot does not exist. Returns {:error, :lag_too_high} only when the slot is confirmed + to be consuming more than 50% of the per-slot WAL limit. + """ + @spec check_replication_slot_lag(pid(), String.t()) :: + :ok | {:error, :lag_too_high} | {:error, any()} + def check_replication_slot_lag(conn, slot_name) do + case Postgrex.query(conn, @slot_lag_query, [slot_name]) do + {:ok, %{rows: [[true]]}} -> {:error, :lag_too_high} + {:ok, %{rows: _}} -> :ok + {:error, _} = err -> err + end + end + @doc """ Connects to the database using the given settings. """ @spec connect(Tenant.t(), binary(), :stop | :exp | :rand | :rand_exp) :: {:ok, pid()} | {:error, any()} def connect(tenant, application_name, backoff \\ :stop) do - tenant - |> from_tenant(application_name, backoff) - |> connect_db() + with {:ok, settings} <- from_tenant(tenant, application_name, backoff) do + connect_db(settings) + end end @doc """ @@ -246,9 +315,8 @@ defmodule Realtime.Database do @spec pool_size_by_application_name(binary(), map() | nil) :: non_neg_integer() def pool_size_by_application_name(application_name, settings) do case application_name do - "realtime_subscription_manager" -> settings["subcriber_pool_size"] || 1 + "realtime_subscription_manager" -> 1 "realtime_subscription_manager_pub" -> settings["subs_pool_size"] || 1 - "realtime_subscription_checker" -> settings["subs_pool_size"] || 1 "realtime_connect" -> settings["db_pool"] || 1 "realtime_health_check" -> 1 "realtime_janitor" -> 1 @@ -278,10 +346,13 @@ defmodule Realtime.Database do def detect_ip_version(host) when is_binary(host) do host = String.to_charlist(host) - cond do - match?({:ok, _}, :inet6_tcp.getaddr(host)) -> {:ok, :inet6} - match?({:ok, _}, :inet.gethostbyname(host)) -> {:ok, :inet} - true -> {:error, :nxdomain} + if match?({:ok, _}, :inet6_tcp.getaddr(host)) do + {:ok, :inet6} + else + case :inet.gethostbyname(host) do + {:ok, _} -> {:ok, :inet} + _ -> {:error, :nxdomain} + end end end @@ -359,7 +430,6 @@ defmodule Realtime.Database do application_names = [ "realtime_subscription_manager", "realtime_subscription_manager_pub", - "realtime_subscription_checker", "realtime_health_check", "realtime_janitor", "realtime_migrations", diff --git a/lib/realtime/env.ex b/lib/realtime/env.ex new file mode 100644 index 000000000..74ae261d8 --- /dev/null +++ b/lib/realtime/env.ex @@ -0,0 +1,80 @@ +defmodule Realtime.Env do + @moduledoc false + # Internal module used to load and validate env vars. + + @spec get_integer(binary(), integer() | nil) :: integer() | nil + def get_integer(env, default \\ nil) + + def get_integer(env, default) when is_integer(default) or is_nil(default) do + value = System.get_env(env) + + if value do + case Integer.parse(value) do + {int, ""} -> int + _ -> raise ArgumentError, "env #{env} expected a Integer, got #{inspect(value)}" + end + else + default + end + end + + def get_integer(env, default) do + raise ArgumentError, + "expected either Integer or empty (nil) as default value for env #{env}, got #{inspect(default)}" + end + + @spec get_charlist(binary(), charlist()) :: charlist() + def get_charlist(env, default) when is_list(default) do + value = System.get_env(env) + if value, do: String.to_charlist(value), else: default + end + + def get_charlist(env, default) do + raise ArgumentError, + "expected a charlist as default value for env #{env}, got #{inspect(default)}" + end + + # accepts true/1 for truthy values and false/0 for falsy values, otherwise raise ArgumentError + @spec get_boolean(binary(), boolean()) :: boolean() + def get_boolean(env, default) when is_boolean(default) do + value = System.get_env(env) + + if value do + value = value |> String.trim() |> String.downcase() + + cond do + value in ["true", "1"] -> true + value in ["false", "0"] -> false + :else -> raise ArgumentError, "env #{env} expected a boolean or 0/1 values, got #{inspect(value)}" + end + else + default + end + end + + def get_boolean(env, default) do + raise ArgumentError, + "expected a boolean as default value for env #{env}, got #{inspect(default)}" + end + + @spec get_binary(binary(), binary() | (-> binary())) :: binary() + def get_binary(env, default) when is_function(default, 0) do + value = System.get_env(env) + if value, do: value, else: default.() + end + + def get_binary(env, default), do: System.get_env(env, default) + + @spec get_list(binary(), [binary()]) :: [binary()] + def get_list(env, default) when is_list(default) do + value = System.get_env(env) + + if value do + value + |> String.split(",") + |> Enum.map(&String.trim/1) + else + default + end + end +end diff --git a/lib/realtime/feature_flags.ex b/lib/realtime/feature_flags.ex new file mode 100644 index 000000000..ef4e2174b --- /dev/null +++ b/lib/realtime/feature_flags.ex @@ -0,0 +1,55 @@ +defmodule Realtime.FeatureFlags do + @moduledoc """ + Manages feature flags with optional per-tenant overrides. + + Each flag has a global enabled/disabled state. Tenants can override that state + via a JSONB map stored on the tenant record. + + Use `enabled?/1` to check the global flag value only. + Use `enabled?/2` when the flag supports per-tenant overrides. Resolution order: + 1. Tenant-specific override (if present) + 2. Global flag value + 3. `false` when the flag does not exist + """ + + alias Realtime.Api + alias Realtime.Api.FeatureFlag + alias Realtime.FeatureFlags.Cache + alias Realtime.Tenants.Cache, as: TenantsCache + + @spec set_tenant_flag(String.t(), String.t(), boolean()) :: + {:ok, Realtime.Api.Tenant.t()} | {:error, :not_found | Ecto.Changeset.t()} + def set_tenant_flag(flag_name, tenant_id, enabled) + when is_binary(flag_name) and is_binary(tenant_id) and is_boolean(enabled) do + case Api.get_tenant_by_external_id(tenant_id, use_replica?: false) do + nil -> + {:error, :not_found} + + tenant -> + updated_flags = Map.put(tenant.feature_flags, flag_name, enabled) + Api.update_tenant_by_external_id(tenant_id, %{feature_flags: updated_flags}) + end + end + + @spec enabled?(String.t()) :: boolean() + def enabled?(flag_name) when is_binary(flag_name) do + case Cache.get_flag(flag_name) do + nil -> false + %FeatureFlag{enabled: enabled} -> enabled + end + end + + @spec enabled?(String.t(), String.t()) :: boolean() + def enabled?(flag_name, tenant_id) when is_binary(flag_name) and is_binary(tenant_id) do + case Cache.get_flag(flag_name) do + nil -> + false + + %FeatureFlag{enabled: global_enabled} -> + case TenantsCache.get_tenant_by_external_id(tenant_id) do + nil -> global_enabled + %{feature_flags: flags} -> Map.get(flags, flag_name, global_enabled) + end + end + end +end diff --git a/lib/realtime/feature_flags/cache.ex b/lib/realtime/feature_flags/cache.ex new file mode 100644 index 000000000..356eb853e --- /dev/null +++ b/lib/realtime/feature_flags/cache.ex @@ -0,0 +1,60 @@ +defmodule Realtime.FeatureFlags.Cache do + @moduledoc """ + In-process Cachex cache for `Realtime.Api.FeatureFlag` records. + + Cache misses fall through to the database automatically via `Cachex.fetch/3`. + Nil results (flag not found) are intentionally not cached so that newly + created flags become visible without requiring an explicit invalidation. + + Use `global_update_cache/1` after mutations to push the updated struct to all + cluster nodes. Use `global_invalidate_cache/1` after deletes. + """ + + require Cachex.Spec + alias Realtime.Api + alias Realtime.Api.FeatureFlag + alias Realtime.GenRpc + + def child_spec(_) do + tenant_cache_expiration = Application.get_env(:realtime, :tenant_cache_expiration) + + %{ + id: __MODULE__, + start: {Cachex, :start_link, [__MODULE__, [expiration: Cachex.Spec.expiration(default: tenant_cache_expiration)]]} + } + end + + @spec get_flag(String.t()) :: FeatureFlag.t() | nil + def get_flag(name) do + with {_, value} <- + Cachex.fetch(__MODULE__, cache_key(name), fn _key -> + with %FeatureFlag{} = flag <- Api.get_feature_flag(name), + do: {:commit, flag}, + else: (_ -> {:ignore, nil}) + end) do + value + end + end + + @spec update_cache(FeatureFlag.t()) :: {:ok, boolean()} | {:error, boolean()} + def update_cache(%FeatureFlag{} = flag) do + Cachex.put(__MODULE__, cache_key(flag.name), flag) + end + + @spec invalidate_cache(String.t()) :: {:ok, boolean()} | {:error, boolean()} + def invalidate_cache(name) when is_binary(name) do + Cachex.del(__MODULE__, cache_key(name)) + end + + @spec global_update_cache(FeatureFlag.t()) :: :ok + def global_update_cache(%FeatureFlag{} = flag) do + GenRpc.multicast(__MODULE__, :update_cache, [flag]) + end + + @spec global_invalidate_cache(FeatureFlag.t()) :: :ok + def global_invalidate_cache(%FeatureFlag{} = flag) do + GenRpc.multicast(__MODULE__, :invalidate_cache, [flag.name]) + end + + defp cache_key(name), do: {:get_flag, name} +end diff --git a/lib/realtime/forum_pub_sub_adapter.ex b/lib/realtime/forum_pub_sub_adapter.ex new file mode 100644 index 000000000..905f71379 --- /dev/null +++ b/lib/realtime/forum_pub_sub_adapter.ex @@ -0,0 +1,33 @@ +defmodule Realtime.ForumPubSubAdapter do + @moduledoc "Forum adapter to use PubSub" + + import Kernel, except: [send: 2] + + @behaviour Forum.Adapter + + @impl true + def register(scope) do + :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, topic(scope)) + end + + @impl true + def broadcast(scope, message) do + Phoenix.PubSub.broadcast_from(Realtime.PubSub, self(), topic(scope), message) + end + + @impl true + def broadcast(scope, _nodes, message) do + # Notice here that we don't filter by nodes, as PubSub broadcasts to all subscribers + # We are broadcasting to everyone because we want to use the fact that Realtime.PubSub uses + # regional broadcasting which is more efficient in this multi-region setup + + broadcast(scope, message) + end + + @impl true + def send(scope, node, message) do + Phoenix.PubSub.direct_broadcast(node, Realtime.PubSub, topic(scope), message) + end + + defp topic(scope), do: "forum:#{scope}" +end diff --git a/lib/realtime/gen_rpc.ex b/lib/realtime/gen_rpc.ex index bb7099242..16979f771 100644 --- a/lib/realtime/gen_rpc.ex +++ b/lib/realtime/gen_rpc.ex @@ -2,14 +2,71 @@ defmodule Realtime.GenRpc do @moduledoc """ RPC module for Realtime using :gen_rpc - :max_gen_rpc_clients is the maximum number of clients (TCP connections) used by gen_rpc - between two nodes + Two separate connection pools are maintained per remote node: + + - Cast pool: used by `cast/5`, `abcast/4`, `multicast/4`. Size controlled by + `MAX_GEN_RPC_CLIENTS` env var (default 5). Client tags: `{:cast, 1..N}`. + + - Call pool: used by `call/5`, `multicall/4`. Size controlled by + `MAX_GEN_RPC_CALL_CLIENTS` env var (default 1). Client tags: `{:call, 1..M}`. """ use Realtime.Logs alias Realtime.Telemetry @type result :: any | {:error, :rpc_error, reason :: any} + @doc """ + Broadcasts the message `msg` asynchronously to the registered process `name` on the specified `nodes`. + + Options: + + - `:key` - Optional key to consistently select the same gen_rpc clients to guarantee message order between nodes + """ + @spec abcast([node], atom, any, keyword()) :: :ok + def abcast(nodes, name, msg, opts) when is_list(nodes) and is_atom(name) and is_list(opts) do + key = Keyword.get(opts, :key, nil) + + {local, remote} = Enum.split_with(nodes, &(&1 == node())) + + if local != [], do: send({name, node()}, msg) + + remote + |> cast_rpc_nodes(key) + |> :gen_rpc.abcast(name, msg) + + :ok + end + + @doc """ + Fire and forget apply(mod, func, args) on one node + + Options: + + - `:key` - Optional key to consistently select the same gen_rpc client to guarantee some message order between nodes + """ + @spec cast(node, module, atom, list(any), keyword()) :: :ok + def cast(node, mod, func, args, opts \\ []) + + # Local + def cast(node, mod, func, args, _opts) when node == node() do + :erpc.cast(node, mod, func, args) + :ok + end + + def cast(node, mod, func, args, opts) + when is_atom(node) and is_atom(mod) and is_atom(func) and is_list(args) and is_list(opts) do + key = Keyword.get(opts, :key, nil) + + # Ensure this node is part of the connected nodes + if node in Node.list() do + node_key = cast_rpc_node(node, key) + + :gen_rpc.cast(node_key, mod, func, args) + end + + :ok + end + @doc """ Fire and forget apply(mod, func, args) on all nodes @@ -21,7 +78,7 @@ defmodule Realtime.GenRpc do def multicast(mod, func, args, opts \\ []) when is_atom(mod) and is_atom(func) and is_list(args) and is_list(opts) do key = Keyword.get(opts, :key, nil) - nodes = rpc_nodes(Node.list(), key) + nodes = cast_rpc_nodes(Node.list(), key) # Use erpc for the local node because :gen_rpc tries to connect with the local node :ok = :erpc.cast(Node.self(), mod, func, args) @@ -35,21 +92,40 @@ defmodule Realtime.GenRpc do Options: - `:key` - Optional key to consistently select the same gen_rpc clients to guarantee message order between nodes - - `:tenant_id` - Tenant ID for telemetry and logging, defaults to nil + - `:tenant_id` - Tenant ID for logging, defaults to nil - `:timeout` - timeout in milliseconds for the RPC call, defaults to 5000ms """ @spec call(node, module, atom, list(any), keyword()) :: result def call(node, mod, func, args, opts) when is_atom(node) and is_atom(mod) and is_atom(func) and is_list(args) and is_list(opts) do + if node == node() or node in Node.list() do + do_call(node, mod, func, args, opts) + else + tenant_id = Keyword.get(opts, :tenant_id) + + log_error( + "ErrorOnRpcCall", + %{target: node, mod: mod, func: func, error: :badnode}, + project: tenant_id, + external_id: tenant_id + ) + + {:error, :rpc_error, :badnode} + end + end + + defp do_call(node, mod, func, args, opts) do timeout = Keyword.get(opts, :timeout, default_rpc_timeout()) tenant_id = Keyword.get(opts, :tenant_id) key = Keyword.get(opts, :key, nil) - node_key = rpc_node(node, key) + node_key = call_rpc_node(node, key) {latency, response} = :timer.tc(fn -> :gen_rpc.call(node_key, mod, func, args, timeout) end) case response do {:badrpc, reason} -> + reason = unwrap_reason(reason) + log_error( "ErrorOnRpcCall", %{target: node, mod: mod, func: func, error: reason}, @@ -57,22 +133,21 @@ defmodule Realtime.GenRpc do external_id: tenant_id ) - telemetry_failure(node, latency, tenant_id) + telemetry_failure(node, latency) {:error, :rpc_error, reason} {:error, _} -> - telemetry_failure(node, latency, tenant_id) + telemetry_failure(node, latency) response _ -> - telemetry_success(node, latency, tenant_id) + telemetry_success(node, latency) response end end # Not using :gen_rpc.multicall here because we can't see the actual results on errors - @doc """ Evaluates apply(mod, func, args) on all nodes @@ -88,8 +163,7 @@ defmodule Realtime.GenRpc do tenant_id = Keyword.get(opts, :tenant_id) key = Keyword.get(opts, :key, nil) - nodes = rpc_nodes([node() | Node.list()], key) - + nodes = call_rpc_nodes([node() | Node.list()], key) # Latency here is the amount of time that it takes for this node to gather the result. # If one node takes a while to reply the remaining calls will have at least the latency reported by this node # Example: @@ -103,7 +177,7 @@ defmodule Realtime.GenRpc do result = case nb_yield(node, ref, timeout) do :timeout -> {:error, :rpc_error, :timeout} - {:value, {:badrpc, reason}} -> {:error, :rpc_error, reason} + {:value, {:badrpc, reason}} -> {:error, :rpc_error, unwrap_reason(reason)} {:value, result} -> result end @@ -121,47 +195,49 @@ defmodule Realtime.GenRpc do external_id: tenant_id ) - telemetry_failure(node, latency, tenant_id) + telemetry_failure(node, latency) {node, result} {node, latency, {:ok, _} = result} -> - telemetry_success(node, latency, tenant_id) + telemetry_success(node, latency) {node, result} {node, latency, result} -> - telemetry_failure(node, latency, tenant_id) + telemetry_failure(node, latency) {node, result} end) end - defp telemetry_success(node, latency, tenant_id) do + defp telemetry_success(node, latency) do Telemetry.execute( [:realtime, :rpc], %{latency: latency}, - %{origin_node: node(), target_node: node, success: true, tenant: tenant_id, mechanism: :gen_rpc} + %{origin_node: node(), target_node: node, success: true, mechanism: :gen_rpc} ) end - defp telemetry_failure(node, latency, tenant_id) do + defp telemetry_failure(node, latency) do Telemetry.execute( [:realtime, :rpc], %{latency: latency}, - %{origin_node: node(), target_node: node, success: false, tenant: tenant_id, mechanism: :gen_rpc} + %{origin_node: node(), target_node: node, success: false, mechanism: :gen_rpc} ) end - # Max amount of clients (TCP connections) used by gen_rpc - defp max_clients(), do: Application.fetch_env!(:realtime, :max_gen_rpc_clients) + defp max_cast_clients(), do: Application.fetch_env!(:realtime, :max_gen_rpc_clients) + defp max_call_clients(), do: Application.fetch_env!(:realtime, :max_gen_rpc_call_clients) + + defp cast_rpc_nodes(nodes, key), do: Enum.map(nodes, &cast_rpc_node(&1, key)) + defp call_rpc_nodes(nodes, key), do: Enum.map(nodes, &call_rpc_node(&1, key)) - defp rpc_nodes(nodes, key), do: Enum.map(nodes, &rpc_node(&1, key)) + defp cast_rpc_node(node, nil), do: {node, {:cast, :rand.uniform(max_cast_clients())}} + defp cast_rpc_node(node, key), do: {node, {:cast, :erlang.phash2(key, max_cast_clients()) + 1}} - # Tag the node with a random number from 1 to max_clients - # This ensures that we don't use the same client/tcp connection for this node - defp rpc_node(node, nil), do: {node, :rand.uniform(max_clients())} + defp call_rpc_node(node, nil), do: {node, {:call, :rand.uniform(max_call_clients())}} + defp call_rpc_node(node, key), do: {node, {:call, :erlang.phash2(key, max_call_clients()) + 1}} - # Tag the node with a random number from 1 to max_clients - # Using phash2 to ensure the same key and the same client per node - defp rpc_node(node, key), do: {node, :erlang.phash2(key, max_clients()) + 1} + defp unwrap_reason({:unknown_error, {{:badrpc, reason}, _}}), do: reason + defp unwrap_reason(reason), do: reason defp default_rpc_timeout, do: Application.get_env(:realtime, :rpc_timeout, 5_000) diff --git a/lib/realtime/gen_rpc/pub_sub.ex b/lib/realtime/gen_rpc/pub_sub.ex new file mode 100644 index 000000000..9299626a2 --- /dev/null +++ b/lib/realtime/gen_rpc/pub_sub.ex @@ -0,0 +1,127 @@ +defmodule Realtime.GenRpcPubSub do + @moduledoc """ + gen_rpc Phoenix.PubSub adapter + """ + + @behaviour Phoenix.PubSub.Adapter + alias Realtime.GenRpc + alias Realtime.GenRpcPubSub.Worker + alias Realtime.Nodes + use Supervisor + + @impl true + def node_name(_), do: node() + + # Supervisor callbacks + + def start_link(opts) do + adapter_name = Keyword.fetch!(opts, :adapter_name) + name = Keyword.fetch!(opts, :name) + pool_size = Keyword.get(opts, :pool_size, 1) + broadcast_pool_size = Keyword.get(opts, :broadcast_pool_size, pool_size) + + Supervisor.start_link(__MODULE__, {adapter_name, name, broadcast_pool_size}, + name: :"#{name}#{adapter_name}_supervisor" + ) + end + + @impl true + def init({adapter_name, pubsub, pool_size}) do + workers = for number <- 1..pool_size, do: :"#{pubsub}#{adapter_name}_#{number}" + + :persistent_term.put(adapter_name, List.to_tuple(workers)) + + children = + for worker <- workers do + Supervisor.child_spec({Realtime.GenRpcPubSub.Worker, {pubsub, worker}}, id: worker) + end + + Supervisor.init(children, strategy: :one_for_one) + end + + defp worker_name(adapter_name, key) do + workers = :persistent_term.get(adapter_name) + elem(workers, :erlang.phash2(key, tuple_size(workers))) + end + + @impl true + def broadcast(adapter_name, topic, message, dispatcher) do + worker = worker_name(adapter_name, self()) + + my_region = Application.get_env(:realtime, :region) + # broadcast to all other nodes in the region + + other_nodes = for node <- Realtime.Nodes.region_nodes(my_region), node != node(), do: node + GenRpc.abcast(other_nodes, worker, Worker.forward_to_local(topic, message, dispatcher), key: self()) + + # send a message to a node in each region to forward to the rest of the region + other_region_nodes = nodes_from_other_regions(my_region, self()) + + GenRpc.abcast(other_region_nodes, worker, Worker.forward_to_region(topic, message, dispatcher), key: self()) + + :ok + end + + defp nodes_from_other_regions(my_region, key) do + Enum.flat_map(Nodes.all_node_regions(), fn + ^my_region -> + [] + + region -> + case Nodes.node_from_region(region, key) do + {:ok, node} -> [node] + _ -> [] + end + end) + end + + @impl true + def direct_broadcast(adapter_name, node_name, topic, message, dispatcher) do + worker = worker_name(adapter_name, self()) + GenRpc.abcast([node_name], worker, Worker.forward_to_local(topic, message, dispatcher), key: self()) + end +end + +defmodule Realtime.GenRpcPubSub.Worker do + @moduledoc false + use GenServer + + def forward_to_local(topic, message, dispatcher), do: {:ftl, topic, message, dispatcher} + def forward_to_region(topic, message, dispatcher), do: {:ftr, topic, message, dispatcher} + + @doc false + def start_link({pubsub, worker}), do: GenServer.start_link(__MODULE__, {pubsub, worker}, name: worker) + + @impl true + def init({pubsub, worker}) do + Process.flag(:message_queue_data, :off_heap) + Process.flag(:fullsweep_after, 20) + {:ok, {pubsub, worker}} + end + + @impl true + # Forward to local + def handle_info({:ftl, topic, message, dispatcher}, {pubsub, worker}) do + Phoenix.PubSub.local_broadcast(pubsub, topic, message, dispatcher) + {:noreply, {pubsub, worker}} + end + + # Forward to the rest of the region + def handle_info({:ftr, topic, message, dispatcher}, {pubsub, worker}) do + # Forward to local first + Phoenix.PubSub.local_broadcast(pubsub, topic, message, dispatcher) + + # Then broadcast to the rest of my region + my_region = Application.get_env(:realtime, :region) + other_nodes = for node <- Realtime.Nodes.region_nodes(my_region), node != node(), do: node + + if other_nodes != [] do + Realtime.GenRpc.abcast(other_nodes, worker, forward_to_local(topic, message, dispatcher), []) + end + + {:noreply, {pubsub, worker}} + end + + @impl true + def handle_info(_, pubsub), do: {:noreply, pubsub} +end diff --git a/lib/realtime/helpers.ex b/lib/realtime/helpers.ex index 6c6209768..8b74adde8 100644 --- a/lib/realtime/helpers.ex +++ b/lib/realtime/helpers.ex @@ -2,11 +2,9 @@ defmodule Realtime.Helpers do @moduledoc """ This module includes helper functions for different contexts that can't be union in one module. """ - require Logger - - @spec cancel_timer(reference() | nil) :: non_neg_integer() | false | :ok | nil - def cancel_timer(nil), do: nil - def cancel_timer(ref), do: Process.cancel_timer(ref) + @spec cancel_timer(reference() | nil) :: :ok + def cancel_timer(ref) when is_reference(ref), do: Process.cancel_timer(ref, info: false) + def cancel_timer(_), do: :ok @doc """ Takes the first N items from the queue and returns the list of items and the new queue. diff --git a/lib/realtime/log_filter.ex b/lib/realtime/log_filter.ex new file mode 100644 index 000000000..c6d335750 --- /dev/null +++ b/lib/realtime/log_filter.ex @@ -0,0 +1,35 @@ +defmodule Realtime.LogFilter do + @moduledoc """ + Primary logger filter that suppresses noisy errors from dependencies. + """ + + @filter_id :connection_noise + + @doc """ + Installs the primary filter into the Erlang logger. Safe to call multiple times. + """ + def setup do + case :logger.add_primary_filter(@filter_id, {&filter/2, []}) do + :ok -> :ok + {:error, {:already_exist, @filter_id}} -> :ok + end + end + + @doc """ + Filter function passed to `:logger.add_primary_filter/2`. + + Returns `:stop` to suppress the event or the original event map to allow it through. + """ + def filter( + %{msg: {:report, %{label: {:gen_statem, :terminate}, reason: {_, %DBConnection.ConnectionError{}, _}}}}, + _ + ), + do: :stop + + def filter(%{meta: %{mfa: {DBConnection.Connection, _, _}}}, _), do: :stop + + @ranch_format "Ranch listener ~p had connection process started with ~p:start_link/3 at ~p exit with reason: ~0p~n" + def filter(%{msg: {:format, @ranch_format, [_, _, _, :killed]}}, _), do: :stop + + def filter(event, _), do: event +end diff --git a/lib/realtime/messages.ex b/lib/realtime/messages.ex index c6d571db7..e209461a2 100644 --- a/lib/realtime/messages.ex +++ b/lib/realtime/messages.ex @@ -3,6 +3,69 @@ defmodule Realtime.Messages do Handles `realtime.messages` table operations """ + alias Realtime.Api.Message + + import Ecto.Query, only: [from: 2] + + @hard_limit 25 + @default_timeout 5_000 + + @doc """ + Fetch last `limit ` messages for a given `topic` inserted after `since` + + Automatically uses RPC if the database connection is not in the same node + + Only allowed for private channels + """ + @spec replay(pid, String.t(), String.t(), non_neg_integer, non_neg_integer) :: + {:ok, Message.t(), [String.t()]} | {:error, term} | {:error, :rpc_error, term} + def replay(conn, tenant_id, topic, since, limit) + when node(conn) == node() and is_integer(since) and is_integer(limit) do + limit = max(min(limit, @hard_limit), 1) + + with {:ok, since} <- DateTime.from_unix(since, :millisecond), + {:ok, messages} <- messages(conn, tenant_id, topic, since, limit) do + {:ok, Enum.reverse(messages), MapSet.new(messages, & &1.id)} + else + {:error, :postgrex_exception} -> {:error, :failed_to_replay_messages} + {:error, :invalid_unix_time} -> {:error, :invalid_replay_params} + error -> error + end + end + + def replay(conn, tenant_id, topic, since, limit) when is_integer(since) and is_integer(limit) do + Realtime.GenRpc.call(node(conn), __MODULE__, :replay, [conn, tenant_id, topic, since, limit], + key: topic, + tenant_id: tenant_id + ) + end + + def replay(_, _, _, _, _), do: {:error, :invalid_replay_params} + + defp messages(conn, tenant_id, topic, since, limit) do + since = DateTime.to_naive(since) + # We want to avoid searching partitions in the future as they should be empty + # so we limit to 1 minute in the future to account for any potential drift + now = NaiveDateTime.utc_now() |> NaiveDateTime.add(1, :minute) + + query = + from m in Message, + where: + m.topic == ^topic and + m.private == true and + m.extension == :broadcast and + m.inserted_at >= ^since and + m.inserted_at < ^now, + limit: ^limit, + order_by: [desc: m.inserted_at] + + {latency, value} = + :timer.tc(Realtime.Tenants.Repo, :all, [conn, query, Message, [timeout: @default_timeout]], :millisecond) + + :telemetry.execute([:realtime, :tenants, :replay], %{latency: latency}, %{tenant: tenant_id}) + value + end + @doc """ Deletes messages older than 72 hours for a given tenant connection """ diff --git a/lib/realtime/metrics_cleaner.ex b/lib/realtime/metrics_cleaner.ex index 773fb4c86..48e4c0908 100644 --- a/lib/realtime/metrics_cleaner.ex +++ b/lib/realtime/metrics_cleaner.ex @@ -6,19 +6,88 @@ defmodule Realtime.MetricsCleaner do defstruct [:check_ref, :interval] - def start_link(args), do: GenServer.start_link(__MODULE__, args) + def handle_forum_event([:forum, :users, :group, :vacant], _, %{group: tenant_id}, vacant_websockets) do + :ets.insert(vacant_websockets, {tenant_id, DateTime.to_unix(DateTime.utc_now(), :second)}) + end + + def handle_forum_event([:forum, :users, :group, :occupied], _, %{group: tenant_id}, vacant_websockets) do + :ets.delete(vacant_websockets, tenant_id) + end - def init(_args) do - interval = Application.get_env(:realtime, :metrics_cleaner_schedule_timer_in_ms) + def handle_syn_event([:syn, Realtime.Tenants.Connect, :unregistered], _, %{name: tenant_id}, disconnected_tenants) do + :ets.insert(disconnected_tenants, {tenant_id, DateTime.to_unix(DateTime.utc_now(), :second)}) + end + + def handle_syn_event([:syn, Realtime.Tenants.Connect, :registered], _, %{name: tenant_id}, disconnected_tenants) do + :ets.delete(disconnected_tenants, tenant_id) + end + + def start_link(opts), do: GenServer.start_link(__MODULE__, opts) + + # 10 minutes + @default_vacant_metric_threshold_in_seconds 600 + + @impl true + def init(opts) do + interval = + opts[:metrics_cleaner_schedule_timer_in_ms] || + Application.fetch_env!(:realtime, :metrics_cleaner_schedule_timer_in_ms) + + vacant_metric_threshold_in_seconds = + opts[:vacant_metric_threshold_in_seconds] || @default_vacant_metric_threshold_in_seconds Logger.info("Starting MetricsCleaner") - {:ok, %{check_ref: check(interval), interval: interval}} + + vacant_websockets = :ets.new(:vacant_websockets, [:set, :public, read_concurrency: false, write_concurrency: :auto]) + + disconnected_tenants = + :ets.new(:disconnected_tenants, [:set, :public, read_concurrency: false, write_concurrency: :auto]) + + :ok = + :telemetry.attach_many( + [self(), :vacant_websockets], + [[:forum, :users, :group, :occupied], [:forum, :users, :group, :vacant]], + &__MODULE__.handle_forum_event/4, + vacant_websockets + ) + + :ok = + :telemetry.attach_many( + [self(), :disconnected_tenants], + [[:syn, Realtime.Tenants.Connect, :registered], [:syn, Realtime.Tenants.Connect, :unregistered]], + &__MODULE__.handle_syn_event/4, + disconnected_tenants + ) + + {:ok, + %{ + check_ref: check(interval), + interval: interval, + vacant_metric_threshold_in_seconds: vacant_metric_threshold_in_seconds, + vacant_websockets: vacant_websockets, + disconnected_tenants: disconnected_tenants + }} end + @impl true + def terminate(_reason, _state) do + :telemetry.detach([self(), :vacant_websockets]) + :telemetry.detach([self(), :disconnected_tenants]) + :ok + end + + @impl true def handle_info(:check, %{interval: interval} = state) do Process.cancel_timer(state.check_ref) - {exec_time, _} = :timer.tc(fn -> loop_and_cleanup_metrics_table() end) + {exec_time, _} = + :timer.tc( + fn -> + loop_and_cleanup_metrics_table(state.vacant_websockets, state.vacant_metric_threshold_in_seconds) + loop_and_cleanup_metrics_table(state.disconnected_tenants, state.vacant_metric_threshold_in_seconds) + end, + :millisecond + ) if exec_time > :timer.seconds(5), do: Logger.warning("Metrics check took: #{exec_time} ms") @@ -31,33 +100,34 @@ defmodule Realtime.MetricsCleaner do {:noreply, state} end - defp check(interval) do - Process.send_after(self(), :check, interval) - end + defp check(interval), do: Process.send_after(self(), :check, interval) - @table_name :"syn_registry_by_name_Elixir.Realtime.Tenants.Connect" - @metrics_table Realtime.PromEx.Metrics - @filter_spec [{{{:_, %{tenant: :"$1"}}, :_}, [], [:"$1"]}] - @tenant_id_spec [{{:"$1", :_, :_, :_, :_, :_}, [], [:"$1"]}] - defp loop_and_cleanup_metrics_table do - tenant_ids = :ets.select(@table_name, @tenant_id_spec) + defp loop_and_cleanup_metrics_table(cleaner_table, vacant_metric_cleanup_threshold_in_seconds) do + threshold = + DateTime.utc_now() + |> DateTime.add(-vacant_metric_cleanup_threshold_in_seconds, :second) + |> DateTime.to_unix(:second) - :ets.select(@metrics_table, @filter_spec) - |> Enum.uniq() - |> Enum.reject(fn tenant_id -> tenant_id in tenant_ids end) - |> Enum.each(fn tenant_id -> delete_metric(tenant_id) end) - end + # We do this to have a consistent view of the table while we read and delete + :ets.safe_fixtable(cleaner_table, true) - @doc """ - Deletes all metrics that contain the given tenant or database_host. - """ - @spec delete_metric(String.t()) :: :ok - def delete_metric(tenant) do - :ets.select_delete(@metrics_table, [ - {{{:_, %{tenant: tenant}}, :_}, [], [true]}, - {{{:_, %{database_host: "db.#{tenant}.supabase.co"}}, :_}, [], [true]} - ]) + try do + # Look for tenant_ids that have been vacant for more than threshold + vacant_tenant_ids = + :ets.select(cleaner_table, [ + {{:"$1", :"$2"}, [{:<, :"$2", threshold}], [:"$1"]} + ]) - :ok + vacant_tenant_ids + |> Enum.map(fn tenant_id -> %{tenant: tenant_id} end) + |> then(&Peep.prune_tags(Realtime.TenantPromEx.Metrics, &1)) + + # Delete them from the table + :ets.select_delete(cleaner_table, [ + {{:"$1", :"$2"}, [{:<, :"$2", threshold}], [true]} + ]) + after + :ets.safe_fixtable(cleaner_table, false) + end end end diff --git a/lib/realtime/metrics_pusher.ex b/lib/realtime/metrics_pusher.ex new file mode 100644 index 000000000..8c3b9eaf9 --- /dev/null +++ b/lib/realtime/metrics_pusher.ex @@ -0,0 +1,166 @@ +defmodule Realtime.MetricsPusher do + @moduledoc """ + GenServer that periodically pushes Prometheus metrics to an endpoint. + + Only starts if `url` is configured. + Pushes metrics every 30 seconds (configurable) to the configured URL endpoint. + """ + + use GenServer + use Realtime.Logs + + require Logger + + defstruct [:push_ref, :interval, :req_options] + + @spec start_link(keyword()) :: {:ok, pid()} | :ignore + def start_link(opts) do + url = opts[:url] || Application.get_env(:realtime, :metrics_pusher_url) + + if is_binary(url) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + else + Logger.warning("MetricsPusher not started: url must be configured") + + :ignore + end + end + + @impl true + def init(opts) do + url = opts[:url] || Application.get_env(:realtime, :metrics_pusher_url) + user = opts[:user] || Application.get_env(:realtime, :metrics_pusher_user, "realtime") + auth = opts[:auth] || Application.get_env(:realtime, :metrics_pusher_auth) + + interval = + Keyword.get( + opts, + :interval, + Application.get_env(:realtime, :metrics_pusher_interval_ms, :timer.seconds(30)) + ) + + timeout = + Keyword.get( + opts, + :timeout, + Application.get_env(:realtime, :metrics_pusher_timeout_ms, :timer.seconds(15)) + ) + + compress = + Keyword.get( + opts, + :compress, + Application.get_env(:realtime, :metrics_pusher_compress, true) + ) + + extra_labels = + Keyword.get( + opts, + :extra_labels, + Application.get_env(:realtime, :metrics_pusher_extra_labels, []) + ) + + params = Enum.map(extra_labels, fn {k, v} -> {:extra_label, "#{k}=#{v}"} end) + + Logger.info("Starting MetricsPusher (url: #{url}, interval: #{interval}ms, compress: #{compress})") + + headers = [{"content-type", "text/plain"}] + + basic_auth = if auth, do: [auth: {:basic, "#{user}:#{auth}"}], else: [] + + req_options = + [ + method: :post, + url: url, + headers: headers, + compress_body: compress, + receive_timeout: timeout, + params: params + ] + |> Keyword.merge(basic_auth) + |> Keyword.merge(Application.get_env(:realtime, :metrics_pusher_req_options, [])) + + state = %__MODULE__{ + push_ref: schedule_push(interval), + interval: interval, + req_options: req_options + } + + {:ok, state} + end + + @impl true + def handle_info(:push, state) do + {exec_time, _} = :timer.tc(fn -> push(state.req_options) end, :millisecond) + + if exec_time > :timer.seconds(5) do + Logger.warning("Metrics push took: #{exec_time} ms") + end + + {:noreply, %{state | push_ref: schedule_push(state.interval)}} + end + + @impl true + def handle_info(msg, state) do + Logger.error("MetricsPusher received unexpected message: #{inspect(msg)}") + {:noreply, state} + end + + defp schedule_push(delay), do: Process.send_after(self(), :push, delay) + + defp push(req_options) do + tasks = [ + Task.Supervisor.async_nolink(Realtime.TaskSupervisor, fn -> + push_metrics("global", &Realtime.PromEx.get_global_metrics/0, req_options) + end), + Task.Supervisor.async_nolink(Realtime.TaskSupervisor, fn -> + push_metrics("tenant", &Realtime.TenantPromEx.get_metrics/0, req_options) + end) + ] + + tasks + |> Task.yield_many(:timer.minutes(1)) + |> Enum.each(fn + {task, nil} -> + Task.shutdown(task, :brutal_kill) + log_error("MetricsPusherTimeout", "MetricsPusher: Task timed out: #{inspect(task)}") + + {_task, {:exit, reason}} -> + log_error("MetricsPusherTaskExited", "MetricsPusher: Task exited with reason: #{inspect(reason)}") + + {_task, {:ok, _}} -> + :ok + end) + end + + defp push_metrics(label, get_metrics_fn, req_options) do + try do + metrics = get_metrics_fn.() + + case send_metrics(req_options, metrics) do + :ok -> + :ok + + {:error, reason} -> + log_error( + "MetricsPusherFailed", + "MetricsPusher: Failed to push #{label} metrics to #{req_options[:url]}: #{inspect(reason)}" + ) + + :ok + end + rescue + error -> + log_error("MetricsPusherException", "MetricsPusher: Exception during #{label} push: #{inspect(error)}") + :ok + end + end + + defp send_metrics(req_options, metrics) do + [{:body, metrics} | req_options] |> Req.request() |> handle_response() + end + + defp handle_response({:ok, %{status: status}}) when status in 200..299, do: :ok + defp handle_response({:ok, %{status: status} = response}), do: {:error, {:http_error, status, response.body}} + defp handle_response({:error, reason}), do: {:error, reason} +end diff --git a/lib/realtime/monitoring/erl_sys_mon.ex b/lib/realtime/monitoring/erl_sys_mon.ex index 32a4f857b..3278886d6 100644 --- a/lib/realtime/monitoring/erl_sys_mon.ex +++ b/lib/realtime/monitoring/erl_sys_mon.ex @@ -10,8 +10,8 @@ defmodule Realtime.ErlSysMon do @defaults [ :busy_dist_port, :busy_port, - {:long_gc, 250}, - {:long_schedule, 100}, + {:long_gc, 500}, + {:long_schedule, 500}, {:long_message_queue, {0, 1_000}} ] @@ -24,8 +24,36 @@ defmodule Realtime.ErlSysMon do {:ok, []} end + def handle_info({:monitor, pid, _type, _meta} = msg, state) when is_pid(pid) do + log_process_info(msg, pid) + {:noreply, state} + end + def handle_info(msg, state) do - Logger.error("#{__MODULE__} message: " <> inspect(msg)) + Logger.warning("#{__MODULE__} message: " <> inspect(msg)) {:noreply, state} end + + defp log_process_info(msg, pid) do + pid_info = + pid + |> Process.info(:dictionary) + |> case do + {:dictionary, dict} when is_list(dict) -> + {List.keyfind(dict, :"$initial_call", 0), List.keyfind(dict, :"$ancestors", 0)} + + other -> + other + end + + extra_info = Process.info(pid, [:registered_name, :message_queue_len, :total_heap_size]) + + Logger.warning( + "#{__MODULE__} message: " <> + inspect(msg) <> "|\n process info: #{inspect(pid_info)} #{inspect(extra_info)}" + ) + rescue + _ -> + Logger.warning("#{__MODULE__} message: " <> inspect(msg)) + end end diff --git a/lib/realtime/monitoring/latency.ex b/lib/realtime/monitoring/latency.ex index 52c46adb4..d9ddd0d9a 100644 --- a/lib/realtime/monitoring/latency.ex +++ b/lib/realtime/monitoring/latency.ex @@ -7,7 +7,7 @@ defmodule Realtime.Latency do use Realtime.Logs alias Realtime.Nodes - alias Realtime.Rpc + alias Realtime.GenRpc defmodule Payload do @moduledoc false @@ -33,7 +33,7 @@ defmodule Realtime.Latency do } end - @every 5_000 + @every 15_000 def start_link(args) do GenServer.start_link(__MODULE__, args, name: __MODULE__) end @@ -76,7 +76,7 @@ defmodule Realtime.Latency do Task.Supervisor.async(Realtime.TaskSupervisor, fn -> {latency, response} = :timer.tc(fn -> - Rpc.call(n, __MODULE__, :pong, [pong_timeout], timeout: timer_timeout) + GenRpc.call(n, __MODULE__, :pong, [pong_timeout], timeout: timer_timeout) end) latency_ms = latency / 1_000 @@ -85,7 +85,7 @@ defmodule Realtime.Latency do from_node = Nodes.short_node_id_from_name(Node.self()) case response do - {:badrpc, reason} -> + {:error, :rpc_error, reason} -> log_error( "RealtimeNodeDisconnected", "Unable to connect to #{short_name} from #{region}: #{reason}" diff --git a/lib/realtime/monitoring/peep/partitioned.ex b/lib/realtime/monitoring/peep/partitioned.ex new file mode 100644 index 000000000..5daa9d8a9 --- /dev/null +++ b/lib/realtime/monitoring/peep/partitioned.ex @@ -0,0 +1,160 @@ +defmodule Realtime.Monitoring.Peep.Partitioned do + @moduledoc """ + Peep.Storage implementation using a single ETS table with a configurable number of partitions + """ + alias Peep.Storage + alias Telemetry.Metrics + + @behaviour Peep.Storage + + @spec new(pos_integer) :: {:ets.tid(), pos_integer} + @impl true + def new(partitions) when is_integer(partitions) and partitions > 0 do + opts = [ + :public, + # Enabling read_concurrency makes switching between reads and writes + # more expensive. The goal is to ruthlessly optimize writes, even at + # the cost of read performance. + read_concurrency: false, + write_concurrency: true, + decentralized_counters: true + ] + + {:ets.new(__MODULE__, opts), partitions} + end + + @impl true + def storage_size({tid, _}) do + %{ + size: :ets.info(tid, :size), + memory: :ets.info(tid, :memory) * :erlang.system_info(:wordsize) + } + end + + @impl true + def insert_metric({tid, partitions}, id, %Metrics.Counter{}, _value, %{} = tags) do + key = {id, tags, :rand.uniform(partitions)} + :ets.update_counter(tid, key, {2, 1}, {key, 0}) + end + + def insert_metric({tid, partitions}, id, %Metrics.Sum{}, value, %{} = tags) do + key = {id, tags, :rand.uniform(partitions)} + :ets.update_counter(tid, key, {2, value}, {key, 0}) + end + + def insert_metric({tid, _partitions}, id, %Metrics.LastValue{}, value, %{} = tags) do + key = {id, tags} + :ets.insert(tid, {key, value}) + end + + def insert_metric({tid, _partitions}, id, %Metrics.Distribution{} = metric, value, %{} = tags) do + key = {id, tags} + + atomics = + case :ets.lookup(tid, key) do + [{_key, ref}] -> + ref + + [] -> + # Race condition: Multiple processes could be attempting + # to write to this key. Thankfully, :ets.insert_new/2 will break ties, + # and concurrent writers should agree on which :atomics object to + # increment. + new_atomics = Storage.Atomics.new(metric) + + case :ets.insert_new(tid, {key, new_atomics}) do + true -> + new_atomics + + false -> + [{_key, atomics}] = :ets.lookup(tid, key) + atomics + end + end + + Storage.Atomics.insert(atomics, value) + end + + @impl true + def get_all_metrics({tid, _partitions}, %Peep.Persistent{ids_to_metrics: itm}) do + :ets.tab2list(tid) + |> group_metrics(itm, %{}) + end + + @impl true + def get_metric({tid, _partitions}, id, %Metrics.Counter{}, tags) do + :ets.select(tid, [{{{id, :"$2", :_}, :"$1"}, [{:==, :"$2", tags}], [:"$1"]}]) + |> Enum.reduce(0, fn count, acc -> count + acc end) + end + + def get_metric({tid, _partitions}, id, %Metrics.Sum{}, tags) do + :ets.select(tid, [{{{id, :"$2", :_}, :"$1"}, [{:==, :"$2", tags}], [:"$1"]}]) + |> Enum.reduce(0, fn count, acc -> count + acc end) + end + + def get_metric({tid, _partitions}, id, %Metrics.LastValue{}, tags) do + case :ets.lookup(tid, {id, tags}) do + [{_key, value}] -> value + _ -> nil + end + end + + def get_metric({tid, _partitions}, id, %Metrics.Distribution{}, tags) do + key = {id, tags} + + case :ets.lookup(tid, key) do + [{_key, atomics}] -> Storage.Atomics.values(atomics) + _ -> nil + end + end + + @impl true + def prune_tags({tid, _partitions}, patterns) do + match_spec = + patterns + |> Enum.flat_map(fn pattern -> + counter_or_sum_key = {:_, pattern, :_} + dist_or_last_value_key = {:_, pattern} + + [ + { + {counter_or_sum_key, :_}, + [], + [true] + }, + { + {dist_or_last_value_key, :_}, + [], + [true] + } + ] + end) + + :ets.select_delete(tid, match_spec) + :ok + end + + defp group_metrics([], _itm, acc) do + acc + end + + defp group_metrics([metric | rest], itm, acc) do + acc2 = group_metric(metric, itm, acc) + group_metrics(rest, itm, acc2) + end + + defp group_metric({{id, tags, _}, value}, itm, acc) do + %{^id => metric} = itm + update_in(acc, [Access.key(metric, %{}), Access.key(tags, 0)], &(&1 + value)) + end + + defp group_metric({{id, tags}, %Storage.Atomics{} = atomics}, itm, acc) do + %{^id => metric} = itm + put_in(acc, [Access.key(metric, %{}), Access.key(tags)], Storage.Atomics.values(atomics)) + end + + defp group_metric({{id, tags}, value}, itm, acc) do + %{^id => metric} = itm + put_in(acc, [Access.key(metric, %{}), Access.key(tags)], value) + end +end diff --git a/lib/realtime/monitoring/peep/partitioned_tables.ex b/lib/realtime/monitoring/peep/partitioned_tables.ex new file mode 100644 index 000000000..4bbbb9395 --- /dev/null +++ b/lib/realtime/monitoring/peep/partitioned_tables.ex @@ -0,0 +1,168 @@ +defmodule Realtime.Monitoring.Peep.PartitionedTables do + @moduledoc """ + Peep.Storage implementation using N ETS tables with optional tag-based routing. + + Each metric write is routed to a specific table based on a `:routing_tag` option. + If the routing tag is present in the metric's tags, `:erlang.phash2/2` is used to + select the table. Otherwise, the first table is used. + + This reduces lock contention by routing different tag values (e.g. different tenants) + to different ETS tables, without partitioning metrics within a table. + + ## Options + + * `:tables` - number of ETS tables to create (default: `1`) + * `:routing_tag` - atom key used to select the target table (default: `nil`) + + ## Example + + {Realtime.Monitoring.Peep.PartitionedTables, [tables: 4, routing_tag: :tenant_id]} + """ + + alias Peep.Storage + alias Telemetry.Metrics + + @behaviour Peep.Storage + + @typep tids() :: tuple() + @typep state() :: {tids(), atom() | nil} + + @spec new(keyword()) :: state() + @impl true + def new(opts) do + n_tables = Keyword.get(opts, :tables, 1) + routing_tag = Keyword.get(opts, :routing_tag, nil) + + ets_opts = [ + :public, + read_concurrency: false, + write_concurrency: true, + decentralized_counters: true + ] + + tids = List.to_tuple(Enum.map(1..n_tables, fn _ -> :ets.new(__MODULE__, ets_opts) end)) + {tids, routing_tag} + end + + @impl true + def storage_size({tids, _routing_tag}) do + {size, memory} = + tids + |> Tuple.to_list() + |> Enum.reduce({0, 0}, fn tid, {size, memory} -> + {size + :ets.info(tid, :size), memory + :ets.info(tid, :memory)} + end) + + %{ + size: size, + memory: memory * :erlang.system_info(:wordsize) + } + end + + @impl true + def insert_metric({tids, routing_tag}, id, %Metrics.Counter{}, _value, %{} = tags) do + tid = get_tid(tids, routing_tag, tags) + key = {id, tags} + :ets.update_counter(tid, key, {2, 1}, {key, 0}) + end + + def insert_metric({tids, routing_tag}, id, %Metrics.Sum{}, value, %{} = tags) do + tid = get_tid(tids, routing_tag, tags) + key = {id, tags} + :ets.update_counter(tid, key, {2, value}, {key, 0}) + end + + def insert_metric({tids, routing_tag}, id, %Metrics.LastValue{}, value, %{} = tags) do + tid = get_tid(tids, routing_tag, tags) + key = {id, tags} + :ets.insert(tid, {key, value}) + end + + def insert_metric({tids, routing_tag}, id, %Metrics.Distribution{} = metric, value, %{} = tags) do + tid = get_tid(tids, routing_tag, tags) + key = {id, tags} + + atomics = + case :ets.lookup(tid, key) do + [{_key, ref}] -> + ref + + [] -> + # Race condition: Multiple processes could be attempting to write to this key. + # :ets.insert_new/2 breaks ties so concurrent writers agree on which + # :atomics object to increment. + new_atomics = Storage.Atomics.new(metric) + + case :ets.insert_new(tid, {key, new_atomics}) do + true -> + new_atomics + + false -> + [{_key, atomics}] = :ets.lookup(tid, key) + atomics + end + end + + Storage.Atomics.insert(atomics, value) + end + + @impl true + def get_all_metrics({tids, _routing_tag}, %Peep.Persistent{ids_to_metrics: itm}) do + tids + |> Tuple.to_list() + |> Enum.flat_map(&:ets.tab2list/1) + |> Enum.reduce(%{}, fn {{id, tags}, value}, acc -> + %{^id => metric} = itm + put_in(acc, [Access.key(metric, %{}), tags], to_value(value)) + end) + end + + @impl true + def get_metric({tids, routing_tag}, id, %Metrics.Distribution{}, tags) do + tid = get_tid(tids, routing_tag, tags) + key = {id, tags} + + case :ets.lookup(tid, key) do + [] -> nil + [{_key, atomics}] -> Storage.Atomics.values(atomics) + end + end + + def get_metric({tids, routing_tag}, id, metric, tags) do + tid = get_tid(tids, routing_tag, tags) + key = {id, tags} + + case :ets.lookup(tid, key) do + [] -> empty_value(metric) + [{_, value}] -> value + end + end + + defp empty_value(%Metrics.Counter{}), do: 0 + defp empty_value(%Metrics.Sum{}), do: 0 + defp empty_value(%Metrics.LastValue{}), do: nil + + @impl true + def prune_tags({tids, routing_tag}, patterns) do + patterns + |> Enum.group_by(&get_tid(tids, routing_tag, &1)) + |> Enum.each(fn {tid, grouped} -> + match_spec = Enum.map(grouped, fn pattern -> {{{:_, pattern}, :_}, [], [true]} end) + :ets.select_delete(tid, match_spec) + end) + + :ok + end + + defp get_tid(tids, nil, _tags), do: elem(tids, 0) + + defp get_tid(tids, routing_tag, tags) do + case Map.fetch(tags, routing_tag) do + {:ok, value} -> elem(tids, :erlang.phash2(value, tuple_size(tids))) + :error -> elem(tids, 0) + end + end + + defp to_value(%Storage.Atomics{} = atomics), do: Storage.Atomics.values(atomics) + defp to_value(value), do: value +end diff --git a/lib/realtime/monitoring/prom_ex.ex b/lib/realtime/monitoring/prom_ex.ex index 9c0db0d87..a4208f59d 100644 --- a/lib/realtime/monitoring/prom_ex.ex +++ b/lib/realtime/monitoring/prom_ex.ex @@ -1,14 +1,20 @@ defmodule Realtime.PromEx do - alias Realtime.Nodes - alias Realtime.PromEx.Plugins.Channels alias Realtime.PromEx.Plugins.Distributed alias Realtime.PromEx.Plugins.GenRpc + alias Realtime.PromEx.Plugins.Migrations alias Realtime.PromEx.Plugins.OsMon alias Realtime.PromEx.Plugins.Phoenix - alias Realtime.PromEx.Plugins.Tenant + alias Realtime.PromEx.Plugins.TenantGlobal alias Realtime.PromEx.Plugins.Tenants @moduledoc """ + PromEx configuration for global metrics (BEAM, OS, Phoenix, distributed infrastructure). + + These are higher-priority metrics. Configure your Victoria Metrics scrape interval + lower compared to the tenant metrics endpoint. + + Exposes metrics via `/metrics` and `/metrics/:region`. + Be sure to add the following to finish setting up PromEx: 1. Update your configuration (config.exs, dev.exs, prod.exs, releases.exs, etc) to @@ -65,6 +71,29 @@ defmodule Realtime.PromEx do alias PromEx.Plugins + defmodule Store do + @moduledoc false + # Custom store to set global tags + + @behaviour PromEx.Storage + + @impl true + def scrape(name) do + Peep.get_all_metrics(name) + |> Realtime.Monitoring.Prometheus.export() + end + + @impl true + def child_spec(name, metrics) do + Peep.child_spec( + name: name, + metrics: metrics, + global_tags: Application.get_env(:realtime, :metrics_tags, %{}), + storage: {Realtime.Monitoring.Peep.PartitionedTables, tables: 4, routing_tag: :tenant} + ) + end + end + @impl true def plugins do poll_rate = Application.get_env(:realtime, :prom_poll_rate) @@ -73,9 +102,9 @@ defmodule Realtime.PromEx do {Plugins.Beam, poll_rate: poll_rate, metric_prefix: [:beam]}, {Phoenix, router: RealtimeWeb.Router, poll_rate: poll_rate, metric_prefix: [:phoenix]}, {OsMon, poll_rate: poll_rate}, + {Migrations, poll_rate: poll_rate}, {Tenants, poll_rate: poll_rate}, - {Tenant, poll_rate: poll_rate}, - {Channels, poll_rate: poll_rate}, + {TenantGlobal, poll_rate: poll_rate}, {Distributed, poll_rate: poll_rate}, {GenRpc, poll_rate: poll_rate} ] @@ -104,29 +133,8 @@ defmodule Realtime.PromEx do ] end - def get_metrics do - %{ - region: region, - node_host: node_host, - short_alloc_id: short_alloc_id - } = get_metrics_tags() - - def_tags = "host=\"#{node_host}\",region=\"#{region}\",id=\"#{short_alloc_id}\"" - - metrics = - PromEx.get_metrics(Realtime.PromEx) - |> String.split("\n") - |> Enum.map_join("\n", fn line -> - case Regex.run(~r/(?!\#)^(\w+)(?:{(.*?)})?\s*(.+)$/, line) do - nil -> - line - - [_, key, tags, value] -> - tags = if tags == "", do: def_tags, else: tags <> "," <> def_tags - - "#{key}{#{tags}} #{value}" - end - end) + def get_global_metrics do + metrics = PromEx.get_metrics(Realtime.PromEx) Realtime.PromEx.__ets_cron_flusher_name__() |> PromEx.ETSCronFlusher.defer_ets_flush() @@ -134,26 +142,6 @@ defmodule Realtime.PromEx do metrics end - @doc "Compressed metrics using :zlib.compress/1" - @spec get_compressed_metrics() :: binary() - def get_compressed_metrics do - get_metrics() - |> :zlib.compress() - end - - def set_metrics_tags do - [_, node_host] = node() |> Atom.to_string() |> String.split("@") - - metrics_tags = %{ - region: Application.get_env(:realtime, :region), - node_host: node_host, - short_alloc_id: Nodes.short_node_id_from_name(node()) - } - - Application.put_env(:realtime, :metrics_tags, metrics_tags) - end - - def get_metrics_tags do - Application.get_env(:realtime, :metrics_tags) - end + @doc deprecated: "Use get_global_metrics/0 instead" + def get_metrics, do: get_global_metrics() end diff --git a/lib/realtime/monitoring/prom_ex/plugins/channels.ex b/lib/realtime/monitoring/prom_ex/plugins/channels.ex index 357838f21..e0c3113fb 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/channels.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/channels.ex @@ -3,16 +3,14 @@ defmodule Realtime.PromEx.Plugins.Channels do Realtime channels monitoring plugin for PromEx """ use PromEx.Plugin - require Logger - @impl true def event_metrics(_opts) do Event.build(:realtime, [ counter( [:realtime, :channel, :error], event_name: [:realtime, :channel, :error], - measurement: :code, - tags: [:code], + measurement: :count, + tags: [:code, :tenant], description: "Count of errors in the Realtime channels initialization" ) ]) diff --git a/lib/realtime/monitoring/prom_ex/plugins/distributed.ex b/lib/realtime/monitoring/prom_ex/plugins/distributed.ex index 060f28036..927b8ac88 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/distributed.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/distributed.ex @@ -70,7 +70,8 @@ defmodule Realtime.PromEx.Plugins.Distributed do measurement: :size, tags: [:origin_node, :target_node] ) - ] + ], + detach_on_error: false ) end diff --git a/lib/realtime/monitoring/prom_ex/plugins/gen_rpc.ex b/lib/realtime/monitoring/prom_ex/plugins/gen_rpc.ex index a4542a889..59e32c4ef 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/gen_rpc.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/gen_rpc.ex @@ -71,7 +71,8 @@ defmodule Realtime.PromEx.Plugins.GenRpc do measurement: :size, tags: [:origin_node, :target_node] ) - ] + ], + detach_on_error: false ) end diff --git a/lib/realtime/monitoring/prom_ex/plugins/migrations.ex b/lib/realtime/monitoring/prom_ex/plugins/migrations.ex new file mode 100644 index 000000000..0eef1447e --- /dev/null +++ b/lib/realtime/monitoring/prom_ex/plugins/migrations.ex @@ -0,0 +1,48 @@ +defmodule Realtime.PromEx.Plugins.Migrations do + @moduledoc """ + Tenant migration metrics. + """ + + use PromEx.Plugin + + defmodule Buckets do + @moduledoc false + use Peep.Buckets.Custom, + buckets: [100, 250, 500, 1_000, 2_000, 5_000, 10_000, 20_000, 30_000, 45_000, 60_000, 90_000, 120_000] + end + + @impl true + def event_metrics(_opts) do + Event.build(:realtime_tenants_migrations, [ + distribution( + [:realtime, :tenants, :migrations, :duration, :milliseconds], + event_name: [:realtime, :tenants, :migrations, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + description: "Tenant migrations duration", + keep: &__MODULE__.migrations_executed/1, + tags: [:platform_region], + reporter_options: [peep_bucket_calculator: Buckets] + ), + counter( + [:realtime, :tenants, :migrations, :exceptions, :total], + event_name: [:realtime, :tenants, :migrations, :exception], + tags: [:error_code], + description: "Count of failed tenant migrations" + ), + counter( + [:realtime, :tenants, :migrations, :reconcile, :total], + event_name: [:realtime, :tenants, :migrations, :reconcile, :stop], + description: "Count of reconciled tenant migrations" + ), + counter( + [:realtime, :tenants, :migrations, :reconcile, :exceptions, :total], + event_name: [:realtime, :tenants, :migrations, :reconcile, :exception], + description: "Count of failed migrations_ran reconciliations" + ) + ]) + end + + def migrations_executed(%{migrations_executed: n}) when is_integer(n) and n > 0, do: true + def migrations_executed(_), do: false +end diff --git a/lib/realtime/monitoring/prom_ex/plugins/osmon.ex b/lib/realtime/monitoring/prom_ex/plugins/osmon.ex index 67d1fcb71..a89a8672c 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/osmon.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/osmon.ex @@ -4,7 +4,6 @@ defmodule Realtime.PromEx.Plugins.OsMon do """ use PromEx.Plugin - require Logger alias Realtime.OsMetrics @event_ram_usage [:prom_ex, :plugin, :osmon, :ram_usage] @@ -57,7 +56,8 @@ defmodule Realtime.PromEx.Plugins.OsMon do description: "The average system load in the last 15 minutes.", measurement: :avg15 ) - ] + ], + detach_on_error: false ) end diff --git a/lib/realtime/monitoring/prom_ex/plugins/phoenix.ex b/lib/realtime/monitoring/prom_ex/plugins/phoenix.ex index d3f64afbe..425dff132 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/phoenix.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/phoenix.ex @@ -5,8 +5,6 @@ if Code.ensure_loaded?(Phoenix) do @moduledoc false use PromEx.Plugin - require Logger - alias Phoenix.Socket alias RealtimeWeb.Endpoint.HTTP, as: HTTP @@ -49,26 +47,42 @@ if Code.ensure_loaded?(Phoenix) do metric_prefix ++ [:connections, :total], event_name: @event_all_connections, description: "The total open connections to ranch.", + measurement: :total + ), + last_value( + metric_prefix ++ [:connections, :active], + event_name: @event_all_connections, + description: "Connections actively processing a request or WebSocket frame.", measurement: :active + ), + last_value( + metric_prefix ++ [:connections, :max], + event_name: @event_all_connections, + description: "The configured maximum connections limit for the ranch listener.", + measurement: :max ) - ] + ], + detach_on_error: false ) end def execute_metrics do - active_conn = - case :ets.lookup(:ranch_server, {:listener_sup, HTTP}) do - [] -> - -1 - - _ -> - HTTP - |> :ranch_server.get_connections_sup() - |> :supervisor.count_children() - |> Keyword.get(:active) - end - - :telemetry.execute(@event_all_connections, %{active: active_conn}, %{}) + info = if :ranch.info()[HTTP], do: :ranch.info(HTTP), else: %{} + + :telemetry.execute( + @event_all_connections, + %{ + total: Map.get(info, :all_connections, -1), + active: Map.get(info, :active_connections, -1), + max: Map.get(info, :max_connections, -1) + }, + %{} + ) + end + + defmodule Buckets do + @moduledoc false + use Peep.Buckets.Custom, buckets: [10, 100, 500, 1_000, 5_000, 10_000] end defp channel_events(metric_prefix) do @@ -99,9 +113,7 @@ if Code.ensure_loaded?(Phoenix) do event_name: [:phoenix, :channel_handled_in], measurement: :duration, description: "The time it takes for the application to respond to channel messages.", - reporter_options: [ - buckets: [10, 100, 500, 1_000, 5_000, 10_000] - ], + reporter_options: [peep_bucket_calculator: Buckets], tag_values: fn %{socket: %Socket{endpoint: endpoint}} -> %{ endpoint: normalize_module_name(endpoint) @@ -124,17 +136,16 @@ if Code.ensure_loaded?(Phoenix) do event_name: [:phoenix, :socket_connected], measurement: :duration, description: "The time it takes for the application to establish a socket connection.", - reporter_options: [ - buckets: [10, 100, 500, 1_000, 5_000, 10_000] - ], - tag_values: fn %{result: result, endpoint: endpoint, transport: transport} -> + reporter_options: [peep_bucket_calculator: Buckets], + tag_values: fn %{result: result, endpoint: endpoint, transport: transport, serializer: serializer} -> %{ transport: transport, result: result, - endpoint: normalize_module_name(endpoint) + endpoint: normalize_module_name(endpoint), + serializer: serializer } end, - tags: [:result, :transport, :endpoint], + tags: [:result, :transport, :endpoint, :serializer], unit: {:native, :millisecond} ) ] diff --git a/lib/realtime/monitoring/prom_ex/plugins/tenant.ex b/lib/realtime/monitoring/prom_ex/plugins/tenant.ex index 1bd324624..4a91f5cd9 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/tenant.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/tenant.ex @@ -2,7 +2,6 @@ defmodule Realtime.PromEx.Plugins.Tenant do @moduledoc false use PromEx.Plugin - require Logger alias Realtime.Telemetry alias Realtime.Tenants alias Realtime.UsersCounter @@ -18,15 +17,20 @@ defmodule Realtime.PromEx.Plugins.Tenant do @impl true def event_metrics(_opts) do - # Event metrics definitions [ channel_events(), + payload_size_metrics(), replication_metrics(), - subscription_metrics(), - payload_size_metrics() + subscription_metrics() ] end + defmodule PayloadSize.Buckets do + @moduledoc false + use Peep.Buckets.Custom, + buckets: [250, 500, 1000, 3000, 5000, 10_000, 25_000, 100_000, 500_000, 1_000_000, 3_000_000] + end + defp payload_size_metrics do Event.build( :realtime_tenant_payload_size_metrics, @@ -36,21 +40,9 @@ defmodule Realtime.PromEx.Plugins.Tenant do event_name: [:realtime, :tenants, :payload, :size], measurement: :size, description: "Tenant payload size", - tags: [:tenant], - unit: :byte, - reporter_options: [ - buckets: [100, 250, 500, 1000, 2000, 3000, 5000, 10_000, 25_000] - ] - ), - distribution( - [:realtime, :payload, :size], - event_name: [:realtime, :tenants, :payload, :size], - measurement: :size, - description: "Payload size", + tags: [:tenant, :message_type], unit: :byte, - reporter_options: [ - buckets: [100, 250, 500, 1000, 2000, 3000, 5000, 10_000, 25_000] - ] + reporter_options: [peep_bucket_calculator: PayloadSize.Buckets] ) ] ) @@ -75,36 +67,39 @@ defmodule Realtime.PromEx.Plugins.Tenant do description: "The cluster total count of connected clients for a tenant.", measurement: :connected_cluster, tags: [:tenant] - ), - last_value( - [:realtime, :connections, :limit_concurrent], - event_name: [:realtime, :connections], - description: "The total count of connected clients for a tenant.", - measurement: :limit, - tags: [:tenant] ) - ] + ], + detach_on_error: false ) end def execute_tenant_metrics do - tenants = Tenants.list_connected_tenants(Node.self()) + cluster_counts = UsersCounter.tenant_counts() + local_tenant_counts = UsersCounter.local_tenant_counts() - for t <- tenants do - count = UsersCounter.tenant_users(Node.self(), t) - cluster_count = UsersCounter.tenant_users(t) + for {t, count} <- local_tenant_counts do tenant = Tenants.Cache.get_tenant_by_external_id(t) if tenant != nil do Telemetry.execute( [:realtime, :connections], - %{connected: count, connected_cluster: cluster_count, limit: tenant.max_concurrent_users}, + %{ + connected: count, + connected_cluster: Map.get(cluster_counts, t, 0), + limit: tenant.max_concurrent_users + }, %{tenant: t} ) end end end + defmodule Replication.Buckets do + @moduledoc false + use Peep.Buckets.Custom, + buckets: [250, 500, 1000, 3000, 5000, 10_000, 25_000, 100_000, 500_000, 1_000_000, 3_000_000] + end + defp replication_metrics do Event.build( :realtime_tenant_replication_event_metrics, @@ -116,36 +111,70 @@ defmodule Realtime.PromEx.Plugins.Tenant do description: "Duration of the logical replication slot polling query for Realtime RLS.", tags: [:tenant], unit: {:microsecond, :millisecond}, - reporter_options: [ - buckets: [125, 250, 500, 1_000, 2_000, 4_000, 8_000, 16_000] - ] - ) - ] - ) - end - - defp subscription_metrics do - Event.build( - :realtime_tenant_channel_event_metrics, - [ - sum( - [:realtime, :subscriptions_checker, :pid_not_found], - event_name: [:realtime, :subscriptions_checker, :pid_not_found], - measurement: :sum, - description: "Sum of pids not found in Subscription tables.", + reporter_options: [peep_bucket_calculator: Replication.Buckets] + ), + counter( + [:realtime, :replication, :poller, :stop, :total], + event_name: [:realtime, :replication, :poller, :stop], + description: + "How many times the tenant's Postgres Changes poller terminated, split by reason. reason=max_retries_reached is the give-up: it is not restarted, so the tenant's subscriptions stop broadcasting until it reconnects.", + tags: [:tenant, :reason], + tag_values: &poller_stop_tags/1 + ), + counter( + [:realtime, :replication, :poller, :exception, :total], + event_name: [:realtime, :replication, :poller, :exception], + description: "How many times the tenant's Postgres Changes poller crashed (terminated abnormally).", + tags: [:tenant] + ), + counter( + [:realtime, :replication, :poller, :query, :exception, :total], + event_name: [:realtime, :replication, :poller, :query, :exception], + description: + "How many of the tenant's polls failed reading changes from the replication slot, split by reason. reason=object_in_use means another backend held the slot. Sustained values come before its poller gives up.", + tags: [:tenant, :reason], + tag_values: &poller_query_exception_tags/1 + ), + counter( + [:realtime, :replication, :poller, :prepare, :exception, :total], + event_name: [:realtime, :replication, :poller, :prepare, :exception], + description: "How many of the tenant's attempts to prepare the replication slot for polling failed.", tags: [:tenant] ), sum( - [:realtime, :subscriptions_checker, :phantom_pid_detected], - event_name: [:realtime, :subscriptions_checker, :phantom_pid_detected], - measurement: :sum, - description: "Sum of phantom pids detected in Subscription tables.", + [:realtime, :replication, :poller, :changes, :dispatch], + event_name: [:realtime, :replication, :poller, :changes, :dispatch], + measurement: :count, + description: "Number of Postgres Changes rows the poller broadcast to subscribers.", tags: [:tenant] + ), + sum( + [:realtime, :replication, :poller, :changes, :skip], + event_name: [:realtime, :replication, :poller, :changes, :skip], + measurement: :count, + description: + "Number of Postgres Changes rows skipped without broadcasting, tagged by reason. reason=rate_limited means the tenant's db events-per-second limit was triggered.", + tags: [:tenant, :reason] ) ] ) end + defmodule PolicyAuthorization.Buckets do + @moduledoc false + use Peep.Buckets.Custom, buckets: [10, 250, 5000, 15_000] + end + + defmodule BroadcastFromDatabase.Buckets do + @moduledoc false + use Peep.Buckets.Custom, buckets: [10, 250, 5000] + end + + defmodule Replay.Buckets do + @moduledoc false + use Peep.Buckets.Custom, buckets: [10, 250, 5000, 15_000] + end + defp channel_events do Event.build( :realtime_tenant_channel_event_metrics, @@ -178,18 +207,18 @@ defmodule Realtime.PromEx.Plugins.Tenant do description: "Sum of Realtime Channel joins.", tags: [:tenant] ), - last_value( - [:realtime, :channel, :events, :limit_per_second], - event_name: [:realtime, :rate_counter, :channel, :events], - measurement: :limit, - description: "Rate limit of messages per second sent on a Realtime Channel.", + sum( + [:realtime, :channel, :input_bytes], + event_name: [:realtime, :channel, :input_bytes], + description: "Sum of input bytes sent on sockets.", + measurement: :size, tags: [:tenant] ), - last_value( - [:realtime, :channel, :joins, :limit_per_second], - event_name: [:realtime, :rate_counter, :channel, :joins], - measurement: :limit, - description: "Rate limit of joins per second on a Realtime Channel.", + sum( + [:realtime, :channel, :output_bytes], + event_name: [:realtime, :channel, :output_bytes], + description: "Sum of output bytes sent on sockets.", + measurement: :size, tags: [:tenant] ), distribution( @@ -199,7 +228,7 @@ defmodule Realtime.PromEx.Plugins.Tenant do unit: :millisecond, description: "Latency of read authorization checks.", tags: [:tenant], - reporter_options: [buckets: [10, 250, 5000, 15_000]] + reporter_options: [peep_bucket_calculator: PolicyAuthorization.Buckets] ), distribution( [:realtime, :tenants, :write_authorization_check], @@ -208,7 +237,7 @@ defmodule Realtime.PromEx.Plugins.Tenant do unit: :millisecond, description: "Latency of write authorization checks.", tags: [:tenant], - reporter_options: [buckets: [10, 250, 5000, 15_000]] + reporter_options: [peep_bucket_calculator: PolicyAuthorization.Buckets] ), distribution( [:realtime, :tenants, :broadcast_from_database, :latency_committed_at], @@ -217,18 +246,75 @@ defmodule Realtime.PromEx.Plugins.Tenant do unit: :millisecond, description: "Latency of database transaction start until reaches server to be broadcasted", tags: [:tenant], - reporter_options: [buckets: [10, 250, 5000]] + reporter_options: [peep_bucket_calculator: BroadcastFromDatabase.Buckets] ), distribution( [:realtime, :tenants, :broadcast_from_database, :latency_inserted_at], event_name: [:realtime, :tenants, :broadcast_from_database], measurement: :latency_inserted_at, - unit: :second, + unit: {:microsecond, :millisecond}, description: "Latency of database inserted_at until reaches server to be broadcasted", tags: [:tenant], - reporter_options: [buckets: [1, 2, 5]] + reporter_options: [peep_bucket_calculator: BroadcastFromDatabase.Buckets] + ), + distribution( + [:realtime, :tenants, :replay], + event_name: [:realtime, :tenants, :replay], + measurement: :latency, + unit: :millisecond, + description: "Latency of broadcast replay", + tags: [:tenant], + reporter_options: [peep_bucket_calculator: Replay.Buckets] ) ] ) end + + defp subscription_metrics do + Event.build( + :realtime_tenant_subscription_event_metrics, + [ + last_value( + [:realtime, :subscriptions, :manager, :subscribers], + event_name: [:realtime, :subscriptions, :manager, :subscribers], + measurement: :count, + description: + "Number of Postgres Changes subscribers tracked for the tenant across the cluster. A drop to zero while clients are connected points at a pool that lost its subscriptions.", + tags: [:tenant] + ), + sum( + [:realtime, :subscriptions, :manager, :dead_pid], + event_name: [:realtime, :subscriptions, :manager, :dead_pid], + measurement: :quantity, + description: + "Number of not-alive subscriber pids the manager handled, tagged by reason. reason=phantom is a dead pid still holding a subscription that was reaped (subscription churn or leak); reason=not_found is a dead pid already gone from the pool (benign race).", + tags: [:tenant, :reason] + ) + ] + ) + end + + defp poller_stop_tags(metadata) do + reason = + case metadata.reason do + {:shutdown, :max_retries_reached} -> :max_retries_reached + {:shutdown, _} -> :shutdown + :shutdown -> :shutdown + :normal -> :normal + _ -> :other + end + + %{tenant: metadata.tenant, reason: reason} + end + + defp poller_query_exception_tags(metadata) do + reason = + case metadata.reason do + :object_in_use -> :object_in_use + %Postgrex.Error{postgres: %{code: code}} -> code + _ -> :other + end + + %{tenant: metadata.tenant, reason: reason} + end end diff --git a/lib/realtime/monitoring/prom_ex/plugins/tenant_global.ex b/lib/realtime/monitoring/prom_ex/plugins/tenant_global.ex new file mode 100644 index 000000000..4f9d1580e --- /dev/null +++ b/lib/realtime/monitoring/prom_ex/plugins/tenant_global.ex @@ -0,0 +1,134 @@ +defmodule Realtime.PromEx.Plugins.TenantGlobal do + @moduledoc """ + Global aggregated variants of per-tenant metrics. + + Subscribes to the same telemetry events as the Tenant plugin but records + metrics without the tenant tag, enabling cluster-wide aggregation. + These live on the global endpoint (/metrics) for high-priority scraping. + """ + + use PromEx.Plugin + alias Realtime.PromEx.Plugins.Tenant + alias Realtime.Telemetry + alias Realtime.UsersCounter + + @global_connections_event [:prom_ex, :plugin, :realtime, :connections, :global] + + @impl true + def polling_metrics(opts) do + poll_rate = Keyword.get(opts, :poll_rate, 5_000) + + [ + Polling.build( + :realtime_global_connections, + poll_rate, + {__MODULE__, :execute_global_connection_metrics, []}, + [ + last_value( + [:realtime, :connections, :global, :connected], + event_name: @global_connections_event, + description: "The node total count of connected clients across all tenants.", + measurement: :connected + ), + last_value( + [:realtime, :connections, :global, :connected_cluster], + event_name: @global_connections_event, + description: "The cluster total count of connected clients across all tenants.", + measurement: :connected_cluster + ) + ], + detach_on_error: false + ) + ] + end + + @impl true + def event_metrics(_opts) do + [ + channel_global_events(), + payload_global_size_metrics() + ] + end + + def execute_global_connection_metrics do + cluster_counts = UsersCounter.tenant_counts() + local_tenant_counts = UsersCounter.local_tenant_counts() + + connected = local_tenant_counts |> Map.values() |> Enum.sum() + connected_cluster = cluster_counts |> Map.values() |> Enum.sum() + + Telemetry.execute( + @global_connections_event, + %{connected: connected, connected_cluster: connected_cluster}, + %{} + ) + end + + defp payload_global_size_metrics do + Event.build( + :realtime_global_payload_size_metrics, + [ + distribution( + [:realtime, :payload, :size], + event_name: [:realtime, :tenants, :payload, :size], + measurement: :size, + description: "Global payload size across all tenants", + tags: [:message_type], + unit: :byte, + reporter_options: [peep_bucket_calculator: Tenant.PayloadSize.Buckets] + ) + ] + ) + end + + defp channel_global_events do + Event.build( + :realtime_global_channel_event_metrics, + [ + sum( + [:realtime, :channel, :global, :events], + event_name: [:realtime, :rate_counter, :channel, :events], + measurement: :sum, + description: "Global sum of messages sent on a Realtime Channel." + ), + sum( + [:realtime, :channel, :global, :presence_events], + event_name: [:realtime, :rate_counter, :channel, :presence_events], + measurement: :sum, + description: "Global sum of presence messages sent on a Realtime Channel." + ), + sum( + [:realtime, :channel, :global, :db_events], + event_name: [:realtime, :rate_counter, :channel, :db_events], + measurement: :sum, + description: "Global sum of db messages sent on a Realtime Channel." + ), + sum( + [:realtime, :channel, :global, :joins], + event_name: [:realtime, :rate_counter, :channel, :joins], + measurement: :sum, + description: "Global sum of Realtime Channel joins." + ), + sum( + [:realtime, :channel, :global, :input_bytes], + event_name: [:realtime, :channel, :input_bytes], + description: "Global sum of input bytes sent on sockets.", + measurement: :size + ), + sum( + [:realtime, :channel, :global, :output_bytes], + event_name: [:realtime, :channel, :output_bytes], + description: "Global sum of output bytes sent on sockets.", + measurement: :size + ), + counter( + [:realtime, :channel, :global, :error], + event_name: [:realtime, :channel, :error], + measurement: :count, + tags: [:code], + description: "Global count of errors in Realtime channel initialization." + ) + ] + ) + end +end diff --git a/lib/realtime/monitoring/prom_ex/plugins/tenants.ex b/lib/realtime/monitoring/prom_ex/plugins/tenants.ex index 0035e9594..2604eceac 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/tenants.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/tenants.ex @@ -6,7 +6,10 @@ defmodule Realtime.PromEx.Plugins.Tenants do alias PromEx.MetricTypes.Event alias Realtime.Tenants.Connect - require Logger + defmodule Buckets do + @moduledoc false + use Peep.Buckets.Custom, buckets: [10, 250, 5000, 15_000] + end @event_connected [:prom_ex, :plugin, :realtime, :tenants, :connected] @@ -14,13 +17,13 @@ defmodule Realtime.PromEx.Plugins.Tenants do def event_metrics(_) do Event.build(:realtime, [ distribution( - [:realtime, :rpc], + [:realtime, :global, :rpc], event_name: [:realtime, :rpc], - description: "Latency of rpc calls triggered by a tenant action", + description: "Global Latency of rpc calls", measurement: :latency, unit: {:microsecond, :millisecond}, - tags: [:success, :tenant, :mechanism], - reporter_options: [buckets: [10, 250, 5000, 15_000]] + tags: [:success, :mechanism], + reporter_options: [peep_bucket_calculator: Buckets] ) ]) end @@ -41,7 +44,8 @@ defmodule Realtime.PromEx.Plugins.Tenants do description: "The total count of connected tenants.", measurement: :connected ) - ] + ], + detach_on_error: false ) ] end diff --git a/lib/realtime/monitoring/prometheus.ex b/lib/realtime/monitoring/prometheus.ex new file mode 100644 index 000000000..ef100f1bc --- /dev/null +++ b/lib/realtime/monitoring/prometheus.ex @@ -0,0 +1,193 @@ +# Based on https://github.com/rkallos/peep/blob/708546ed069aebdf78ac1f581130332bd2e8b5b1/lib/peep/prometheus.ex +defmodule Realtime.Monitoring.Prometheus do + @moduledoc """ + Prometheus exporter module + + Use a temporary ets table to cache formatted names and label values + """ + + alias Telemetry.Metrics.{Counter, Distribution, LastValue, Sum} + + def export(metrics) do + cache = :ets.new(:cache, [:set, :private, read_concurrency: false, write_concurrency: :auto]) + + result = [Enum.map(metrics, &format(&1, cache)), "# EOF\n"] + :ets.delete(cache) + result + end + + defp format({%Counter{}, _series} = metric, cache) do + format_standard(metric, "counter", cache) + end + + defp format({%Sum{} = spec, _series} = metric, cache) do + format_standard(metric, spec.reporter_options[:prometheus_type] || "counter", cache) + end + + defp format({%LastValue{} = spec, _series} = metric, cache) do + format_standard(metric, spec.reporter_options[:prometheus_type] || "gauge", cache) + end + + defp format({%Distribution{} = metric, tagged_series}, cache) do + name = format_name(metric.name, cache) + help = ["# HELP ", name, " ", escape_help(metric.description)] + type = ["# TYPE ", name, " histogram"] + + distributions = + Enum.map(tagged_series, fn {tags, buckets} -> + format_distribution(name, tags, buckets, cache) + end) + + [help, ?\n, type, ?\n, distributions] + end + + defp format_distribution(name, tags, buckets, cache) do + has_labels? = not Enum.empty?(tags) + + buckets_as_floats = + Map.drop(buckets, [:sum, :infinity]) + |> Enum.map(fn {bucket_string, count} -> {String.to_float(bucket_string), count} end) + |> Enum.sort() + + {prefix_sums, count} = prefix_sums(buckets_as_floats) + + {labels_done, bucket_partial} = + if has_labels? do + labels = format_labels(tags, cache) + {[?{, labels, "} "], [name, "_bucket{", labels, ",le=\""]} + else + {?\s, [name, "_bucket{le=\""]} + end + + samples = + prefix_sums + |> Enum.map(fn {upper_bound, count} -> + [bucket_partial, format_value(upper_bound), "\"} ", Integer.to_string(count), ?\n] + end) + + sum = Map.get(buckets, :sum, 0) + inf = Map.get(buckets, :infinity, 0) + + [ + samples, + [bucket_partial, "+Inf\"} ", Integer.to_string(count + inf), ?\n], + [name, "_sum", labels_done, Integer.to_string(sum), ?\n], + [name, "_count", labels_done, Integer.to_string(count + inf), ?\n] + ] + end + + defp format_standard({metric, series}, type, cache) do + name = format_name(metric.name, cache) + help = ["# HELP ", name, " ", escape_help(metric.description)] + type = ["# TYPE ", name, " ", to_string(type)] + + samples = + Enum.map(series, fn {labels, value} -> + has_labels? = not Enum.empty?(labels) + + if has_labels? do + [name, ?{, format_labels(labels, cache), ?}, " ", format_value(value), ?\n] + else + [name, " ", format_value(value), ?\n] + end + end) + + [help, ?\n, type, ?\n, samples] + end + + defp format_labels(labels, cache) do + labels + |> Enum.sort() + |> Enum.map_intersperse(?,, fn {k, v} -> [to_string(k), "=\"", escape(v, cache), ?"] end) + end + + defp format_name(name, cache) do + case :ets.lookup_element(cache, name, 2, nil) do + nil -> + result = + name + |> Enum.join("_") + |> format_name_start() + |> IO.iodata_to_binary() + + :ets.insert(cache, {name, result}) + result + + result -> + result + end + end + + # Name must start with an ascii letter + defp format_name_start(<>) when h not in ?A..?Z and h not in ?a..?z, + do: format_name_start(rest) + + defp format_name_start(<>), + do: format_name_rest(rest, <<>>) + + # Otherwise only letters, numbers, or _ + defp format_name_rest(<>, acc) + when h in ?A..?Z or h in ?a..?z or h in ?0..?9 or h == ?_, + do: format_name_rest(rest, [acc, h]) + + defp format_name_rest(<<_, rest::binary>>, acc), do: format_name_rest(rest, acc) + defp format_name_rest(<<>>, acc), do: acc + + defp format_value(true), do: "1" + defp format_value(false), do: "0" + defp format_value(nil), do: "0" + defp format_value(n) when is_integer(n), do: Integer.to_string(n) + defp format_value(f) when is_float(f), do: Float.to_string(f) + + defp escape(nil, _cache), do: "nil" + + defp escape(value, cache) do + case :ets.lookup_element(cache, value, 2, nil) do + nil -> + result = + value + |> safe_to_string() + |> do_escape(<<>>) + |> IO.iodata_to_binary() + + :ets.insert(cache, {value, result}) + result + + result -> + result + end + end + + defp safe_to_string(value) do + case String.Chars.impl_for(value) do + nil -> inspect(value) + _ -> to_string(value) + end + end + + defp do_escape(<>, acc), do: do_escape(rest, [acc, ?\\, ?\"]) + defp do_escape(<>, acc), do: do_escape(rest, [acc, ?\\, ?\\]) + defp do_escape(<>, acc), do: do_escape(rest, [acc, ?\\, ?n]) + defp do_escape(<>, acc), do: do_escape(rest, [acc, h]) + defp do_escape(<<>>, acc), do: acc + + defp escape_help(value) do + value + |> to_string() + |> escape_help(<<>>) + end + + defp escape_help(<>, acc), do: escape_help(rest, <>) + defp escape_help(<>, acc), do: escape_help(rest, <>) + defp escape_help(<>, acc), do: escape_help(rest, <>) + defp escape_help(<<>>, acc), do: acc + + defp prefix_sums(buckets), do: prefix_sums(buckets, [], 0) + defp prefix_sums([], acc, sum), do: {Enum.reverse(acc), sum} + + defp prefix_sums([{bucket, count} | rest], acc, sum) do + new_sum = sum + count + new_bucket = {bucket, new_sum} + prefix_sums(rest, [new_bucket | acc], new_sum) + end +end diff --git a/lib/realtime/monitoring/tenant_prom_ex.ex b/lib/realtime/monitoring/tenant_prom_ex.ex new file mode 100644 index 000000000..e69b5550c --- /dev/null +++ b/lib/realtime/monitoring/tenant_prom_ex.ex @@ -0,0 +1,35 @@ +defmodule Realtime.TenantPromEx do + alias Realtime.PromEx.Plugins.Channels + alias Realtime.PromEx.Plugins.Tenant + + @moduledoc """ + PromEx configuration for tenant-level metrics. + + These metrics are per-tenant and considered secondary priority for scraping. + Configure your Victoria Metrics scrape interval higher (e.g. 60s) compared + to the global metrics endpoint. + + Exposes metrics via `/metrics/tenant` and `/metrics/:region/tenant`. + """ + + use PromEx, otp_app: :realtime + + @impl true + def plugins do + poll_rate = Application.get_env(:realtime, :prom_poll_rate) + + [ + {Tenant, poll_rate: poll_rate}, + {Channels, poll_rate: poll_rate} + ] + end + + def get_metrics do + metrics = PromEx.get_metrics(Realtime.TenantPromEx) + + Realtime.TenantPromEx.__ets_cron_flusher_name__() + |> PromEx.ETSCronFlusher.defer_ets_flush() + + metrics + end +end diff --git a/lib/realtime/nodes.ex b/lib/realtime/nodes.ex index ae237eb5f..601828b2f 100644 --- a/lib/realtime/nodes.ex +++ b/lib/realtime/nodes.ex @@ -12,21 +12,32 @@ defmodule Realtime.Nodes do @spec get_node_for_tenant(Tenant.t()) :: {:ok, node(), binary()} | {:error, term()} def get_node_for_tenant(nil), do: {:error, :tenant_not_found} - def get_node_for_tenant(%Tenant{external_id: tenant_id} = tenant) do + def get_node_for_tenant(%Tenant{} = tenant) do with region <- Tenants.region(tenant), tenant_region <- platform_region_translator(region), - node <- launch_node(tenant_id, tenant_region, node()) do + node <- launch_node(tenant_region, node(), tenant.external_id) do {:ok, node, tenant_region} end end @doc """ - Translates a region from a platform to the closest Supabase tenant region + Translates a region from a platform to the closest Supabase tenant region. + + Region mapping can be customized via the REGION_MAPPING environment variable. + If not provided, uses the default hardcoded mapping. """ @spec platform_region_translator(String.t() | nil) :: nil | binary() def platform_region_translator(nil), do: nil def platform_region_translator(tenant_region) when is_binary(tenant_region) do + case Application.get_env(:realtime, :region_mapping) do + nil -> default_region_mapping(tenant_region) + mapping when is_map(mapping) -> Map.get(mapping, tenant_region) + end + end + + # Private function with hardcoded defaults + defp default_region_mapping(tenant_region) do case tenant_region do "ap-east-1" -> "ap-southeast-1" "ap-northeast-1" -> "ap-southeast-1" @@ -54,7 +65,6 @@ defmodule Realtime.Nodes do Lists the nodes in a region. Sorts by node name in case the list order is unstable. """ - @spec region_nodes(String.t() | nil) :: [atom()] def region_nodes(region) when is_binary(region) do :syn.members(RegionNodes, region) @@ -64,32 +74,140 @@ defmodule Realtime.Nodes do def region_nodes(nil), do: [] + @doc """ + Picks a node from a region based on the provided key + """ + @spec node_from_region(String.t(), term()) :: {:ok, node} | {:error, :not_available} + def node_from_region(region, key) when is_binary(region) do + nodes = region_nodes(region) + + case nodes do + [] -> + {:error, :not_available} + + _ -> + member_count = Enum.count(nodes) + index = :erlang.phash2(key, member_count) + + {:ok, Enum.fetch!(nodes, index)} + end + end + + def node_from_region(_, _), do: {:error, :not_available} + @doc """ Picks the node to launch the Postgres connection on. - If there are not two nodes in a region the connection is established from + Selection is deterministic within time buckets to prevent syn conflicts from + concurrent requests for the same tenant. Uses time-bucketed seeded random + selection to pick 2 candidate nodes, compares their loads, and picks the + least loaded one. + + The time bucket approach ensures: + - Requests within same time window (default: 60s) pick same nodes → prevents conflicts + - Requests in different time windows pick different random nodes → better long-term distribution + + If the uptime of the node is below the configured threshold for load balancing, + a consistent node is picked based on hashing the tenant ID. + + If there are not two nodes in a region, the connection is established from the `default` node given. """ - @spec launch_node(String.t(), String.t() | nil, atom()) :: atom() - def launch_node(tenant_id, region, default) do + @spec launch_node(String.t() | nil, atom(), String.t()) :: atom() + def launch_node(region, default, tenant_id) when is_binary(tenant_id) do case region_nodes(region) do - [node] -> - Logger.warning("Only one region node (#{inspect(node)}) for #{region} using default #{inspect(default)}") - - default - [] -> Logger.warning("Zero region nodes for #{region} using #{inspect(default)}") default - regions_nodes -> - member_count = Enum.count(regions_nodes) - index = :erlang.phash2(tenant_id, member_count) + [single_node] -> + single_node - Enum.fetch!(regions_nodes, index) + nodes -> + load_aware_node_picker(nodes, tenant_id) end end + @node_selection_time_bucket_seconds Application.compile_env( + :realtime, + :node_selection_time_bucket_seconds, + 60 + ) + + @cache Realtime.Nodes.Cache + @node_load_ttl_ms @node_selection_time_bucket_seconds * 1_000 + + defp load_aware_node_picker(regions_nodes, tenant_id) when is_binary(tenant_id) do + case regions_nodes do + nodes -> + node_count = length(nodes) + + {node1, node2} = two_random_nodes(tenant_id, nodes, node_count) + + # Compare loads and pick least loaded + load1 = node_load(node1) + load2 = node_load(node2) + + if is_number(load1) and is_number(load2) do + if load1 <= load2, do: node1, else: node2 + else + # Fallback to consistently picking a node if load data is not available + index = :erlang.phash2(tenant_id, node_count) + Enum.fetch!(nodes, index) + end + end + end + + defp two_random_nodes(tenant_id, nodes, node_count) do + # Get current time bucket (unix timestamp / bucket_size) + time_bucket = div(System.system_time(:second), @node_selection_time_bucket_seconds) + + # Seed the RNG without storing into the process dictionary + seed_value = :erlang.phash2({tenant_id, time_bucket}) + rand_state = :rand.seed_s(:exsss, seed_value) + + {id1, rand_state2} = :rand.uniform_s(node_count, rand_state) + {id2, _rand_state3} = :rand.uniform_s(node_count, rand_state2) + + # Ensure id2 is different from id1 when multiple nodes available + id2 = + if id1 == id2 and node_count > 1 do + # Pick next node (wraps around using rem) + rem(id1, node_count) + 1 + else + id2 + end + + node1 = Enum.at(nodes, id1 - 1) + node2 = Enum.at(nodes, id2 - 1) + {node1, node2} + end + + @doc """ + Gets the node load for a node either locally or remotely. Returns {:error, :not_enough_data} if the node has not been running for long enough to get reliable metrics. + """ + @spec node_load(atom()) :: integer() | {:error, :not_enough_data} + def node_load(node) when node() == node do + if uptime_ms() < Application.fetch_env!(:realtime, :node_balance_uptime_threshold_in_ms), + do: {:error, :not_enough_data}, + else: :cpu_sup.avg5() + end + + def node_load(node) when node() != node do + {_, value} = + Cachex.fetch(@cache, node, fn _ -> + result = Realtime.GenRpc.call(node, __MODULE__, :node_load, [node], []) + + case result do + result when is_number(result) -> {:commit, result, [expire: @node_load_ttl_ms]} + {:error, :not_enough_data} -> {:commit, result, [expire: @node_load_ttl_ms]} + _ -> {:ignore, result} + end + end) + + value + end + @doc """ Gets a short node name from a node name when a node name looks like `realtime-prod@fdaa:0:cc:a7b:b385:83c3:cfe3:2` @@ -105,7 +223,7 @@ defmodule Realtime.Nodes do iex> node = :"pink@127.0.0.1" iex> Realtime.Helpers.short_node_id_from_name(node) - "127.0.0.1" + "pink@127.0.0.1" iex> node = :"pink@10.0.1.1" iex> Realtime.Helpers.short_node_id_from_name(node) @@ -124,64 +242,21 @@ defmodule Realtime.Nodes do [_, _, _, _, _, one, two, _] -> one <> two + ["127.0.0.1"] -> + Atom.to_string(name) + _other -> host end end - @mapping_realtime_region_to_tenant_region_aws %{ - "ap-southeast-1" => [ - "ap-east-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-south-1", - "ap-southeast-1" - ], - "ap-southeast-2" => ["ap-southeast-2"], - "eu-west-2" => [ - "eu-central-1", - "eu-central-2", - "eu-north-1", - "eu-west-1", - "eu-west-2", - "eu-west-3" - ], - "us-east-1" => [ - "ca-central-1", - "sa-east-1", - "us-east-1", - "us-east-2" - ], - "us-west-1" => ["us-west-1", "us-west-2"] - } - @mapping_realtime_region_to_tenant_region_fly %{ - "iad" => ["ca-central-1", "sa-east-1", "us-east-1"], - "lhr" => ["eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3"], - "sea" => ["us-west-1"], - "syd" => [ - "ap-east-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-south-1", - "ap-southeast-1", - "ap-southeast-2" - ] - } - - @doc """ - Fetches the tenant regions for a given realtime reagion - """ - @spec region_to_tenant_regions(String.t()) :: list() | nil - def region_to_tenant_regions(region) do - platform = Application.get_env(:realtime, :platform) - - mappings = - case platform do - :aws -> @mapping_realtime_region_to_tenant_region_aws - :fly -> @mapping_realtime_region_to_tenant_region_fly - _ -> %{} - end + @spec all_node_regions() :: [String.t()] + @doc "List all the regions where nodes can be launched" + def all_node_regions(), do: :syn.group_names(RegionNodes) - Map.get(mappings, region) + defp uptime_ms do + start_time = :erlang.system_info(:start_time) + now = :erlang.monotonic_time() + :erlang.convert_time_unit(now - start_time, :native, :millisecond) end end diff --git a/lib/realtime/operations.ex b/lib/realtime/operations.ex index 76efa38fb..0a3fdf0d7 100644 --- a/lib/realtime/operations.ex +++ b/lib/realtime/operations.ex @@ -9,12 +9,14 @@ defmodule Realtime.Operations do """ def rebalance do Enum.reduce(:syn.group_names(:users), 0, fn tenant, acc -> - case :syn.lookup(Extensions.PostgresCdcRls, tenant) do + scope = Realtime.Syn.PostgresCdc.scope(tenant) + + case :syn.lookup(scope, tenant) do {pid, %{region: region}} -> platform_region = Realtime.Nodes.platform_region_translator(region) current_node = node(pid) - case Realtime.Nodes.launch_node(tenant, platform_region, false) do + case Realtime.Nodes.launch_node(platform_region, false, tenant) do ^current_node -> acc _ -> stop_user_tenant_process(tenant, platform_region, acc) end diff --git a/lib/realtime/postgres_cdc.ex b/lib/realtime/postgres_cdc.ex index eef81a1ec..765a71dbd 100644 --- a/lib/realtime/postgres_cdc.ex +++ b/lib/realtime/postgres_cdc.ex @@ -1,8 +1,6 @@ defmodule Realtime.PostgresCdc do @moduledoc false - require Logger - alias Realtime.Api.Tenant @timeout 10_000 @@ -16,8 +14,8 @@ defmodule Realtime.PostgresCdc do apply(module, :handle_connect, [opts]) end - def after_connect(module, connect_response, extension, params) do - apply(module, :handle_after_connect, [connect_response, extension, params]) + def after_connect(module, connect_response, extension, params, tenant) do + apply(module, :handle_after_connect, [connect_response, extension, params, tenant]) end def subscribe(module, pg_change_params, tenant, metadata) do @@ -80,7 +78,8 @@ defmodule Realtime.PostgresCdc do end @callback handle_connect(any()) :: {:ok, any()} | nil - @callback handle_after_connect(any(), any(), any()) :: {:ok, any()} | {:error, any()} + @callback handle_after_connect(any(), any(), any(), tenant_id :: String.t()) :: + {:ok, any()} | {:error, any()} | {:error, any(), any()} @callback handle_subscribe(any(), any(), any()) :: :ok @callback handle_stop(any(), any()) :: any() end diff --git a/lib/realtime/rate_counter/rate_counter.ex b/lib/realtime/rate_counter/rate_counter.ex index d489b86f2..889b91646 100644 --- a/lib/realtime/rate_counter/rate_counter.ex +++ b/lib/realtime/rate_counter/rate_counter.ex @@ -4,6 +4,8 @@ defmodule Realtime.RateCounter do These rate counters use the GenCounter module. Start your RateCounter here and increment it with a `GenCounter.add/1` call, for example. + + Average is calculated as the average number of events per second """ use GenServer @@ -20,7 +22,7 @@ defmodule Realtime.RateCounter do defstruct id: nil, opts: [] end - @idle_shutdown :timer.minutes(15) + @idle_shutdown :timer.minutes(5) @tick :timer.seconds(1) @max_bucket_len 60 @cache __MODULE__ @@ -77,22 +79,6 @@ defmodule Realtime.RateCounter do ) end - @spec stop(term()) :: :ok - def stop(tenant_id) do - keys = - Registry.select(Realtime.Registry.Unique, [ - {{{:"$1", :_, {:_, :_, :"$2"}}, :"$3", :_}, [{:==, :"$1", __MODULE__}, {:==, :"$2", tenant_id}], [:"$_"]} - ]) - - Enum.each(keys, fn {{_, _, key}, {pid, _}} -> - if Process.alive?(pid), do: GenServer.stop(pid) - GenCounter.delete(key) - Cachex.del!(@cache, key) - end) - - :ok - end - @doc """ Starts a new RateCounter under a DynamicSupervisor """ @@ -108,6 +94,10 @@ defmodule Realtime.RateCounter do }) end + @doc "Publish an update to the RateCounter with the given id" + @spec publish_update(term()) :: :ok + def publish_update(id), do: Phoenix.PubSub.broadcast(Realtime.PubSub, update_topic(id), :update) + @doc """ Gets the state of the RateCounter. @@ -136,6 +126,8 @@ defmodule Realtime.RateCounter do end end + defp update_topic(id), do: "rate_counter:#{inspect(id)}" + @impl true def init(args) do id = Keyword.fetch!(args, :id) @@ -151,6 +143,8 @@ defmodule Realtime.RateCounter do # a RateCounter running to calculate avg and buckets GenCounter.reset(id) + :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, update_topic(id)) + telemetry = if telem_opts do Logger.metadata(telem_opts.metadata) @@ -216,7 +210,8 @@ defmodule Realtime.RateCounter do bucket_len = Enum.count(bucket) sum = Enum.sum(bucket) - avg = sum / bucket_len + + avg = sum / bucket_len / (state.tick / 1_000) state = %{state | bucket: bucket, sum: sum, avg: avg} @@ -228,23 +223,11 @@ defmodule Realtime.RateCounter do {:noreply, state} end - @impl true def handle_info(:idle_shutdown, state) do if Enum.all?(state.bucket, &(&1 == 0)) do # All the buckets are empty, so we can assume this RateCounter has not been useful recently - Logger.warning("#{__MODULE__} idle_shutdown reached for: #{inspect(state.id)}") - GenCounter.delete(state.id) - # We are expiring in the near future instead of deleting so that - # The process dies before the cache information disappears - # If we were using Cachex.delete instead then the following rare scenario would be possible: - # * RateCounter.get/2 is called; - # * Cache was deleted but the process has not stopped yet; - # * RateCounter.get/2 will then try to start a new RateCounter but the supervisor will return :already_started; - # * Process finally stops; - # * The cache is still empty because no new process was started causing an error - - Cachex.expire(@cache, state.id, :timer.seconds(1)) - {:stop, :normal, state} + Logger.info("#{__MODULE__} idle_shutdown reached for: #{inspect(state.id)}") + shutdown(state) else Process.cancel_timer(state.idle_shutdown_ref) idle_shutdown_ref = shutdown_after(state.idle_shutdown) @@ -252,6 +235,29 @@ defmodule Realtime.RateCounter do end end + def handle_info(:update, state) do + # When we get an update message we shutdown so that this RateCounter + # can be restarted with new parameters + shutdown(state) + end + + def handle_info(_, state), do: {:noreply, state} + + defp shutdown(state) do + GenCounter.delete(state.id) + # We are expiring in the near future instead of deleting so that + # The process dies before the cache information disappears + # If we were using Cachex.delete instead then the following rare scenario would be possible: + # * RateCounter.get/2 is called; + # * Cache was deleted but the process has not stopped yet; + # * RateCounter.get/2 will then try to start a new RateCounter but the supervisor will return :already_started; + # * Process finally stops; + # * The cache is still empty because no new process was started causing an error + + Cachex.expire(@cache, state.id, :timer.seconds(1)) + {:stop, :normal, state} + end + defp maybe_trigger_limit(%{limit: %{log: false}} = state), do: state defp maybe_trigger_limit(%{limit: %{triggered: true, measurement: measurement}} = state) do diff --git a/lib/realtime/repo.ex b/lib/realtime/repo.ex index f3850712a..5375c2c97 100644 --- a/lib/realtime/repo.ex +++ b/lib/realtime/repo.ex @@ -1,12 +1,8 @@ defmodule Realtime.Repo do - use Realtime.Logs - use Ecto.Repo, otp_app: :realtime, adapter: Ecto.Adapters.Postgres - import Ecto.Query - def with_dynamic_repo(config, callback) do default_dynamic_repo = get_dynamic_repo() {:ok, repo} = [name: nil, pool_size: 2] |> Keyword.merge(config) |> Realtime.Repo.start_link() @@ -19,244 +15,4 @@ defmodule Realtime.Repo do Supervisor.stop(repo) end end - - @doc """ - Lists all records for a given query and converts them into a given struct - """ - @spec all(DBConnection.conn(), Ecto.Queryable.t(), module(), [Postgrex.execute_option()]) :: - {:ok, list(struct())} | {:error, any()} - def all(conn, query, result_struct, opts \\ []) do - conn - |> run_all_query(query, opts) - |> result_to_structs(result_struct) - end - - @doc """ - Fetches one record for a given query and converts it into a given struct - """ - @spec one( - DBConnection.conn(), - Ecto.Query.t(), - module(), - Postgrex.option() | Keyword.t() - ) :: - {:error, any()} | {:ok, struct()} | Ecto.Changeset.t() - def one(conn, query, result_struct, opts \\ []) do - conn - |> run_all_query(query, opts) - |> result_to_single_struct(result_struct, nil) - end - - @doc """ - Inserts a given changeset into the database and converts the result into a given struct - """ - @spec insert( - DBConnection.conn(), - Ecto.Changeset.t(), - module(), - Postgrex.option() | Keyword.t() - ) :: - {:ok, struct()} | {:error, any()} | Ecto.Changeset.t() - def insert(conn, changeset, result_struct, opts \\ []) do - with {:ok, {query, args}} <- insert_query_from_changeset(changeset) do - conn - |> run_query_with_trap(query, args, opts) - |> result_to_single_struct(result_struct, changeset) - end - end - - @doc """ - Inserts all changesets into the database and converts the result into a given list of structs - """ - @spec insert_all_entries( - DBConnection.conn(), - [Ecto.Changeset.t()], - module(), - Postgrex.option() | Keyword.t() - ) :: - {:ok, [struct()]} | {:error, any()} | Ecto.Changeset.t() - def insert_all_entries(conn, changesets, result_struct, opts \\ []) do - with {:ok, {query, args}} <- insert_all_query_from_changeset(changesets) do - conn - |> run_query_with_trap(query, args, opts) - |> result_to_structs(result_struct) - end - end - - @doc """ - Deletes records for a given query and returns the number of deleted records - """ - @spec del(DBConnection.conn(), Ecto.Queryable.t()) :: - {:ok, non_neg_integer()} | {:error, any()} - def del(conn, query) do - with {:ok, %Postgrex.Result{num_rows: num_rows}} <- run_delete_query(conn, query) do - {:ok, num_rows} - end - end - - @doc """ - Updates an entry based on the changeset and returns the updated entry - """ - @spec update(DBConnection.conn(), Ecto.Changeset.t(), module()) :: - {:ok, struct()} | {:error, any()} | Ecto.Changeset.t() - def update(conn, changeset, result_struct, opts \\ []) do - with {:ok, {query, args}} <- update_query_from_changeset(changeset) do - conn - |> run_query_with_trap(query, args, opts) - |> result_to_single_struct(result_struct, changeset) - end - end - - defp result_to_single_struct( - {:error, %Postgrex.Error{postgres: %{code: :unique_violation, constraint: "channels_name_index"}}}, - _struct, - changeset - ) do - Ecto.Changeset.add_error(changeset, :name, "has already been taken") - end - - defp result_to_single_struct({:error, _} = error, _, _), do: error - - defp result_to_single_struct({:ok, %Postgrex.Result{rows: []}}, _, _) do - {:error, :not_found} - end - - defp result_to_single_struct({:ok, %Postgrex.Result{rows: [row], columns: columns}}, struct, _) do - {:ok, load(struct, Enum.zip(columns, row))} - end - - defp result_to_single_struct({:ok, %Postgrex.Result{num_rows: num_rows}}, _, _) do - raise("expected at most one result but got #{num_rows} in result") - end - - defp result_to_structs({:error, _} = error, _), do: error - - defp result_to_structs({:ok, %Postgrex.Result{rows: rows, columns: columns}}, struct) do - {:ok, Enum.map(rows, &load(struct, Enum.zip(columns, &1)))} - end - - defp insert_query_from_changeset(%{valid?: false} = changeset), do: {:error, changeset} - - defp insert_query_from_changeset(changeset) do - schema = changeset.data.__struct__ - source = schema.__schema__(:source) - prefix = schema.__schema__(:prefix) - acc = %{header: [], rows: []} - - %{header: header, rows: rows} = - Enum.reduce(changeset.changes, acc, fn {field, row}, %{header: header, rows: rows} -> - row = - case row do - row when is_boolean(row) -> row - row when is_atom(row) -> Atom.to_string(row) - _ -> row - end - - %{ - header: [Atom.to_string(field) | header], - rows: [row | rows] - } - end) - - table = "\"#{prefix}\".\"#{source}\"" - header = "(#{Enum.map_join(header, ",", &"\"#{&1}\"")})" - - arg_index = - rows - |> Enum.with_index(1) - |> Enum.map_join(",", fn {_, index} -> "$#{index}" end) - - {:ok, {"INSERT INTO #{table} #{header} VALUES (#{arg_index}) RETURNING *", rows}} - end - - defp insert_all_query_from_changeset(changesets) do - invalid = Enum.filter(changesets, &(!&1.valid?)) - - if invalid != [] do - {:error, changesets} - else - [schema] = changesets |> Enum.map(& &1.data.__struct__) |> Enum.uniq() - - source = schema.__schema__(:source) - prefix = schema.__schema__(:prefix) - changes = Enum.map(changesets, & &1.changes) - - %{header: header, rows: rows} = - Enum.reduce(changes, %{header: [], rows: []}, fn v, changes_acc -> - Enum.reduce(v, changes_acc, fn {field, row}, %{header: header, rows: rows} -> - row = - case row do - row when is_boolean(row) -> row - row when is_atom(row) -> Atom.to_string(row) - _ -> row - end - - %{ - header: Enum.uniq([Atom.to_string(field) | header]), - rows: [row | rows] - } - end) - end) - - args_index = - rows - |> Enum.chunk_every(length(header)) - |> Enum.reduce({"", 1}, fn row, {acc, count} -> - arg_index = - row - |> Enum.with_index(count) - |> Enum.map_join("", fn {_, index} -> "$#{index}," end) - |> String.trim_trailing(",") - |> then(&"(#{&1})") - - {"#{acc},#{arg_index}", count + length(row)} - end) - |> elem(0) - |> String.trim_leading(",") - - table = "\"#{prefix}\".\"#{source}\"" - header = "(#{Enum.map_join(header, ",", &"\"#{&1}\"")})" - {:ok, {"INSERT INTO #{table} #{header} VALUES #{args_index} RETURNING *", rows}} - end - end - - defp update_query_from_changeset(%{valid?: false} = changeset), do: {:error, changeset} - - defp update_query_from_changeset(changeset) do - %Ecto.Changeset{data: %{id: id, __struct__: struct}, changes: changes} = changeset - changes = Keyword.new(changes) - query = from(c in struct, where: c.id == ^id, select: c, update: [set: ^changes]) - {:ok, to_sql(:update_all, query)} - end - - defp run_all_query(conn, query, opts) do - {query, args} = to_sql(:all, query) - run_query_with_trap(conn, query, args, opts) - end - - defp run_delete_query(conn, query) do - {query, args} = to_sql(:delete_all, query) - run_query_with_trap(conn, query, args) - end - - defp run_query_with_trap(conn, query, args, opts \\ []) do - Postgrex.query(conn, query, args, opts) - rescue - e -> - log_error("ErrorRunningQuery", e) - {:error, :postgrex_exception} - catch - :exit, {:noproc, {DBConnection.Holder, :checkout, _}} -> - log_error( - "UnableCheckoutConnection", - "Unable to checkout connection, please check your connection pool configuration" - ) - - {:error, :postgrex_exception} - - :exit, reason -> - log_error("UnknownError", reason) - - {:error, :postgrex_exception} - end end diff --git a/lib/realtime/repo_replica.ex b/lib/realtime/repo_replica.ex index 8079ccb8e..2a036bd29 100644 --- a/lib/realtime/repo_replica.ex +++ b/lib/realtime/repo_replica.ex @@ -2,7 +2,10 @@ defmodule Realtime.Repo.Replica do @moduledoc """ Generates a read-only replica repo for the region specified in config/runtime.exs. """ - require Logger + use Ecto.Repo, + otp_app: :realtime, + adapter: Ecto.Adapters.Postgres, + read_only: true @replicas_fly %{ "sea" => Realtime.Repo.Replica.SJC, @@ -25,47 +28,58 @@ defmodule Realtime.Repo.Replica do "us-west-1" => Realtime.Repo.Replica.SanJose } - @ast (quote do - use Ecto.Repo, - otp_app: :realtime, - adapter: Ecto.Adapters.Postgres, - read_only: true - end) + for replica_module <- Enum.uniq(Map.values(@replicas_fly) ++ Map.values(@replicas_aws)) do + defmodule replica_module do + use Ecto.Repo, + otp_app: :realtime, + adapter: Ecto.Adapters.Postgres, + read_only: true + end + end @doc """ Returns the replica repo module for the region specified in config/runtime.exs. """ @spec replica() :: module() def replica do - replicas = - case Application.get_env(:realtime, :platform) do - :aws -> @replicas_aws - :fly -> @replicas_fly - _ -> %{} - end - region = Application.get_env(:realtime, :region) - replica = Map.get(replicas, region) - replica_conf = Application.get_env(:realtime, replica) + master_region = Application.get_env(:realtime, :master_region) || region - # Do not create module if replica isn't set or configuration is not present - cond do - is_nil(replica) -> - Logger.info("Replica region not found, defaulting to Realtime.Repo") + case configured_replica_module(region) do + nil -> Realtime.Repo - is_nil(replica_conf) -> - Logger.info("Replica config not found for #{region} region") - Realtime.Repo + replica -> + replica_conf = Application.get_env(:realtime, replica) + + cond do + is_nil(replica_conf) -> + Realtime.Repo + + region == master_region -> + Realtime.Repo + + true -> + replica + end + end + end + + defp configured_replica_module(region) do + main_replica_config = Application.get_env(:realtime, __MODULE__) - true -> - # Check if module is present - case Code.ensure_compiled(replica) do - {:module, _} -> nil - _ -> {:module, _, _, _} = Module.create(replica, @ast, Macro.Env.location(__ENV__)) + # If the main replica module is configured we don't bother with specific replica modules + if main_replica_config do + __MODULE__ + else + replicas = + case Application.get_env(:realtime, :platform) do + :aws -> @replicas_aws + :fly -> @replicas_fly + _ -> %{} end - replica + Map.get(replicas, region) end end diff --git a/lib/realtime/rpc.ex b/lib/realtime/rpc.ex index c63b29f08..7e4095b95 100644 --- a/lib/realtime/rpc.ex +++ b/lib/realtime/rpc.ex @@ -10,14 +10,13 @@ defmodule Realtime.Rpc do """ @spec call(atom(), atom(), atom(), any(), keyword()) :: any() def call(node, mod, func, args, opts \\ []) do - tenant_id = Keyword.get(opts, :tenant_id) timeout = Keyword.get(opts, :timeout, Application.get_env(:realtime, :rpc_timeout)) {latency, response} = :timer.tc(fn -> :rpc.call(node, mod, func, args, timeout) end) Telemetry.execute( [:realtime, :rpc], %{latency: latency}, - %{mod: mod, func: func, target_node: node, origin_node: node(), mechanism: :rpc, tenant: tenant_id, success: nil} + %{mod: mod, func: func, target_node: node, origin_node: node(), mechanism: :rpc, success: nil} ) response @@ -45,7 +44,6 @@ defmodule Realtime.Rpc do target_node: node, origin_node: node(), success: true, - tenant: tenant_id, mechanism: :erpc } ) @@ -62,7 +60,6 @@ defmodule Realtime.Rpc do target_node: node, origin_node: node(), success: false, - tenant: tenant_id, mechanism: :erpc } ) @@ -87,7 +84,6 @@ defmodule Realtime.Rpc do target_node: node, origin_node: node(), success: false, - tenant: tenant_id, mechanism: :erpc } ) diff --git a/lib/realtime/syn/postgres_cdc.ex b/lib/realtime/syn/postgres_cdc.ex new file mode 100644 index 000000000..3b4dd6541 --- /dev/null +++ b/lib/realtime/syn/postgres_cdc.ex @@ -0,0 +1,23 @@ +defmodule Realtime.Syn.PostgresCdc do + @moduledoc """ + Scope for the PostgresCdc module. + """ + + @doc """ + Returns the scope for a given tenant id. + """ + @spec scope(String.t()) :: atom() + def scope(tenant_id) do + shards = Application.fetch_env!(:realtime, :postgres_cdc_scope_shards) + shard = :erlang.phash2(tenant_id, shards) + :"realtime_postgres_cdc_#{shard}" + end + + def scopes() do + shards = Application.fetch_env!(:realtime, :postgres_cdc_scope_shards) + Enum.map(0..(shards - 1), fn shard -> :"realtime_postgres_cdc_#{shard}" end) + end + + def syn_topic_prefix(), do: "realtime_postgres_cdc_" + def syn_topic(tenant_id), do: "#{syn_topic_prefix()}#{tenant_id}" +end diff --git a/lib/realtime/syn_handler.ex b/lib/realtime/syn_handler.ex index 397c8cf8f..27dbb12d6 100644 --- a/lib/realtime/syn_handler.ex +++ b/lib/realtime/syn_handler.ex @@ -3,24 +3,40 @@ defmodule Realtime.SynHandler do Custom defined Syn's callbacks """ require Logger - alias Extensions.PostgresCdcRls - alias RealtimeWeb.Endpoint + alias Realtime.Syn.PostgresCdc alias Realtime.Tenants.Connect + alias RealtimeWeb.Endpoint @behaviour :syn_event_handler + @postgres_cdc_scope_prefix PostgresCdc.syn_topic_prefix() + @impl true - def on_registry_process_updated(Connect, tenant_id, _pid, %{conn: conn}, :normal) when is_pid(conn) do + def on_registry_process_updated(Connect, tenant_id, pid, %{conn: conn} = meta, :normal) when is_pid(conn) do # Update that a database connection is ready - Endpoint.local_broadcast(Connect.syn_topic(tenant_id), "ready", %{conn: conn}) + Endpoint.local_broadcast(Connect.syn_topic(tenant_id), "ready", %{ + pid: pid, + conn: conn, + replication_conn: meta[:replication_conn] + }) end - def on_registry_process_updated(PostgresCdcRls, tenant_id, _pid, meta, _reason) do - # Update that the CdCRls connection is ready - Endpoint.local_broadcast(PostgresCdcRls.syn_topic(tenant_id), "ready", meta) + def on_registry_process_updated(scope, tenant_id, _pid, meta, _reason) do + scope = Atom.to_string(scope) + + case scope do + @postgres_cdc_scope_prefix <> _ -> + Endpoint.local_broadcast(PostgresCdc.syn_topic(tenant_id), "ready", meta) + + _ -> + :ok + end end - def on_registry_process_updated(_scope, _name, _pid, _meta, _reason), do: :ok + @impl true + def on_process_registered(scope, name, _pid, _meta, _reason) do + :telemetry.execute([:syn, scope, :registered], %{}, %{name: name}) + end @doc """ When processes registered with :syn are unregistered, either manually or by stopping, this @@ -32,13 +48,20 @@ defmodule Realtime.SynHandler do was started, and subsequently stopped because :syn handled the conflict. """ @impl true - def on_process_unregistered(mod, name, pid, _meta, reason) do - if reason == :syn_conflict_resolution do - log("#{mod} terminated due to syn conflict resolution: #{inspect(name)} #{inspect(pid)}") + def on_process_unregistered(scope, name, pid, _meta, reason) do + :telemetry.execute([:syn, scope, :unregistered], %{}, %{name: name}) + + case Atom.to_string(scope) do + @postgres_cdc_scope_prefix <> _ = scope -> + Endpoint.local_broadcast(PostgresCdc.syn_topic(name), scope <> "_down", %{pid: pid, reason: reason}) + + _ -> + topic = topic(scope) + Endpoint.local_broadcast(topic <> ":" <> name, topic <> "_down", %{pid: pid, reason: reason}) end - topic = topic(mod) - Endpoint.local_broadcast(topic <> ":" <> name, topic <> "_down", nil) + if reason == :syn_conflict_resolution, + do: log("#{scope} terminated due to syn conflict resolution: #{inspect(name)} #{inspect(pid)}") :ok end @@ -53,19 +76,19 @@ defmodule Realtime.SynHandler do If it times out an exit with reason :kill that can't be trapped """ @impl true - def resolve_registry_conflict(mod, name, {pid1, _meta1, time1}, {pid2, _meta2, time2}) do - {pid_to_keep, pid_to_stop} = decide(pid1, time1, pid2, time2) + def resolve_registry_conflict(mod, name, {pid1, _meta1, _time1}, {pid2, _meta2, _time2}) do + {pid_to_keep, pid_to_stop} = decide(pid1, pid2, name) # Is this function running on the node that should stop? if node(pid_to_stop) == node() do log( - "Resolving conflict on scope #{inspect(mod)} for name #{inspect(name)} {#{inspect(pid1)}, #{time1}} vs {#{inspect(pid2)}, #{time2}}, stop local process: #{inspect(pid_to_stop)}" + "Resolving conflict on scope #{inspect(mod)} for name #{inspect(name)} {#{node(pid1)}, #{inspect(pid1)}} vs {#{node(pid2)}, #{inspect(pid2)}}, stop local process: #{inspect(pid_to_stop)}" ) stop(pid_to_stop) else log( - "Resolving conflict on scope #{inspect(mod)} for name #{inspect(name)} {#{inspect(pid1)}, #{time1}} vs {#{inspect(pid2)}, #{time2}}, remote process will be stopped: #{inspect(pid_to_stop)}" + "Resolving conflict on scope #{inspect(mod)} for name #{inspect(name)} {#{node(pid1)}, #{inspect(pid1)}} vs {#{node(pid2)}, #{inspect(pid2)}}, remote process will be stopped: #{inspect(pid_to_stop)}" ) end @@ -90,23 +113,26 @@ defmodule Realtime.SynHandler do defp log(message), do: Logger.warning("SynHandler(#{node()}): #{message}") - # If the time on both pids are exactly the same - # we compare the node names and pick one consistently - # Node names are necessarily unique - defp decide(pid1, time1, pid2, time2) when time1 == time2 do - if node(pid1) < node(pid2) do - {pid1, pid2} - else - {pid2, pid1} - end - end - - defp decide(pid1, time1, pid2, time2) do - # We pick the one that started first. - if time1 < time2 do - {pid1, pid2} + # We use node and the name to decide who lives and who dies + # This way both nodes will always agree on the same outcome + # regardless of timing issues + defp decide(pid1, pid2, name) do + # We hash the name to not always pick one specific node when a conflict happens + # between these 2 nodes + hash = :erlang.phash2(name, 2) + + if hash == 1 do + if node(pid1) < node(pid2) do + {pid1, pid2} + else + {pid2, pid1} + end else - {pid2, pid1} + if node(pid1) < node(pid2) do + {pid2, pid1} + else + {pid1, pid2} + end end end diff --git a/lib/realtime/telemetry/logger.ex b/lib/realtime/telemetry/logger.ex index cbc0c6cc4..1427ef922 100644 --- a/lib/realtime/telemetry/logger.ex +++ b/lib/realtime/telemetry/logger.ex @@ -4,15 +4,20 @@ defmodule Realtime.Telemetry.Logger do """ require Logger + use Realtime.Logs use GenServer @events [ [:realtime, :connections], [:realtime, :rate_counter, :channel, :events], - [:realtime, :rate_counter, :channel, :joins], [:realtime, :rate_counter, :channel, :db_events], - [:realtime, :rate_counter, :channel, :presence_events] + [:realtime, :rate_counter, :channel, :presence_events], + [:realtime, :tenants, :migrations, :start], + [:realtime, :tenants, :migrations, :stop], + [:realtime, :tenants, :migrations, :exception], + [:realtime, :tenants, :migrations, :reconcile, :stop], + [:realtime, :tenants, :migrations, :reconcile, :exception] ] def start_link(args) do @@ -28,13 +33,53 @@ defmodule Realtime.Telemetry.Logger do @doc """ Logs billing metrics for a tenant aggregated and emitted by a PromEx metric poller. """ - def handle_event(event, measurements, %{tenant: tenant}, _config) do meta = %{project: tenant, measurements: measurements} Logger.info(["Billing metrics: ", inspect(event)], meta) :ok end + def handle_event([:realtime, :tenants, :migrations, :start], _measurements, metadata, _config) do + Logger.info( + "Applying migrations to #{metadata.hostname}", + project: metadata.external_id + ) + end + + def handle_event([:realtime, :tenants, :migrations, :stop], measurements, metadata, _config) do + duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond) + + Logger.info( + "Finished applying #{metadata.migrations_executed} migrations for tenant #{metadata.external_id} in #{duration_ms}ms", + project: metadata.external_id + ) + end + + def handle_event([:realtime, :tenants, :migrations, :exception], _measurements, metadata, _config) do + log_error( + "MigrationsFailedToRun", + metadata.reason, + project: metadata.external_id, + error_code: metadata.error_code + ) + end + + def handle_event([:realtime, :tenants, :migrations, :reconcile, :stop], _measurements, metadata, _config) do + log_warning( + "MigrationCountMismatch", + "Reconciling migrations_ran for tenant #{metadata.external_id} cached=#{metadata.cached_migrations_ran} database=#{metadata.database_migrations_ran}", + project: metadata.external_id + ) + end + + def handle_event([:realtime, :tenants, :migrations, :reconcile, :exception], _measurements, metadata, _config) do + log_error( + "MigrationCountMismatchReconcileFailed", + metadata.reason, + project: metadata.external_id + ) + end + def handle_event(_event, _measurements, _metadata, _config) do :ok end diff --git a/lib/realtime/telemetry/telemetry.ex b/lib/realtime/telemetry/telemetry.ex index 6062e145e..76fd6d2be 100644 --- a/lib/realtime/telemetry/telemetry.ex +++ b/lib/realtime/telemetry/telemetry.ex @@ -1,14 +1,38 @@ defmodule Realtime.Telemetry do @moduledoc """ - Telemetry wrapper + Telemetry integration. """ @doc """ Dispatches Telemetry events. """ - @spec execute([atom, ...], map, map) :: :ok def execute(event, measurements, metadata \\ %{}) do :telemetry.execute(event, measurements, metadata) end + + @spec start([atom, ...], map, map) :: integer() + def start(event, metadata \\ %{}, measurements \\ %{}) do + start_time = System.monotonic_time() + measurements = Map.merge(measurements, %{system_time: System.system_time()}) + + execute(event ++ [:start], measurements, metadata) + + start_time + end + + @spec stop([atom, ...], integer(), map, map) :: :ok + def stop(event, start_time, metadata \\ %{}, measurements \\ %{}) do + end_time = System.monotonic_time() + measurements = Map.merge(measurements, %{duration: end_time - start_time}) + execute(event ++ [:stop], measurements, metadata) + end + + @spec exception([atom, ...], integer(), atom(), any(), list(), map, map) :: :ok + def exception(event, start_time, kind, reason, stacktrace, metadata \\ %{}, measurements \\ %{}) do + end_time = System.monotonic_time() + measurements = Map.merge(measurements, %{duration: end_time - start_time}) + metadata = Map.merge(metadata, %{kind: kind, reason: reason, stacktrace: stacktrace}) + execute(event ++ [:exception], measurements, metadata) + end end diff --git a/lib/realtime/tenants.ex b/lib/realtime/tenants.ex index 63965abea..b21617f06 100644 --- a/lib/realtime/tenants.ex +++ b/lib/realtime/tenants.ex @@ -3,27 +3,17 @@ defmodule Realtime.Tenants do Everything to do with Tenants. """ - require Logger + use Realtime.Logs - alias Realtime.Api alias Realtime.Api.Tenant alias Realtime.Database alias Realtime.RateCounter - alias Realtime.Repo alias Realtime.Repo.Replica alias Realtime.Tenants.Cache alias Realtime.Tenants.Connect alias Realtime.Tenants.Migrations alias Realtime.UsersCounter - @doc """ - Gets a list of connected tenant `external_id` strings in the cluster or a node. - """ - @spec list_connected_tenants(atom()) :: [String.t()] - def list_connected_tenants(node) do - :syn.group_names(:users, node) - end - @doc """ Gets the database connection pid managed by the Tenants.Connect process. @@ -32,7 +22,6 @@ defmodule Realtime.Tenants do iex> Realtime.Tenants.get_health_conn(%Realtime.Api.Tenant{external_id: "not_found_tenant"}) {:error, :tenant_database_connection_initializing} """ - @spec get_health_conn(Tenant.t()) :: {:error, term()} | {:ok, pid()} def get_health_conn(%Tenant{external_id: external_id}) do Connect.get_status(external_id) @@ -40,10 +29,13 @@ defmodule Realtime.Tenants do @doc """ Checks if a tenant is healthy. A tenant is healthy if: - - Tenant has no db connection and zero client connetions + - Tenant has no db connection and zero client connections - Tenant has a db connection and >0 client connections A tenant is not healthy if a tenant has client connections and no database connection. + + The response includes `replication_connected` to indicate if the replication connection + for broadcast changes is active. This is informational and does not affect the healthy status. """ @spec health_check(binary) :: @@ -52,7 +44,8 @@ defmodule Realtime.Tenants do | String.t() | %{ connected_cluster: pos_integer, - db_connected: false, + db_connected: boolean, + replication_connected: boolean, healthy: false, region: String.t(), node: String.t() @@ -60,7 +53,8 @@ defmodule Realtime.Tenants do | {:ok, %{ connected_cluster: non_neg_integer, - db_connected: true, + db_connected: boolean, + replication_connected: boolean, healthy: true, region: String.t(), node: String.t() @@ -76,6 +70,7 @@ defmodule Realtime.Tenants do %{ healthy: false, db_connected: false, + replication_connected: false, connected_cluster: connected_cluster, region: region, node: node @@ -86,11 +81,13 @@ defmodule Realtime.Tenants do {:ok, _health_conn} -> connected_cluster = UsersCounter.tenant_users(external_id) + replication_connected = replication_connected?(external_id) {:ok, %{ healthy: true, db_connected: true, + replication_connected: replication_connected, connected_cluster: connected_cluster, region: region, node: node @@ -98,14 +95,14 @@ defmodule Realtime.Tenants do connected_cluster when is_integer(connected_cluster) -> tenant = Cache.get_tenant_by_external_id(external_id) - {:ok, db_conn} = Database.connect(tenant, "realtime_health_check") - Process.alive?(db_conn) && GenServer.stop(db_conn) - Migrations.run_migrations(tenant) + healthy = not Migrations.run_migrations?(tenant) + if not healthy, do: Migrations.run_migrations_async(tenant) {:ok, %{ - healthy: true, + healthy: healthy, db_connected: false, + replication_connected: false, connected_cluster: connected_cluster, region: region, node: node @@ -113,6 +110,52 @@ defmodule Realtime.Tenants do end end + @doc """ + Creates the `realtime.messages` partitions for the days around today. + """ + @spec create_messages_partitions(pid()) :: :ok + def create_messages_partitions(db_conn_pid) do + Logger.info("Creating partitions for realtime.messages") + today = Date.utc_today() + yesterday = Date.add(today, -1) + future = Date.add(today, 3) + + dates = Date.range(yesterday, future) + + Enum.each(dates, fn date -> + partition_name = "messages_#{date |> Date.to_iso8601() |> String.replace("-", "_")}" + start_timestamp = Date.to_string(date) + end_timestamp = Date.to_string(Date.add(date, 1)) + + Database.transaction(db_conn_pid, fn conn -> + create = """ + CREATE TABLE IF NOT EXISTS realtime.#{partition_name} + PARTITION OF realtime.messages + FOR VALUES FROM ('#{start_timestamp}') TO ('#{end_timestamp}'); + """ + + alter_owner = "ALTER TABLE realtime.#{partition_name} OWNER TO supabase_realtime_admin" + + with {:ok, _} <- Postgrex.query(conn, create, []), + {:ok, _} <- Postgrex.query(conn, alter_owner, []) do + Logger.debug("Partition #{partition_name} created") + else + {:error, %Postgrex.Error{postgres: %{code: :duplicate_table}}} -> :ok + {:error, error} -> log_error("PartitionCreationFailed", error) + end + end) + end) + + :ok + end + + defp replication_connected?(external_id) do + case Connect.replication_status(external_id) do + {:ok, _pid} -> true + {:error, :not_connected} -> false + end + end + @doc """ All the keys that we use to create counters and RateLimiters for tenants. """ @@ -147,6 +190,8 @@ defmodule Realtime.Tenants do @spec joins_per_second_rate(String.t(), non_neg_integer) :: RateCounter.Args.t() def joins_per_second_rate(tenant_id, max_joins_per_second) when is_binary(tenant_id) do opts = [ + tick: :timer.seconds(5), + max_bucket_len: 12, telemetry: %{ event_name: [:channel, :joins], measurements: %{limit: max_joins_per_second}, @@ -193,6 +238,8 @@ defmodule Realtime.Tenants do def events_per_second_rate(tenant_id, max_events_per_second) do opts = [ + tick: :timer.seconds(5), + max_bucket_len: 12, telemetry: %{ event_name: [:channel, :events], measurements: %{limit: max_events_per_second}, @@ -232,16 +279,32 @@ defmodule Realtime.Tenants do end @doc "RateCounter arguments for counting database events per second." - @spec db_events_per_second_rate(Tenant.t() | String.t()) :: RateCounter.Args.t() - def db_events_per_second_rate(%Tenant{} = tenant), do: db_events_per_second_rate(tenant.external_id) + @spec db_events_per_second_rate(Tenant.t()) :: RateCounter.Args.t() + def db_events_per_second_rate(%Tenant{} = tenant), + do: db_events_per_second_rate(tenant.external_id, tenant.max_events_per_second) - def db_events_per_second_rate(tenant_id) when is_binary(tenant_id) do + @doc "RateCounter arguments for counting database events per second with a limit." + @spec db_events_per_second_rate(String.t(), non_neg_integer) :: RateCounter.Args.t() + def db_events_per_second_rate(tenant_id, max_events_per_second) when is_binary(tenant_id) do opts = [ + tick: :timer.seconds(5), + max_bucket_len: 12, telemetry: %{ event_name: [:channel, :db_events], measurements: %{}, metadata: %{tenant: tenant_id} - } + }, + limit: [ + value: max_events_per_second, + measurement: :avg, + log: true, + log_fn: fn -> + Logger.error("MessagePerSecondRateLimitReached: Too many postgres changes messages per second", + external_id: tenant_id, + project: tenant_id + ) + end + ] ] %RateCounter.Args{id: db_events_per_second_key(tenant_id), opts: opts} @@ -272,6 +335,8 @@ defmodule Realtime.Tenants do @spec presence_events_per_second_rate(String.t(), non_neg_integer) :: RateCounter.Args.t() def presence_events_per_second_rate(tenant_id, max_presence_events_per_second) do opts = [ + tick: :timer.seconds(5), + max_bucket_len: 12, telemetry: %{ event_name: [:channel, :presence_events], measurements: %{limit: max_presence_events_per_second}, @@ -314,7 +379,7 @@ defmodule Realtime.Tenants do opts = [ max_bucket_len: 30, limit: [ - value: pool_size(tenant), + value: authorization_pool_size(tenant), measurement: :sum, log_fn: fn -> Logger.critical("IncreaseConnectionPool: Too many database timeouts", @@ -325,14 +390,68 @@ defmodule Realtime.Tenants do ] ] - %RateCounter.Args{id: {:channel, :authorization_errors, external_id}, opts: opts} + %RateCounter.Args{id: authorization_errors_per_second_key(external_id), opts: opts} + end + + def authorization_errors_per_second_key(tenant_id), do: {:channel, :authorization_errors, tenant_id} + + @spec subscription_errors_per_second_rate(String.t(), non_neg_integer) :: RateCounter.Args.t() + def subscription_errors_per_second_rate(tenant_id, pool_size) do + opts = [ + max_bucket_len: 30, + limit: [ + value: pool_size, + measurement: :sum, + log_fn: fn -> + Logger.error("IncreaseSubscriptionConnectionPool: Too many database timeouts", + external_id: tenant_id, + project: tenant_id + ) + end + ] + ] + + %RateCounter.Args{id: subscription_errors_per_second_key(tenant_id), opts: opts} + end + + def subscription_errors_per_second_key(tenant_id), do: {:channel, :subscription_errors, tenant_id} + + @connect_errors_limit 3 + @connect_errors_tick 1000 + @connect_errors_bucket_len 5 + @doc "RateCounter arguments for counting connect errors. Uses a 1s tick with a 5-bucket window (5s) and triggers after 3 errors." + @spec connect_errors_per_second_rate(Tenant.t() | String.t()) :: RateCounter.Args.t() + def connect_errors_per_second_rate(%Tenant{external_id: external_id}) do + connect_errors_per_second_rate(external_id) + end + + def connect_errors_per_second_rate(tenant_id) do + opts = [ + tick: @connect_errors_tick, + max_bucket_len: @connect_errors_bucket_len, + limit: [ + value: @connect_errors_limit, + measurement: :sum, + log_fn: fn -> + Logger.critical( + "DatabaseConnectionRateLimitReached: Too many connection attempts against the tenant database", + external_id: tenant_id, + project: tenant_id + ) + end + ] + ] + + %RateCounter.Args{id: connect_errors_per_second_key(tenant_id), opts: opts} end - defp pool_size(%{extensions: [%{settings: settings} | _]}) do + def connect_errors_per_second_key(tenant_id), do: {:database, :connect, tenant_id} + + defp authorization_pool_size(%{extensions: [%{settings: settings} | _]}) do Database.pool_size_by_application_name("realtime_connect", settings) end - defp pool_size(_), do: 1 + defp authorization_pool_size(_), do: 1 @spec get_tenant_limits(Realtime.Api.Tenant.t(), maybe_improper_list) :: list def get_tenant_limits(%Tenant{} = tenant, keys) when is_list(keys) do @@ -394,59 +513,26 @@ defmodule Realtime.Tenants do do: "#{external_id}:#{sub_topic}" @doc """ - Sets tenant as suspended. New connections won't be accepted + Returns the region of the tenant based on its extensions. + If the region is not set, it returns nil. """ - @spec suspend_tenant_by_external_id(String.t()) :: {:ok, Tenant.t()} | {:error, term()} - def suspend_tenant_by_external_id(external_id) do - external_id - |> Cache.get_tenant_by_external_id() - |> Api.update_tenant(%{suspend: true}) - |> tap(fn _ -> broadcast_operation_event(:suspend_tenant, external_id) end) - end + @spec region(Tenant.t()) :: String.t() | nil + def region(%Tenant{extensions: [%{settings: settings}]}), do: Map.get(settings, "region") + def region(_), do: nil @doc """ - Sets tenant as unsuspended. New connections will be accepted """ - @spec unsuspend_tenant_by_external_id(String.t()) :: {:ok, Tenant.t()} | {:error, term()} - def unsuspend_tenant_by_external_id(external_id) do - external_id + @spec validate_payload_size(Tenant.t() | binary(), map() | binary()) :: :ok | {:error, :payload_size_exceeded} + def validate_payload_size(tenant_id, payload) when is_binary(tenant_id) do + tenant_id |> Cache.get_tenant_by_external_id() - |> Api.update_tenant(%{suspend: false}) - |> tap(fn _ -> broadcast_operation_event(:unsuspend_tenant, external_id) end) + |> validate_payload_size(payload) end - @doc """ - Checks if migrations for a given tenant need to run. - """ - @spec run_migrations?(Tenant.t()) :: boolean() - def run_migrations?(%Tenant{} = tenant) do - tenant.migrations_ran < Enum.count(Migrations.migrations()) + @payload_size_padding 500 + def validate_payload_size(%Tenant{max_payload_size_in_kb: max_payload_size_in_kb}, payload) do + max_payload_size = max_payload_size_in_kb * 1000 + @payload_size_padding + payload_size = :erlang.external_size(payload) + if payload_size > max_payload_size, do: {:error, :payload_size_exceeded}, else: :ok end - - @doc """ - Updates the migrations_ran field for a tenant. - """ - @spec update_migrations_ran(binary(), integer()) :: {:ok, Tenant.t()} | {:error, term()} - def update_migrations_ran(external_id, count) do - external_id - |> Cache.get_tenant_by_external_id() - |> Tenant.changeset(%{migrations_ran: count}) - |> Repo.update!() - |> tap(fn _ -> Cache.distributed_invalidate_tenant_cache(external_id) end) - end - - @doc """ - Broadcasts an operation event to the tenant's operations channel. - """ - @spec broadcast_operation_event(:suspend_tenant | :unsuspend_tenant | :disconnect, String.t()) :: :ok - def broadcast_operation_event(action, external_id), - do: Phoenix.PubSub.broadcast!(Realtime.PubSub, "realtime:operations:" <> external_id, action) - - @doc """ - Returns the region of the tenant based on its extensions. - If the region is not set, it returns nil. - """ - @spec region(Tenant.t()) :: String.t() | nil - def region(%Tenant{extensions: [%{settings: settings}]}), do: Map.get(settings, "region") - def region(_), do: nil end diff --git a/lib/realtime/tenants/authorization.ex b/lib/realtime/tenants/authorization.ex index da7093f61..a508fab00 100644 --- a/lib/realtime/tenants/authorization.ex +++ b/lib/realtime/tenants/authorization.ex @@ -17,7 +17,7 @@ defmodule Realtime.Tenants.Authorization do alias Realtime.Database alias Realtime.GenCounter alias Realtime.GenRpc - alias Realtime.Repo + alias Realtime.Tenants.Repo alias Realtime.Tenants.Authorization.Policies defstruct [:tenant_id, :topic, :headers, :jwt, :claims, :role, :sub] @@ -59,14 +59,22 @@ defmodule Realtime.Tenants.Authorization do Automatically uses RPC if the database connection is not in the same node """ - @spec get_read_authorizations(Policies.t(), pid(), t()) :: - {:ok, Policies.t()} | {:error, any()} | {:error, :rls_policy_error, any()} - def get_read_authorizations(policies, db_conn, authorization_context) when node() == node(db_conn) do + @spec get_read_authorizations(Policies.t(), pid(), t(), keyword()) :: + {:ok, Policies.t()} + | {:error, :rls_policy_error, Postgrex.Error.t()} + | {:error, :query_canceled, Postgrex.Error.t()} + | {:error, :missing_partition} + | {:error, :increase_connection_pool} + | {:error, :tenant_database_unavailable} + | {:error, any()} + def get_read_authorizations(policies, db_conn, authorization_context, opts \\ []) + + def get_read_authorizations(policies, db_conn, authorization_context, opts) when node() == node(db_conn) do rate_counter = rate_counter(authorization_context.tenant_id) if rate_counter.limit.triggered == false do db_conn - |> get_read_policies_for_connection(authorization_context, policies) + |> get_read_policies_for_connection(authorization_context, policies, opts) |> handle_policies_result(rate_counter) else {:error, :increase_connection_pool} @@ -74,7 +82,7 @@ defmodule Realtime.Tenants.Authorization do end # Remote call - def get_read_authorizations(policies, db_conn, authorization_context) do + def get_read_authorizations(policies, db_conn, authorization_context, opts) do rate_counter = rate_counter(authorization_context.tenant_id) if rate_counter.limit.triggered == false do @@ -82,7 +90,7 @@ defmodule Realtime.Tenants.Authorization do node(db_conn), __MODULE__, :get_read_authorizations, - [policies, db_conn, authorization_context], + [policies, db_conn, authorization_context, opts], tenant_id: authorization_context.tenant_id, key: authorization_context.tenant_id ) do @@ -106,14 +114,22 @@ defmodule Realtime.Tenants.Authorization do Automatically uses RPC if the database connection is not in the same node """ - @spec get_write_authorizations(Policies.t(), pid(), __MODULE__.t()) :: - {:ok, Policies.t()} | {:error, any()} | {:error, :rls_policy_error, any()} - def get_write_authorizations(policies, db_conn, authorization_context) when node() == node(db_conn) do + @spec get_write_authorizations(Policies.t(), pid(), t(), keyword()) :: + {:ok, Policies.t()} + | {:error, :rls_policy_error, Postgrex.Error.t()} + | {:error, :query_canceled, Postgrex.Error.t()} + | {:error, :missing_partition} + | {:error, :increase_connection_pool} + | {:error, :tenant_database_unavailable} + | {:error, any()} + def get_write_authorizations(policies, db_conn, authorization_context, opts \\ []) + + def get_write_authorizations(policies, db_conn, authorization_context, opts) when node() == node(db_conn) do rate_counter = rate_counter(authorization_context.tenant_id) if rate_counter.limit.triggered == false do db_conn - |> get_write_policies_for_connection(authorization_context, policies) + |> get_write_policies_for_connection(authorization_context, policies, opts) |> handle_policies_result(rate_counter) else {:error, :increase_connection_pool} @@ -121,7 +137,7 @@ defmodule Realtime.Tenants.Authorization do end # Remote call - def get_write_authorizations(policies, db_conn, authorization_context) do + def get_write_authorizations(policies, db_conn, authorization_context, opts) do rate_counter = rate_counter(authorization_context.tenant_id) if rate_counter.limit.triggered == false do @@ -129,7 +145,7 @@ defmodule Realtime.Tenants.Authorization do node(db_conn), __MODULE__, :get_write_authorizations, - [policies, db_conn, authorization_context], + [policies, db_conn, authorization_context, opts], tenant_id: authorization_context.tenant_id, key: authorization_context.tenant_id ) do @@ -148,9 +164,8 @@ defmodule Realtime.Tenants.Authorization do end end - def get_write_authorizations(db_conn, authorization_context) do - get_write_authorizations(%Policies{}, db_conn, authorization_context) - end + def get_write_authorizations(db_conn, authorization_context), + do: get_write_authorizations(%Policies{}, db_conn, authorization_context) defp handle_policies_result(result, rate_counter) do case result do @@ -160,6 +175,18 @@ defmodule Realtime.Tenants.Authorization do {:ok, {:error, %Postgrex.Error{} = error}} -> {:error, :rls_policy_error, error} + {:error, %Postgrex.Error{postgres: %{code: :invalid_parameter_value}} = error} -> + {:error, :rls_policy_error, error} + + {:error, %Postgrex.Error{postgres: %{code: :query_canceled}} = error} -> + {:error, :query_canceled, error} + + {:error, %Postgrex.Error{postgres: %{code: :check_violation, table: "messages"}}} -> + {:error, :missing_partition} + + {:error, %Postgrex.Error{} = error} -> + {:error, :rls_policy_error, error} + {:error, %ConnectionError{reason: :queue_timeout}} -> GenCounter.add(rate_counter.id) {:error, :increase_connection_pool} @@ -168,6 +195,9 @@ defmodule Realtime.Tenants.Authorization do GenCounter.add(rate_counter.id) {:error, :increase_connection_pool} + {:error, %ConnectionError{}} -> + {:error, :tenant_database_unavailable} + {:error, error} -> {:error, error} end @@ -210,132 +240,105 @@ defmodule Realtime.Tenants.Authorization do ) end - defp get_read_policies_for_connection(conn, authorization_context, policies) do + defp get_read_policies_for_connection(conn, authorization_context, policies, caller_opts) do tenant_id = authorization_context.tenant_id opts = [telemetry: [:realtime, :tenants, :read_authorization_check], tenant_id: tenant_id] metadata = [project: tenant_id, external_id: tenant_id, tenant_id: tenant_id] + extensions = extensions_to_check(caller_opts) Database.transaction( conn, fn transaction_conn -> - messages = [ - Message.changeset(%Message{}, %{ - topic: authorization_context.topic, - extension: :broadcast - }), - Message.changeset(%Message{}, %{ - topic: authorization_context.topic, - extension: :presence - }) - ] - - {:ok, messages} = Repo.insert_all_entries(transaction_conn, messages, Message) - - {[%{id: broadcast_id}], [%{id: presence_id}]} = - Enum.split_with(messages, &(&1.extension == :broadcast)) - - set_conn_config(transaction_conn, authorization_context) - - policies = - get_read_policy_for_connection_and_extension( - transaction_conn, - authorization_context, - broadcast_id, - presence_id, - policies - ) - - Postgrex.query!(transaction_conn, "ROLLBACK AND CHAIN", []) - policies + changesets = + Enum.map(extensions, fn ext -> + Message.changeset(%Message{}, %{topic: authorization_context.topic, extension: ext}) + end) + + with {:ok, messages} <- Repo.insert_all_entries(transaction_conn, changesets, Message), + messages_by_extension = Map.new(messages, &{&1.extension, &1.id}), + _ = set_conn_config(transaction_conn, authorization_context), + {:ok, policies} <- + check_read_policies(transaction_conn, authorization_context, messages_by_extension, policies) do + Postgrex.query!(transaction_conn, "ROLLBACK AND CHAIN", []) + policies + else + {:error, reason} -> DBConnection.rollback(transaction_conn, reason) + end end, opts, metadata ) end - defp get_write_policies_for_connection(conn, authorization_context, policies) do + defp get_write_policies_for_connection(conn, authorization_context, policies, caller_opts) do tenant_id = authorization_context.tenant_id opts = [telemetry: [:realtime, :tenants, :write_authorization_check], tenant_id: tenant_id] metadata = [project: tenant_id, external_id: tenant_id] + extensions = extensions_to_check(caller_opts) Database.transaction( conn, fn transaction_conn -> set_conn_config(transaction_conn, authorization_context) - policies = - get_write_policy_for_connection_and_extension( - transaction_conn, - authorization_context, - policies - ) - - Postgrex.query!(transaction_conn, "ROLLBACK AND CHAIN", []) - - policies + with {:ok, policies} <- check_write_policies(transaction_conn, authorization_context, extensions, policies) do + Postgrex.query!(transaction_conn, "ROLLBACK AND CHAIN", []) + policies + else + {:error, reason} -> DBConnection.rollback(transaction_conn, reason) + end end, opts, metadata ) end - defp get_read_policy_for_connection_and_extension( - conn, - authorization_context, - broadcast_id, - presence_id, - policies - ) do - query = - from(m in Message, - where: [topic: ^authorization_context.topic], - where: [extension: :broadcast, id: ^broadcast_id], - or_where: [extension: :presence, id: ^presence_id] - ) - - with {:ok, res} <- Repo.all(conn, query, Message) do - can_presence? = Enum.any?(res, fn %{id: id} -> id == presence_id end) - can_broadcast? = Enum.any?(res, fn %{id: id} -> id == broadcast_id end) + @all_extensions [:broadcast, :presence] - policies - |> Policies.update_policies(:presence, :read, can_presence?) - |> Policies.update_policies(:broadcast, :read, can_broadcast?) - end + defp extensions_to_check(opts) do + if Keyword.get(opts, :presence_enabled?, true), + do: @all_extensions, + else: [:broadcast] end - defp get_write_policy_for_connection_and_extension( - conn, - authorization_context, - policies - ) do - broadcast_changeset = - Message.changeset(%Message{}, %{topic: authorization_context.topic, extension: :broadcast}) + defp check_read_policies(conn, authorization_context, messages_by_extension, policies) do + ids = Map.values(messages_by_extension) - presence_changeset = - Message.changeset(%Message{}, %{topic: authorization_context.topic, extension: :presence}) + query = from(m in Message, where: m.topic == ^authorization_context.topic and m.id in ^ids) - policies = - case Repo.insert(conn, broadcast_changeset, Message, mode: :savepoint) do - {:ok, _} -> - Policies.update_policies(policies, :broadcast, :write, true) + with {:ok, res} <- Repo.all(conn, query, Message) do + returned_ids = MapSet.new(res, & &1.id) - {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} -> - Policies.update_policies(policies, :broadcast, :write, false) + {:ok, + Enum.reduce(@all_extensions, policies, fn extension, acc -> + can? = + Map.has_key?(messages_by_extension, extension) and + MapSet.member?(returned_ids, messages_by_extension[extension]) - e -> - e - end + Policies.update_policies(acc, extension, :read, can?) + end)} + end + end - case Repo.insert(conn, presence_changeset, Message, mode: :savepoint) do - {:ok, _} -> - Policies.update_policies(policies, :presence, :write, true) + defp check_write_policies(conn, authorization_context, extensions, policies) do + Enum.reduce_while(@all_extensions, {:ok, policies}, fn extension, {:ok, acc} -> + if extension in extensions do + changeset = Message.changeset(%Message{}, %{topic: authorization_context.topic, extension: extension}) - {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} -> - Policies.update_policies(policies, :presence, :write, false) + case Repo.insert(conn, changeset, Message, mode: :savepoint) do + {:ok, _} -> + {:cont, {:ok, Policies.update_policies(acc, extension, :write, true)}} - e -> - e - end + {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} -> + {:cont, {:ok, Policies.update_policies(acc, extension, :write, false)}} + + {:error, reason} -> + {:halt, {:error, reason}} + end + else + {:cont, {:ok, Policies.update_policies(acc, extension, :write, false)}} + end + end) end defp rate_counter(tenant_id) do diff --git a/lib/realtime/tenants/authorization/policies/broadcast_policies.ex b/lib/realtime/tenants/authorization/policies/broadcast_policies.ex index 80d1724c0..2b116afc6 100644 --- a/lib/realtime/tenants/authorization/policies/broadcast_policies.ex +++ b/lib/realtime/tenants/authorization/policies/broadcast_policies.ex @@ -2,8 +2,6 @@ defmodule Realtime.Tenants.Authorization.Policies.BroadcastPolicies do @moduledoc """ BroadcastPolicies structure that holds the required authorization information for a given connection within the scope of a sending / receiving broadcasts messages """ - require Logger - defstruct read: nil, write: nil @type t :: %__MODULE__{ diff --git a/lib/realtime/tenants/authorization/policies/presence_policies.ex b/lib/realtime/tenants/authorization/policies/presence_policies.ex index 228d45f10..889816660 100644 --- a/lib/realtime/tenants/authorization/policies/presence_policies.ex +++ b/lib/realtime/tenants/authorization/policies/presence_policies.ex @@ -2,8 +2,6 @@ defmodule Realtime.Tenants.Authorization.Policies.PresencePolicies do @moduledoc """ PresencePolicies structure that holds the required authorization information for a given connection within the scope of a tracking / receiving presence messages """ - require Logger - defstruct read: nil, write: nil @type t :: %__MODULE__{ diff --git a/lib/realtime/tenants/batch_broadcast.ex b/lib/realtime/tenants/batch_broadcast.ex index 4fc31aa0f..61913d37e 100644 --- a/lib/realtime/tenants/batch_broadcast.ex +++ b/lib/realtime/tenants/batch_broadcast.ex @@ -29,9 +29,11 @@ defmodule Realtime.Tenants.BatchBroadcast do @spec broadcast( auth_params :: map() | nil, tenant :: Tenant.t(), - messages :: %{messages: list(%{topic: String.t(), payload: map(), event: String.t(), private: boolean()})}, + messages :: %{ + messages: list(%{id: String.t(), topic: String.t(), payload: map(), event: String.t(), private: boolean()}) + }, super_user :: boolean() - ) :: :ok | {:error, atom()} + ) :: :ok | {:error, atom() | Ecto.Changeset.t()} def broadcast(auth_params, tenant, messages, super_user \\ false) def broadcast(%Plug.Conn{} = conn, %Tenant{} = tenant, messages, super_user) do @@ -46,8 +48,12 @@ defmodule Realtime.Tenants.BatchBroadcast do broadcast(auth_params, %Tenant{} = tenant, messages, super_user) end + def broadcast(_auth_params, %Tenant{suspend: true}, _messages, _super_user) do + {:error, :forbidden, "Tenant is suspended"} + end + def broadcast(auth_params, %Tenant{} = tenant, messages, super_user) do - with %Ecto.Changeset{valid?: true} = changeset <- changeset(%__MODULE__{}, messages), + with %Ecto.Changeset{valid?: true} = changeset <- changeset(%__MODULE__{}, messages, tenant), %Ecto.Changeset{changes: %{messages: messages}} = changeset, events_per_second_rate = Tenants.events_per_second_rate(tenant), :ok <- check_rate_limit(events_per_second_rate, tenant, length(messages)) do @@ -59,8 +65,8 @@ defmodule Realtime.Tenants.BatchBroadcast do # Handle events for public channel events |> Map.get(false, []) - |> Enum.each(fn %{topic: sub_topic, payload: payload, event: event} -> - send_message_and_count(tenant, events_per_second_rate, sub_topic, event, payload, true) + |> Enum.each(fn message -> + send_message_and_count(tenant, events_per_second_rate, message, true) end) # Handle events for private channel @@ -69,15 +75,11 @@ defmodule Realtime.Tenants.BatchBroadcast do |> Enum.group_by(fn event -> Map.get(event, :topic) end) |> Enum.each(fn {topic, events} -> if super_user do - Enum.each(events, fn %{topic: sub_topic, payload: payload, event: event} -> - send_message_and_count(tenant, events_per_second_rate, sub_topic, event, payload, false) - end) + Enum.each(events, fn message -> send_message_and_count(tenant, events_per_second_rate, message, false) end) else case permissions_for_message(tenant, auth_params, topic) do %Policies{broadcast: %BroadcastPolicies{write: true}} -> - Enum.each(events, fn %{topic: sub_topic, payload: payload, event: event} -> - send_message_and_count(tenant, events_per_second_rate, sub_topic, event, payload, false) - end) + Enum.each(events, fn message -> send_message_and_count(tenant, events_per_second_rate, message, false) end) _ -> nil @@ -86,22 +88,26 @@ defmodule Realtime.Tenants.BatchBroadcast do end) :ok + else + %Ecto.Changeset{valid?: false} = changeset -> {:error, changeset} + error -> error end end def broadcast(_, nil, _, _), do: {:error, :tenant_not_found} - def changeset(payload, attrs) do + defp changeset(payload, attrs, tenant) do payload |> cast(attrs, []) - |> cast_embed(:messages, required: true, with: &message_changeset/2) + |> cast_embed(:messages, required: true, with: fn message, attrs -> message_changeset(message, tenant, attrs) end) end - def message_changeset(message, attrs) do + defp message_changeset(message, tenant, attrs) do message - |> cast(attrs, [:topic, :payload, :event, :private]) + |> cast(attrs, [:id, :topic, :payload, :event, :private]) |> maybe_put_private_change() |> validate_required([:topic, :payload, :event]) + |> validate_payload_size(tenant) end defp maybe_put_private_change(changeset) do @@ -111,15 +117,37 @@ defmodule Realtime.Tenants.BatchBroadcast do end end + defp validate_payload_size(changeset, tenant) do + payload = get_change(changeset, :payload) + + case Tenants.validate_payload_size(tenant, payload) do + :ok -> changeset + _ -> add_error(changeset, :payload, "Payload size exceeds tenant limit") + end + end + @event_type "broadcast" - defp send_message_and_count(tenant, events_per_second_rate, topic, event, payload, public?) do - tenant_topic = Tenants.tenant_topic(tenant, topic, public?) - payload = %{"payload" => payload, "event" => event, "type" => "broadcast"} + defp send_message_and_count(tenant, events_per_second_rate, message, public?) do + tenant_topic = Tenants.tenant_topic(tenant, message.topic, public?) + + payload = %{"payload" => message.payload, "event" => message.event, "type" => "broadcast"} - broadcast = %Phoenix.Socket.Broadcast{topic: topic, event: @event_type, payload: payload} + payload = + if message[:id], + do: Map.put(payload, "meta", %{"id" => message.id}), + else: payload + + broadcast = %Phoenix.Socket.Broadcast{topic: message.topic, event: @event_type, payload: payload} GenCounter.add(events_per_second_rate.id) - TenantBroadcaster.pubsub_broadcast(tenant.external_id, tenant_topic, broadcast, RealtimeChannel.MessageDispatcher) + + TenantBroadcaster.pubsub_broadcast( + tenant.external_id, + tenant_topic, + broadcast, + RealtimeChannel.MessageDispatcher, + :broadcast + ) end defp permissions_for_message(_, nil, _), do: nil diff --git a/lib/realtime/tenants/cache.ex b/lib/realtime/tenants/cache.ex index aead951a3..4bc830982 100644 --- a/lib/realtime/tenants/cache.ex +++ b/lib/realtime/tenants/cache.ex @@ -3,8 +3,9 @@ defmodule Realtime.Tenants.Cache do Cache for Tenants. """ require Cachex.Spec - require Logger + alias Realtime.Api.Tenant + alias Realtime.GenRpc alias Realtime.Tenants def child_spec(_) do @@ -16,32 +17,51 @@ defmodule Realtime.Tenants.Cache do } end - def get_tenant_by_external_id(keyword), do: apply_repo_fun(__ENV__.function, [keyword]) + @spec fetch_tenant_by_external_id(String.t()) :: {:ok, Tenant.t()} | {:error, :tenant_not_found} + def fetch_tenant_by_external_id(tenant_id) do + case get_tenant_by_external_id(tenant_id) do + %Tenant{} = tenant -> {:ok, tenant} + _ -> {:error, :tenant_not_found} + end + end + + @spec get_tenant_by_external_id(String.t()) :: Tenant.t() | nil + def get_tenant_by_external_id(tenant_id) do + case Cachex.fetch(__MODULE__, cache_key(tenant_id), fn _key -> + case Tenants.get_tenant_by_external_id(tenant_id) do + %Tenant{} = tenant -> {:commit, tenant} + _ -> {:ignore, nil} + end + end) do + {:commit, value} -> value + {:ok, value} -> value + {:ignore, value} -> value + end + end + + defp cache_key(tenant_id), do: {:get_tenant_by_external_id, tenant_id} @doc """ Invalidates the cache for a tenant in the local node """ - def invalidate_tenant_cache(tenant_id), do: Cachex.del(__MODULE__, {{:get_tenant_by_external_id, 1}, [tenant_id]}) + def invalidate_tenant_cache(tenant_id), do: Cachex.del(__MODULE__, cache_key(tenant_id)) + + def distributed_invalidate_tenant_cache(tenant_id) when is_binary(tenant_id) do + GenRpc.multicast(__MODULE__, :invalidate_tenant_cache, [tenant_id]) + end @doc """ - Broadcasts a message to invalidate the tenant cache to all connected nodes + Update the cache for a tenant """ - @spec distributed_invalidate_tenant_cache(String.t()) :: boolean() - def distributed_invalidate_tenant_cache(tenant_id) when is_binary(tenant_id) do - nodes = [Node.self() | Node.list()] - results = :erpc.multicall(nodes, __MODULE__, :invalidate_tenant_cache, [tenant_id], 1000) - - results - |> Enum.map(fn - {res, _} -> - res - - exception -> - Logger.error("Failed to invalidate tenant cache: #{inspect(exception)}") - :error - end) - |> Enum.all?(&(&1 == :ok)) + def update_cache(tenant) do + Cachex.put(__MODULE__, cache_key(tenant.external_id), tenant) end - defp apply_repo_fun(arg1, arg2), do: Realtime.ContextCache.apply_fun(Tenants, arg1, arg2) + @doc """ + Update the cache for a tenant in all nodes + """ + @spec global_cache_update(Realtime.Api.Tenant.t()) :: :ok + def global_cache_update(tenant) do + GenRpc.multicast(__MODULE__, :update_cache, [tenant]) + end end diff --git a/lib/realtime/tenants/connect.ex b/lib/realtime/tenants/connect.ex index b9bf00eb4..6b4a1437d 100644 --- a/lib/realtime/tenants/connect.ex +++ b/lib/realtime/tenants/connect.ex @@ -11,40 +11,66 @@ defmodule Realtime.Tenants.Connect do use Realtime.Logs - alias Realtime.Tenants.Rebalancer alias Realtime.Api.Tenant + alias Realtime.GenCounter + alias Realtime.RateCounter alias Realtime.Rpc alias Realtime.Tenants alias Realtime.Tenants.Connect.CheckConnection alias Realtime.Tenants.Connect.GetTenant alias Realtime.Tenants.Connect.Piper + alias Realtime.Tenants.Connect.ReconcileMigrations alias Realtime.Tenants.Connect.RegisterProcess - alias Realtime.Tenants.Connect.StartCounters alias Realtime.Tenants.Migrations + alias Realtime.Tenants.Rebalancer alias Realtime.Tenants.ReplicationConnection alias Realtime.UsersCounter + alias DBConnection.Backoff @rpc_timeout_default 30_000 - @check_connected_user_interval_default 50_000 - @connected_users_bucket_shutdown [0, 0, 0, 0, 0, 0] + @check_connected_user_interval_default :timer.seconds(60) + @connected_users_bucket_shutdown [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + @type t :: %__MODULE__{ + tenant_id: binary(), + db_conn_reference: reference(), + db_conn_pid: pid(), + replication_connection_pid: pid(), + replication_connection_reference: reference(), + replication_start_task: reference() | nil, + backoff: Backoff.t(), + replication_recovery_started_at: non_neg_integer() | nil, + check_connected_user_interval: non_neg_integer(), + connected_users_bucket: list(non_neg_integer()), + check_connect_region_interval: non_neg_integer(), + migrations_ran_on_database: non_neg_integer() + } defstruct tenant_id: nil, db_conn_reference: nil, db_conn_pid: nil, replication_connection_pid: nil, replication_connection_reference: nil, + replication_start_task: nil, + backoff: nil, + replication_recovery_started_at: nil, check_connected_user_interval: nil, connected_users_bucket: [1], - check_connect_region_interval: nil + check_connect_region_interval: nil, + migrations_ran_on_database: 0 + + @tenant_id_spec [{{:"$1", :_, :_, :_, :_, :_}, [], [:"$1"]}] + @spec list_tenants() :: [binary] + def list_tenants() do + :syn_registry_by_name + |> :syn_backbone.get_table_name(__MODULE__) + |> :ets.select(@tenant_id_spec) + end @doc "Check if Connect has finished setting up connections" def ready?(tenant_id) do case whereis(tenant_id) do - pid when is_pid(pid) -> - GenServer.call(pid, :ready?) - - _ -> - false + pid when is_pid(pid) -> GenServer.call(pid, :ready?) + _ -> false end end @@ -56,20 +82,39 @@ defmodule Realtime.Tenants.Connect do | {:error, :tenant_database_unavailable} | {:error, :initializing} | {:error, :tenant_database_connection_initializing} + | {:error, :tenant_db_too_many_connections} + | {:error, :connect_rate_limit_reached} | {:error, :rpc_error, term()} def lookup_or_start_connection(tenant_id, opts \\ []) when is_binary(tenant_id) do case get_status(tenant_id) do {:ok, conn} -> {:ok, conn} - {:error, :tenant_database_unavailable} -> - call_external_node(tenant_id, opts) - - {:error, :tenant_database_connection_initializing} -> - call_external_node(tenant_id, opts) - - {:error, :initializing} -> - {:error, :tenant_database_unavailable} + error -> + rate_args = Tenants.connect_errors_per_second_rate(tenant_id) + {:ok, rate} = RateCounter.get(rate_args) + + if rate.limit.triggered do + {:error, :connect_rate_limit_reached} + else + case error do + {:error, :tenant_database_connection_initializing} -> + case call_external_node(tenant_id, opts) do + {:ok, pid} -> + {:ok, pid} + + err -> + GenCounter.add(rate_args.id) + err + end + + {:error, :initializing} -> + {:error, :tenant_database_unavailable} + + {:error, reason} -> + {:error, reason} + end + end end end @@ -81,16 +126,19 @@ defmodule Realtime.Tenants.Connect do | {:error, :tenant_database_unavailable} | {:error, :initializing} | {:error, :tenant_database_connection_initializing} + | {:error, :tenant_db_too_many_connections} def get_status(tenant_id) do case :syn.lookup(__MODULE__, tenant_id) do - {_pid, %{conn: nil}} -> - wait_for_connection(tenant_id) + {pid, %{conn: nil}} -> + wait_for_connection(pid, tenant_id) + + {_, %{conn: conn, replication_conn: nil}} -> + {:ok, conn} {_, %{conn: conn}} -> {:ok, conn} :undefined -> - Logger.warning("Connection process starting up") {:error, :tenant_database_connection_initializing} error -> @@ -101,7 +149,7 @@ defmodule Realtime.Tenants.Connect do def syn_topic(tenant_id), do: "connect:#{tenant_id}" - defp wait_for_connection(tenant_id) do + defp wait_for_connection(pid, tenant_id) do RealtimeWeb.Endpoint.subscribe(syn_topic(tenant_id)) # We do a lookup after subscribing because we could've missed a message while subscribing @@ -112,9 +160,18 @@ defmodule Realtime.Tenants.Connect do _ -> # Wait for up to 5 seconds for the ready event receive do - %{event: "ready", payload: %{conn: conn}} -> {:ok, conn} + %{event: "ready", payload: %{pid: ^pid, conn: conn}} -> + {:ok, conn} + + %{event: "connect_down", payload: %{pid: ^pid, reason: {:shutdown, :tenant_db_too_many_connections}}} -> + {:error, :tenant_db_too_many_connections} + + %{event: "connect_down", payload: %{pid: ^pid, reason: _reason}} -> + metadata = [external_id: tenant_id, project: tenant_id] + log_error("UnableToConnectToTenantDatabase", "Unable to connect to tenant database", metadata) + {:error, :tenant_database_unavailable} after - 5_000 -> {:error, :initializing} + 15_000 -> {:error, :initializing} end end after @@ -139,16 +196,6 @@ defmodule Realtime.Tenants.Connect do {:error, {:already_started, _}} -> get_status(tenant_id) - {:error, {:shutdown, :tenant_db_too_many_connections}} -> - {:error, :tenant_db_too_many_connections} - - {:error, {:shutdown, :tenant_not_found}} -> - {:error, :tenant_not_found} - - {:error, :shutdown} -> - log_error("UnableToConnectToTenantDatabase", "Unable to connect to tenant database", metadata) - {:error, :tenant_database_unavailable} - {:error, error} -> log_error("UnableToConnectToTenantDatabase", error, metadata) {:error, :tenant_database_unavailable} @@ -166,10 +213,21 @@ defmodule Realtime.Tenants.Connect do end end + @doc """ + Returns the replication connection status from :syn metadata without RPC calls. + """ + @spec replication_status(binary()) :: {:ok, pid()} | {:error, :not_connected} + def replication_status(tenant_id) do + case :syn.lookup(__MODULE__, tenant_id) do + {_, %{replication_conn: pid}} when is_pid(pid) -> {:ok, pid} + _ -> {:error, :not_connected} + end + end + @doc """ Shutdown the tenant Connection and linked processes """ - @spec shutdown(binary()) :: :ok | nil + @spec shutdown(binary()) :: :ok def shutdown(tenant_id) do case whereis(tenant_id) do pid when is_pid(pid) -> @@ -190,12 +248,13 @@ defmodule Realtime.Tenants.Connect do check_connect_region_interval = Keyword.get(opts, :check_connect_region_interval, rebalance_check_interval_in_ms()) - name = {__MODULE__, tenant_id, %{conn: nil, region: region}} + name = {__MODULE__, tenant_id, %{conn: nil, region: region, replication_conn: nil}} state = %__MODULE__{ tenant_id: tenant_id, check_connected_user_interval: check_connected_user_interval, - check_connect_region_interval: check_connect_region_interval + check_connect_region_interval: check_connect_region_interval, + backoff: Backoff.new(backoff_min: :timer.seconds(5), backoff_max: :timer.minutes(5), backoff_type: :rand_exp) } opts = Keyword.put(opts, :name, {:via, :syn, name}) @@ -209,36 +268,40 @@ defmodule Realtime.Tenants.Connect do def init(%{tenant_id: tenant_id} = state) do Logger.metadata(external_id: tenant_id, project: tenant_id) + {:ok, state, {:continue, :db_connect}} + end + + @impl true + def handle_continue(:db_connect, state) do pipes = [ GetTenant, CheckConnection, - StartCounters, + ReconcileMigrations, RegisterProcess ] case Piper.run(pipes, state) do {:ok, acc} -> - {:ok, acc, {:continue, :run_migrations}} + {:noreply, acc, {:continue, :provision_tenant}} {:error, :tenant_not_found} -> - {:stop, {:shutdown, :tenant_not_found}} + {:stop, {:shutdown, :tenant_not_found}, state} {:error, :tenant_db_too_many_connections} -> - {:stop, {:shutdown, :tenant_db_too_many_connections}} + {:stop, {:shutdown, :tenant_db_too_many_connections}, state} {:error, error} -> log_error("UnableToConnectToTenantDatabase", error) - {:stop, :shutdown} + {:stop, :shutdown, state} end end - @impl true - def handle_continue(:run_migrations, state) do + def handle_continue(:provision_tenant, state) do %{tenant: tenant, db_conn_pid: db_conn_pid} = state - Logger.warning("Tenant #{tenant.external_id} is initializing: #{inspect(node())}") + Logger.info("Tenant #{tenant.external_id} is initializing: #{inspect(node())}") with res when res in [:ok, :noop] <- Migrations.run_migrations(tenant), - :ok <- Migrations.create_partitions(db_conn_pid) do + :ok <- Tenants.create_messages_partitions(db_conn_pid) do {:noreply, state, {:continue, :start_replication}} else error -> @@ -252,31 +315,7 @@ defmodule Realtime.Tenants.Connect do end def handle_continue(:start_replication, state) do - %{tenant: tenant} = state - - with {:ok, replication_connection_pid} <- ReplicationConnection.start(tenant, self()) do - replication_connection_reference = Process.monitor(replication_connection_pid) - - state = %{ - state - | replication_connection_pid: replication_connection_pid, - replication_connection_reference: replication_connection_reference - } - - {:noreply, state, {:continue, :setup_connected_user_events}} - else - {:error, :max_wal_senders_reached} -> - log_error("ReplicationMaxWalSendersReached", "Tenant database has reached the maximum number of WAL senders") - {:stop, :shutdown, state} - - {:error, error} -> - log_error("StartReplicationFailed", error) - {:stop, :shutdown, state} - end - rescue - error -> - log_error("StartReplicationFailed", error) - {:stop, :shutdown, state} + {:noreply, async_start_replication_connection(state), {:continue, :setup_connected_user_events}} end def handle_continue(:setup_connected_user_events, state) do @@ -353,8 +392,89 @@ defmodule Realtime.Tenants.Connect do {:DOWN, replication_connection_reference, _, _, _}, %{replication_connection_reference: replication_connection_reference} = state ) do - Logger.warning("Replication connection has died") - {:stop, :shutdown, state} + log_warning("ReplicationConnectionDown", "Replication connection has been terminated, recovery window opened") + {:noreply, open_replication_recovery(state)} + end + + # Handle the result of the async replication connection start task + def handle_info({replication_start_task, result}, %{replication_start_task: replication_start_task} = state) do + Process.demonitor(replication_start_task, [:flush]) + state = %{state | replication_start_task: nil} + + case result do + {:ok, replication_connection_pid} -> + update_syn_replication_conn(state.tenant_id, replication_connection_pid) + replication_connection_reference = Process.monitor(replication_connection_pid) + + {:noreply, + %{ + state + | replication_connection_pid: replication_connection_pid, + replication_connection_reference: replication_connection_reference, + backoff: Backoff.reset(state.backoff), + replication_recovery_started_at: nil + }} + + {:error, :max_wal_senders_reached} -> + log_error("ReplicationMaxWalSendersReached", "Tenant database has reached the maximum number of WAL senders") + {:noreply, open_replication_recovery(state)} + + {:error, :replication_connection_timeout} -> + log_error("ReplicationConnectionTimeout", "Replication connection timed out during initialization") + {:noreply, open_replication_recovery(state)} + + {:error, error} -> + log_error("StartReplicationFailed", error) + {:noreply, open_replication_recovery(state)} + end + end + + # Handle a crash of the async replication connection start task + def handle_info( + {:DOWN, replication_start_task, :process, _, reason}, + %{replication_start_task: replication_start_task} = state + ) do + log_error("StartReplicationFailed", reason) + {:noreply, open_replication_recovery(%{state | replication_start_task: nil})} + end + + @replication_connection_query "SELECT 1 from pg_stat_activity where application_name='realtime_replication_connection'" + @max_replication_recovery_ms :timer.hours(2) + def handle_info(:recover_replication_connection, %{replication_recovery_started_at: nil} = state) do + {:noreply, state} + end + + def handle_info(:recover_replication_connection, state) do + %{db_conn_pid: db_conn_pid, replication_recovery_started_at: started_at} = state + elapsed = System.monotonic_time(:millisecond) - started_at + + cond do + elapsed > @max_replication_recovery_ms -> + log_warning( + "ReplicationRecoveryWindowExceeded", + "Replication recovery window exceeded after #{elapsed}ms, terminating connection" + ) + + {:stop, :shutdown, state} + + # A start is already in flight, don't start another one + is_reference(state.replication_start_task) -> + {:noreply, state} + + true -> + case Postgrex.query(db_conn_pid, @replication_connection_query, []) do + {:ok, %{num_rows: 0}} -> + {:noreply, async_start_replication_connection(state)} + + {:ok, %{num_rows: _}} -> + Logger.info("Waiting for old walsender to exit") + {:noreply, schedule_replication_retry(state)} + + {:error, error} -> + log_error("ReplicationConnectionRecoveryFailed", "DB check failed during recovery: #{inspect(error)}") + {:noreply, schedule_replication_retry(state)} + end + end end def handle_info(_, state), do: {:noreply, state} @@ -369,12 +489,12 @@ defmodule Realtime.Tenants.Connect do @impl true def terminate(reason, %{tenant_id: tenant_id}) do Logger.info("Tenant #{tenant_id} has been terminated: #{inspect(reason)}") - Realtime.MetricsCleaner.delete_metric(tenant_id) :ok end ## Private functions defp call_external_node(tenant_id, opts) do + Logger.warning("Connection process starting up") rpc_timeout = Keyword.get(opts, :rpc_timeout, @rpc_timeout_default) with tenant <- Tenants.Cache.get_tenant_by_external_id(tenant_id), @@ -390,7 +510,7 @@ defmodule Realtime.Tenants.Connect do defp update_connected_users_bucket(tenant_id, connected_users_bucket) do connected_users_bucket |> then(&(&1 ++ [UsersCounter.tenant_users(tenant_id)])) - |> Enum.take(-6) + |> Enum.take(-11) end defp send_connected_user_check_message( @@ -413,4 +533,44 @@ defmodule Realtime.Tenants.Connect do defp tenant_suspended?(_), do: :ok defp rebalance_check_interval_in_ms(), do: Application.fetch_env!(:realtime, :rebalance_check_interval_in_ms) + + defp open_replication_recovery(%{tenant_id: tenant_id} = state) do + update_syn_replication_conn(tenant_id, nil) + recovery_started_at = state.replication_recovery_started_at || System.monotonic_time(:millisecond) + + state = %{ + state + | replication_connection_pid: nil, + replication_connection_reference: nil, + replication_recovery_started_at: recovery_started_at + } + + schedule_replication_retry(state) + end + + defp schedule_replication_retry(%{backoff: backoff} = state) do + {timeout, backoff} = Backoff.backoff(backoff) + Process.send_after(self(), :recover_replication_connection, timeout) + %{state | backoff: backoff} + end + + defp update_syn_replication_conn(tenant_id, pid) do + :syn.update_registry(__MODULE__, tenant_id, fn _pid, meta -> %{meta | replication_conn: pid} end) + end + + # Starts the replication connection off-process so Connect stays responsive to its mailbox + # while Postgrex.ReplicationConnection performs its (up to ~30s) synchronous connect. + # The resulting process is supervised by its own DynamicSupervisor and monitors `connect_pid`, + # so it self-cleans if Connect dies while the start is in flight. `connect_pid` must be captured + # here (the Connect process) and not inside the task, which would pass the task's pid. + defp async_start_replication_connection(%{tenant: tenant} = state) do + connect_pid = self() + + %Task{ref: ref} = + Task.Supervisor.async_nolink(Realtime.TaskSupervisor, fn -> + ReplicationConnection.start(tenant, connect_pid) + end) + + %{state | replication_start_task: ref} + end end diff --git a/lib/realtime/tenants/connect/check_connection.ex b/lib/realtime/tenants/connect/check_connection.ex index 697c08b6c..ec16db269 100644 --- a/lib/realtime/tenants/connect/check_connection.ex +++ b/lib/realtime/tenants/connect/check_connection.ex @@ -2,18 +2,23 @@ defmodule Realtime.Tenants.Connect.CheckConnection do @moduledoc """ Check tenant database connection. """ - alias Realtime.Database @behaviour Realtime.Tenants.Connect.Piper @impl true def run(acc) do %{tenant: tenant} = acc - case Database.check_tenant_connection(tenant) do - {:ok, conn} -> - Process.link(conn) + case Realtime.Database.check_tenant_connection(tenant) do + {:ok, conn, migrations_ran} -> db_conn_reference = Process.monitor(conn) - {:ok, %{acc | db_conn_pid: conn, db_conn_reference: db_conn_reference}} + + {:ok, + %{ + acc + | db_conn_pid: conn, + db_conn_reference: db_conn_reference, + migrations_ran_on_database: migrations_ran + }} {:error, error} -> {:error, error} diff --git a/lib/realtime/tenants/connect/reconcile_migrations.ex b/lib/realtime/tenants/connect/reconcile_migrations.ex new file mode 100644 index 000000000..33c18821e --- /dev/null +++ b/lib/realtime/tenants/connect/reconcile_migrations.ex @@ -0,0 +1,40 @@ +defmodule Realtime.Tenants.Connect.ReconcileMigrations do + @moduledoc """ + Reconciles the tenant's cached migrations_ran counter with the actual + migration count from the tenant database's schema_migrations table. + + This handles the case where a project restore causes the database schema + to revert while the migrations_ran counter remains at the latest value. + """ + + alias Realtime.Api + alias Realtime.Telemetry + + @behaviour Realtime.Tenants.Connect.Piper + + @event [:realtime, :tenants, :migrations, :reconcile] + + @impl true + def run(%{tenant: %{migrations_ran: migrations_ran}, migrations_ran_on_database: migrations_ran} = acc), + do: {:ok, acc} + + def run(%{tenant: tenant, migrations_ran_on_database: migrations_ran_on_database} = acc) do + metadata = %{ + external_id: tenant.external_id, + cached_migrations_ran: tenant.migrations_ran, + database_migrations_ran: migrations_ran_on_database + } + + start_time = Telemetry.start(@event, metadata) + + case Api.update_migrations_ran(tenant.external_id, migrations_ran_on_database) do + {:ok, updated_tenant} -> + Telemetry.stop(@event, start_time, metadata) + {:ok, %{acc | tenant: updated_tenant}} + + {:error, error} -> + Telemetry.exception(@event, start_time, :error, error, [], metadata) + {:error, error} + end + end +end diff --git a/lib/realtime/tenants/connect/start_counters.ex b/lib/realtime/tenants/connect/start_counters.ex deleted file mode 100644 index f8ce6c378..000000000 --- a/lib/realtime/tenants/connect/start_counters.ex +++ /dev/null @@ -1,60 +0,0 @@ -defmodule Realtime.Tenants.Connect.StartCounters do - @moduledoc """ - Start tenant counters. - """ - - alias Realtime.RateCounter - alias Realtime.Tenants - - @behaviour Realtime.Tenants.Connect.Piper - - @impl true - def run(acc) do - %{tenant: tenant} = acc - - with :ok <- start_joins_per_second_counter(tenant), - :ok <- start_max_events_counter(tenant), - :ok <- start_db_events_counter(tenant) do - {:ok, acc} - end - end - - def start_joins_per_second_counter(tenant) do - res = - tenant - |> Tenants.joins_per_second_rate() - |> RateCounter.new() - - case res do - {:ok, _} -> :ok - {:error, {:already_started, _}} -> :ok - {:error, reason} -> {:error, reason} - end - end - - def start_max_events_counter(tenant) do - res = - tenant - |> Tenants.events_per_second_rate() - |> RateCounter.new() - - case res do - {:ok, _} -> :ok - {:error, {:already_started, _}} -> :ok - {:error, reason} -> {:error, reason} - end - end - - def start_db_events_counter(tenant) do - res = - tenant - |> Tenants.db_events_per_second_rate() - |> RateCounter.new() - - case res do - {:ok, _} -> :ok - {:error, {:already_started, _}} -> :ok - {:error, reason} -> {:error, reason} - end - end -end diff --git a/lib/realtime/tenants/janitor/maintenance_task.ex b/lib/realtime/tenants/janitor/maintenance_task.ex index 4a01432c5..4ecebcf9d 100644 --- a/lib/realtime/tenants/janitor/maintenance_task.ex +++ b/lib/realtime/tenants/janitor/maintenance_task.ex @@ -1,8 +1,10 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTask do @moduledoc """ - Perform maintenance on the messages table. - * Delete old messages - * Create new partitions + Perform maintenance on tenant's database: + + - Delete old messages + - Create new partitions + """ @spec run(String.t()) :: :ok | {:error, any} @@ -10,7 +12,7 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTask do with %Realtime.Api.Tenant{} = tenant <- Realtime.Tenants.Cache.get_tenant_by_external_id(tenant_external_id), {:ok, conn} <- Realtime.Database.connect(tenant, "realtime_janitor"), :ok <- Realtime.Messages.delete_old_messages(conn), - :ok <- Realtime.Tenants.Migrations.create_partitions(conn) do + :ok <- Realtime.Tenants.create_messages_partitions(conn) do GenServer.stop(conn) :ok end diff --git a/lib/realtime/tenants/migrations.ex b/lib/realtime/tenants/migrations.ex index 04475c2b7..c1ba36a26 100644 --- a/lib/realtime/tenants/migrations.ex +++ b/lib/realtime/tenants/migrations.ex @@ -1,149 +1,103 @@ defmodule Realtime.Tenants.Migrations do @moduledoc """ - Run Realtime database migrations for tenant's database. + Manage Tenant's database migrations. """ + use GenServer, restart: :transient use Realtime.Logs - alias Realtime.Tenants alias Realtime.Database + alias Realtime.FeatureFlags alias Realtime.Registry.Unique alias Realtime.Repo alias Realtime.Api.Tenant + alias Realtime.Api + alias Realtime.Nodes + alias Realtime.GenRpc + alias Realtime.Telemetry - alias Realtime.Tenants.Migrations.{ - CreateRealtimeSubscriptionTable, - CreateRealtimeCheckFiltersTrigger, - CreateRealtimeQuoteWal2jsonFunction, - CreateRealtimeCheckEqualityOpFunction, - CreateRealtimeBuildPreparedStatementSqlFunction, - CreateRealtimeCastFunction, - CreateRealtimeIsVisibleThroughFiltersFunction, - CreateRealtimeApplyRlsFunction, - GrantRealtimeUsageToAuthenticatedRole, - EnableRealtimeApplyRlsFunctionPostgrest9Compatibility, - UpdateRealtimeSubscriptionCheckFiltersFunctionSecurity, - UpdateRealtimeBuildPreparedStatementSqlFunctionForCompatibilityWithAllTypes, - EnableGenericSubscriptionClaims, - AddWalPayloadOnErrorsInApplyRlsFunction, - UpdateChangeTimestampToIso8601ZuluFormat, - UpdateSubscriptionCheckFiltersFunctionDynamicTableName, - UpdateApplyRlsFunctionToApplyIso8601, - AddQuotedRegtypesSupport, - AddOutputForDataLessThanEqual64BytesWhenPayloadTooLarge, - AddQuotedRegtypesBackwardCompatibilitySupport, - RecreateRealtimeBuildPreparedStatementSqlFunction, - NullPassesFiltersRecreateIsVisibleThroughFilters, - UpdateApplyRlsFunctionToPassThroughDeleteEventsOnFilter, - MillisecondPrecisionForWalrus, - AddInOpToFilters, - EnableFilteringOnDeleteRecord, - UpdateSubscriptionCheckFiltersForInFilterNonTextTypes, - ConvertCommitTimestampToUtc, - OutputFullRecordWhenUnchangedToast, - CreateListChangesFunction, - CreateChannels, - SetRequiredGrants, - CreateRlsHelperFunctions, - EnableChannelsRls, - AddChannelsColumnForWriteCheck, - AddUpdateGrantToChannels, - AddBroadcastsPoliciesTable, - AddInsertAndDeleteGrantToChannels, - AddPresencesPoliciesTable, - CreateRealtimeAdminAndMoveOwnership, - RemoveCheckColumns, - RedefineAuthorizationTables, - FixWalrusRoleHandling, - UnloggedMessagesTable, - LoggedMessagesTable, - FilterDeletePostgresChanges, - AddPayloadToMessages, - ChangeMessagesIdType, - UuidAutoGeneration, - MessagesPartitioning, - MessagesUsingUuid, - FixSendFunction, - RecreateEntityIndexUsingBtree, - FixSendFunctionPartitionCreation, - RealtimeSendHandleExceptionsRemovePartitionCreation, - RealtimeSendSetsConfig, - RealtimeSubscriptionUnlogged, - RealtimeSubscriptionLogged, - RemoveUnusedPublications, - RealtimeSendSetsTopicConfig, - SubscriptionIndexBridgingDisabled, - RunSubscriptionIndexBridgingDisabled, - BroadcastSendErrorLogging - } + alias Realtime.Tenants.Migrations @migrations [ - {20_211_116_024_918, CreateRealtimeSubscriptionTable}, - {20_211_116_045_059, CreateRealtimeCheckFiltersTrigger}, - {20_211_116_050_929, CreateRealtimeQuoteWal2jsonFunction}, - {20_211_116_051_442, CreateRealtimeCheckEqualityOpFunction}, - {20_211_116_212_300, CreateRealtimeBuildPreparedStatementSqlFunction}, - {20_211_116_213_355, CreateRealtimeCastFunction}, - {20_211_116_213_934, CreateRealtimeIsVisibleThroughFiltersFunction}, - {20_211_116_214_523, CreateRealtimeApplyRlsFunction}, - {20_211_122_062_447, GrantRealtimeUsageToAuthenticatedRole}, - {20_211_124_070_109, EnableRealtimeApplyRlsFunctionPostgrest9Compatibility}, - {20_211_202_204_204, UpdateRealtimeSubscriptionCheckFiltersFunctionSecurity}, - {20_211_202_204_605, UpdateRealtimeBuildPreparedStatementSqlFunctionForCompatibilityWithAllTypes}, - {20_211_210_212_804, EnableGenericSubscriptionClaims}, - {20_211_228_014_915, AddWalPayloadOnErrorsInApplyRlsFunction}, - {20_220_107_221_237, UpdateChangeTimestampToIso8601ZuluFormat}, - {20_220_228_202_821, UpdateSubscriptionCheckFiltersFunctionDynamicTableName}, - {20_220_312_004_840, UpdateApplyRlsFunctionToApplyIso8601}, - {20_220_603_231_003, AddQuotedRegtypesSupport}, - {20_220_603_232_444, AddOutputForDataLessThanEqual64BytesWhenPayloadTooLarge}, - {20_220_615_214_548, AddQuotedRegtypesBackwardCompatibilitySupport}, - {20_220_712_093_339, RecreateRealtimeBuildPreparedStatementSqlFunction}, - {20_220_908_172_859, NullPassesFiltersRecreateIsVisibleThroughFilters}, - {20_220_916_233_421, UpdateApplyRlsFunctionToPassThroughDeleteEventsOnFilter}, - {20_230_119_133_233, MillisecondPrecisionForWalrus}, - {20_230_128_025_114, AddInOpToFilters}, - {20_230_128_025_212, EnableFilteringOnDeleteRecord}, - {20_230_227_211_149, UpdateSubscriptionCheckFiltersForInFilterNonTextTypes}, - {20_230_228_184_745, ConvertCommitTimestampToUtc}, - {20_230_308_225_145, OutputFullRecordWhenUnchangedToast}, - {20_230_328_144_023, CreateListChangesFunction}, - {20_231_018_144_023, CreateChannels}, - {20_231_204_144_023, SetRequiredGrants}, - {20_231_204_144_024, CreateRlsHelperFunctions}, - {20_231_204_144_025, EnableChannelsRls}, - {20_240_108_234_812, AddChannelsColumnForWriteCheck}, - {20_240_109_165_339, AddUpdateGrantToChannels}, - {20_240_227_174_441, AddBroadcastsPoliciesTable}, - {20_240_311_171_622, AddInsertAndDeleteGrantToChannels}, - {20_240_321_100_241, AddPresencesPoliciesTable}, - {20_240_401_105_812, CreateRealtimeAdminAndMoveOwnership}, - {20_240_418_121_054, RemoveCheckColumns}, - {20_240_523_004_032, RedefineAuthorizationTables}, - {20_240_618_124_746, FixWalrusRoleHandling}, - {20_240_801_235_015, UnloggedMessagesTable}, - {20_240_805_133_720, LoggedMessagesTable}, - {20_240_827_160_934, FilterDeletePostgresChanges}, - {20_240_919_163_303, AddPayloadToMessages}, - {20_240_919_163_305, ChangeMessagesIdType}, - {20_241_019_105_805, UuidAutoGeneration}, - {20_241_030_150_047, MessagesPartitioning}, - {20_241_108_114_728, MessagesUsingUuid}, - {20_241_121_104_152, FixSendFunction}, - {20_241_130_184_212, RecreateEntityIndexUsingBtree}, - {20_241_220_035_512, FixSendFunctionPartitionCreation}, - {20_241_220_123_912, RealtimeSendHandleExceptionsRemovePartitionCreation}, - {20_241_224_161_212, RealtimeSendSetsConfig}, - {20_250_107_150_512, RealtimeSubscriptionUnlogged}, - {20_250_110_162_412, RealtimeSubscriptionLogged}, - {20_250_123_174_212, RemoveUnusedPublications}, - {20_250_128_220_012, RealtimeSendSetsTopicConfig}, - {20_250_506_224_012, SubscriptionIndexBridgingDisabled}, - {20_250_523_164_012, RunSubscriptionIndexBridgingDisabled}, - {20_250_714_121_412, BroadcastSendErrorLogging} + {20_211_116_024_918, Migrations.CreateRealtimeSubscriptionTable}, + {20_211_116_045_059, Migrations.CreateRealtimeCheckFiltersTrigger}, + {20_211_116_050_929, Migrations.CreateRealtimeQuoteWal2jsonFunction}, + {20_211_116_051_442, Migrations.CreateRealtimeCheckEqualityOpFunction}, + {20_211_116_212_300, Migrations.CreateRealtimeBuildPreparedStatementSqlFunction}, + {20_211_116_213_355, Migrations.CreateRealtimeCastFunction}, + {20_211_116_213_934, Migrations.CreateRealtimeIsVisibleThroughFiltersFunction}, + {20_211_116_214_523, Migrations.CreateRealtimeApplyRlsFunction}, + {20_211_122_062_447, Migrations.GrantRealtimeUsageToAuthenticatedRole}, + {20_211_124_070_109, Migrations.EnableRealtimeApplyRlsFunctionPostgrest9Compatibility}, + {20_211_202_204_204, Migrations.UpdateRealtimeSubscriptionCheckFiltersFunctionSecurity}, + {20_211_202_204_605, Migrations.UpdateRealtimeBuildPreparedStatementSqlFunctionForCompatibilityWithAllTypes}, + {20_211_210_212_804, Migrations.EnableGenericSubscriptionClaims}, + {20_211_228_014_915, Migrations.AddWalPayloadOnErrorsInApplyRlsFunction}, + {20_220_107_221_237, Migrations.UpdateChangeTimestampToIso8601ZuluFormat}, + {20_220_228_202_821, Migrations.UpdateSubscriptionCheckFiltersFunctionDynamicTableName}, + {20_220_312_004_840, Migrations.UpdateApplyRlsFunctionToApplyIso8601}, + {20_220_603_231_003, Migrations.AddQuotedRegtypesSupport}, + {20_220_603_232_444, Migrations.AddOutputForDataLessThanEqual64BytesWhenPayloadTooLarge}, + {20_220_615_214_548, Migrations.AddQuotedRegtypesBackwardCompatibilitySupport}, + {20_220_712_093_339, Migrations.RecreateRealtimeBuildPreparedStatementSqlFunction}, + {20_220_908_172_859, Migrations.NullPassesFiltersRecreateIsVisibleThroughFilters}, + {20_220_916_233_421, Migrations.UpdateApplyRlsFunctionToPassThroughDeleteEventsOnFilter}, + {20_230_119_133_233, Migrations.MillisecondPrecisionForWalrus}, + {20_230_128_025_114, Migrations.AddInOpToFilters}, + {20_230_128_025_212, Migrations.EnableFilteringOnDeleteRecord}, + {20_230_227_211_149, Migrations.UpdateSubscriptionCheckFiltersForInFilterNonTextTypes}, + {20_230_228_184_745, Migrations.ConvertCommitTimestampToUtc}, + {20_230_308_225_145, Migrations.OutputFullRecordWhenUnchangedToast}, + {20_230_328_144_023, Migrations.CreateListChangesFunction}, + {20_231_018_144_023, Migrations.CreateChannels}, + {20_231_204_144_023, Migrations.SetRequiredGrants}, + {20_231_204_144_024, Migrations.CreateRlsHelperFunctions}, + {20_231_204_144_025, Migrations.EnableChannelsRls}, + {20_240_108_234_812, Migrations.AddChannelsColumnForWriteCheck}, + {20_240_109_165_339, Migrations.AddUpdateGrantToChannels}, + {20_240_227_174_441, Migrations.AddBroadcastsPoliciesTable}, + {20_240_311_171_622, Migrations.AddInsertAndDeleteGrantToChannels}, + {20_240_321_100_241, Migrations.AddPresencesPoliciesTable}, + {20_240_401_105_812, Migrations.CreateRealtimeAdminAndMoveOwnership}, + {20_240_418_121_054, Migrations.RemoveCheckColumns}, + {20_240_523_004_032, Migrations.RedefineAuthorizationTables}, + {20_240_618_124_746, Migrations.FixWalrusRoleHandling}, + {20_240_801_235_015, Migrations.UnloggedMessagesTable}, + {20_240_805_133_720, Migrations.LoggedMessagesTable}, + {20_240_827_160_934, Migrations.FilterDeletePostgresChanges}, + {20_240_919_163_303, Migrations.AddPayloadToMessages}, + {20_240_919_163_305, Migrations.ChangeMessagesIdType}, + {20_241_019_105_805, Migrations.UuidAutoGeneration}, + {20_241_030_150_047, Migrations.MessagesPartitioning}, + {20_241_108_114_728, Migrations.MessagesUsingUuid}, + {20_241_121_104_152, Migrations.FixSendFunction}, + {20_241_130_184_212, Migrations.RecreateEntityIndexUsingBtree}, + {20_241_220_035_512, Migrations.FixSendFunctionPartitionCreation}, + {20_241_220_123_912, Migrations.RealtimeSendHandleExceptionsRemovePartitionCreation}, + {20_241_224_161_212, Migrations.RealtimeSendSetsConfig}, + {20_250_107_150_512, Migrations.RealtimeSubscriptionUnlogged}, + {20_250_110_162_412, Migrations.RealtimeSubscriptionLogged}, + {20_250_123_174_212, Migrations.RemoveUnusedPublications}, + {20_250_128_220_012, Migrations.RealtimeSendSetsTopicConfig}, + {20_250_506_224_012, Migrations.SubscriptionIndexBridgingDisabled}, + {20_250_523_164_012, Migrations.RunSubscriptionIndexBridgingDisabled}, + {20_250_714_121_412, Migrations.BroadcastSendErrorLogging}, + {20_250_905_041_441, Migrations.CreateMessagesReplayIndex}, + {20_251_103_001_201, Migrations.BroadcastSendIncludePayloadId}, + {20_251_120_212_548, Migrations.AddActionToSubscriptions}, + {20_251_120_215_549, Migrations.FilterActionPostgresChanges}, + {20_260_218_120_000, Migrations.FixByteaDoubleEncodingInCast}, + {20_260_326_120_000, Migrations.ListChangesWithSlotCount}, + {20_260_514_120_000, Migrations.AddBinaryPayloadToMessages}, + {20_260_527_120_000, Migrations.AddSelectColumnsToSubscriptions}, + {20_260_528_120_000, Migrations.Wal2jsonEscapeSpecialChars}, + {20_260_603_120_000, Migrations.AddSendBinaryFunction}, + {20_260_605_120_000, Migrations.RenameBroadcastSendWarning}, + {20_260_606_110_000, Migrations.SubscriptionCheckFiltersUsePgAttribute}, + {20_260_606_120_000, Migrations.SetupSupabaseRealtimeAdmin} ] - defstruct [:tenant_external_id, :settings] + defstruct [:tenant_external_id, :settings, migrations_ran: 0] @type t :: %__MODULE__{ tenant_external_id: binary(), @@ -151,25 +105,74 @@ defmodule Realtime.Tenants.Migrations do } @doc """ - Run migrations for the given tenant. + Checks if migrations for a given tenant need to run. + """ + @spec run_migrations?(Tenant.t() | integer()) :: boolean() + def run_migrations?(%Tenant{} = tenant) do + available_migrations = + tenant.external_id + |> migrations() + |> Enum.count() + + tenant.migrations_ran < available_migrations + end + + def run_migrations?(migrations_ran) when is_integer(migrations_ran), + do: migrations_ran < Enum.count(migrations()) + + @doc """ + Run migrations for the given tenant, blocking until they complete. """ @spec run_migrations(Tenant.t()) :: :ok | :noop | {:error, any()} def run_migrations(%Tenant{} = tenant) do + if run_migrations?(tenant) do + {node, attrs} = migration_target(tenant) + GenRpc.call(node, __MODULE__, :start_migration, [attrs], tenant_id: tenant.external_id, timeout: 50_000) + else + :noop + end + end + + @doc """ + Triggers migrations for the given tenant without blocking the caller. + """ + @spec run_migrations_async(Tenant.t()) :: :ok | :noop + def run_migrations_async(%Tenant{} = tenant) do + if run_migrations?(tenant) do + {node, attrs} = migration_target(tenant) + GenRpc.cast(node, __MODULE__, :start_migration, [attrs]) + else + :noop + end + end + + defp migration_target(%Tenant{} = tenant) do %{extensions: [%{settings: settings} | _]} = tenant - attrs = %__MODULE__{tenant_external_id: tenant.external_id, settings: settings} + attrs = %__MODULE__{ + tenant_external_id: tenant.external_id, + settings: settings, + migrations_ran: tenant.migrations_ran + } + + node = + case Nodes.get_node_for_tenant(tenant) do + {:ok, node, _} -> node + {:error, _} -> node() + end + + {node, attrs} + end + + def start_migration(attrs) do supervisor = - {:via, PartitionSupervisor, {Realtime.Tenants.Migrations.DynamicSupervisor, tenant.external_id}} + {:via, PartitionSupervisor, {Realtime.Tenants.Migrations.DynamicSupervisor, attrs.tenant_external_id}} spec = {__MODULE__, attrs} - if Tenants.run_migrations?(tenant) do - case DynamicSupervisor.start_child(supervisor, spec) do - :ignore -> :ok - error -> error - end - else - :noop + case DynamicSupervisor.start_child(supervisor, spec) do + :ignore -> :ok + error -> error end end @@ -181,11 +184,11 @@ defmodule Realtime.Tenants.Migrations do def init(%__MODULE__{tenant_external_id: tenant_external_id, settings: settings}) do Logger.metadata(external_id: tenant_external_id, project: tenant_external_id) - case migrate(settings) do + case migrate(tenant_external_id, settings) do :ok -> - Task.Supervisor.async_nolink(__MODULE__.TaskSupervisor, Tenants, :update_migrations_ran, [ + Task.Supervisor.async_nolink(__MODULE__.TaskSupervisor, Api, :update_migrations_ran, [ tenant_external_id, - Enum.count(@migrations) + Enum.count(migrations(tenant_external_id)) ]) :ignore @@ -195,71 +198,70 @@ defmodule Realtime.Tenants.Migrations do end end - defp migrate(settings) do - settings = Database.from_settings(settings, "realtime_migrations", :stop) - - [ - hostname: settings.hostname, - port: settings.port, - database: settings.database, - password: settings.password, - username: settings.username, - pool_size: settings.pool_size, - backoff_type: settings.backoff_type, - socket_options: settings.socket_options, - parameters: [application_name: settings.application_name], - ssl: settings.ssl - ] - |> Repo.with_dynamic_repo(fn repo -> - Logger.info("Applying migrations to #{settings.hostname}") - - try do - opts = [all: true, prefix: "realtime", dynamic_repo: repo] - Ecto.Migrator.run(Repo, @migrations, :up, opts) - - :ok - rescue - error -> - log_error("MigrationsFailedToRun", error) - {:error, error} - end - end) + defp migrate(tenant_external_id, settings) do + platform_region = Map.get(settings, "region") + + with {:ok, settings} <- Database.from_settings(settings, "realtime_migrations", :stop) do + [ + hostname: settings.hostname, + port: settings.port, + database: settings.database, + password: settings.password, + username: settings.username, + pool_size: settings.pool_size, + backoff_type: settings.backoff_type, + socket_options: settings.socket_options, + parameters: [application_name: settings.application_name], + ssl: settings.ssl + ] + |> Repo.with_dynamic_repo(fn repo -> + event = [:realtime, :tenants, :migrations] + metadata = %{external_id: tenant_external_id, hostname: settings.hostname, platform_region: platform_region} + start_time = Telemetry.start(event, metadata) + + try do + opts = [all: true, prefix: "realtime", dynamic_repo: repo] + result = Ecto.Migrator.run(Repo, migrations(tenant_external_id), :up, opts) + Telemetry.stop(event, start_time, Map.put(metadata, :migrations_executed, length(result))) + rescue + error -> + metadata = Map.put(metadata, :error_code, error_code(error)) + + Telemetry.exception( + event, + start_time, + :error, + error, + __STACKTRACE__, + metadata + ) + + {:error, error} + end + end) + end end + defp error_code(%Postgrex.Error{postgres: %{code: code}}), do: code + defp error_code(%DBConnection.ConnectionError{}), do: :connection_error + defp error_code(_), do: :other + @doc """ - Create partitions against tenant db connection + Returns the migrations to run. """ - @spec create_partitions(pid()) :: :ok - def create_partitions(db_conn_pid) do - Logger.info("Creating partitions for realtime.messages") - today = Date.utc_today() - yesterday = Date.add(today, -1) - future = Date.add(today, 3) - - dates = Date.range(yesterday, future) - - Enum.each(dates, fn date -> - partition_name = "messages_#{date |> Date.to_iso8601() |> String.replace("-", "_")}" - start_timestamp = Date.to_string(date) - end_timestamp = Date.to_string(Date.add(date, 1)) - - Database.transaction(db_conn_pid, fn conn -> - query = """ - CREATE TABLE IF NOT EXISTS realtime.#{partition_name} - PARTITION OF realtime.messages - FOR VALUES FROM ('#{start_timestamp}') TO ('#{end_timestamp}'); - """ - - case Postgrex.query(conn, query, []) do - {:ok, _} -> Logger.debug("Partition #{partition_name} created") - {:error, %Postgrex.Error{postgres: %{code: :duplicate_table}}} -> :ok - {:error, error} -> log_error("PartitionCreationFailed", error) - end - end) - end) + @spec migrations(String.t() | nil) :: [{pos_integer(), module()}] + def migrations(tenant_external_id \\ nil) do + Enum.filter(@migrations, fn {_version, module} -> migration_enabled?(module, tenant_external_id) end) + end + + defp migration_enabled?(Migrations.SetupSupabaseRealtimeAdmin, nil = _tenant_external_id) do + FeatureFlags.enabled?("use_supabase_realtime_admin") + end - :ok + defp migration_enabled?(Migrations.SetupSupabaseRealtimeAdmin, tenant_external_id) + when is_binary(tenant_external_id) do + FeatureFlags.enabled?("use_supabase_realtime_admin", tenant_external_id) end - def migrations(), do: @migrations + defp migration_enabled?(_migration, _tenant_external_id), do: true end diff --git a/lib/realtime/tenants/replication_connection.ex b/lib/realtime/tenants/replication_connection.ex index 45e03c66e..be3a4830f 100644 --- a/lib/realtime/tenants/replication_connection.ex +++ b/lib/realtime/tenants/replication_connection.ex @@ -11,7 +11,7 @@ defmodule Realtime.Tenants.ReplicationConnection do * `publication_name` - The name of the publication to create. If not provided, it will use the schema and table name. * `replication_slot_name` - The name of the replication slot to create. If not provided, it will use the schema and table name. * `output_plugin` - The output plugin to use. Default is `pgoutput`. - * `proto_version` - The protocol version to use. Default is `1`. + * `proto_version` - The protocol version to use. Default is `2`. * `handler_module` - The module that will handle the data received from the replication stream. * `metadata` - The metadata to pass to the handler module. @@ -27,9 +27,17 @@ defmodule Realtime.Tenants.ReplicationConnection do alias Realtime.Adapters.Postgres.Protocol.Write alias Realtime.Api.Tenant alias Realtime.Database + alias Realtime.GenCounter + alias Realtime.RateCounter alias Realtime.Telemetry - alias Realtime.Tenants.BatchBroadcast + alias Realtime.Tenants alias Realtime.Tenants.Cache + alias Realtime.Tenants.Connect + alias RealtimeWeb.RealtimeChannel + alias RealtimeWeb.Socket.UserBroadcast + alias RealtimeWeb.TenantBroadcaster + + @default_query_timeout :timer.minutes(4) @type t :: %__MODULE__{ tenant_id: String.t(), @@ -38,8 +46,8 @@ defmodule Realtime.Tenants.ReplicationConnection do :disconnected | :check_replication_slot | :create_publication - | :check_publication - | :create_slot + | :validate_publication + | :create_replication_slot | :start_replication_slot | :streaming, publication_name: String.t(), @@ -49,7 +57,8 @@ defmodule Realtime.Tenants.ReplicationConnection do relations: map(), buffer: list(), monitored_pid: pid(), - latency_committed_at: integer() + latency_committed_at: integer(), + query_timeout: timeout() } defstruct tenant_id: nil, opts: [], @@ -57,46 +66,27 @@ defmodule Realtime.Tenants.ReplicationConnection do publication_name: nil, replication_slot_name: nil, output_plugin: "pgoutput", - proto_version: 1, + proto_version: 2, relations: %{}, buffer: [], monitored_pid: nil, - latency_committed_at: nil - - defmodule Wrapper do - @moduledoc """ - This GenServer exists at the moment so that we can have an init timeout for ReplicationConnection - """ - use GenServer - - def start_link(args, init_timeout) do - GenServer.start_link(__MODULE__, args, timeout: init_timeout) - end - - @impl true - def init(args) do - case Realtime.Tenants.ReplicationConnection.start_link(args) do - {:ok, pid} -> {:ok, pid} - {:error, reason} -> {:stop, reason} - end - end - end + latency_committed_at: nil, + query_timeout: @default_query_timeout - @default_init_timeout 30_000 @table "messages" @schema "realtime" @doc """ Starts the replication connection for a tenant and monitors a given pid to stop the ReplicationConnection. """ - @spec start(Realtime.Api.Tenant.t(), pid()) :: {:ok, pid()} | {:error, any()} - def start(tenant, monitored_pid, init_timeout \\ @default_init_timeout) do + @spec start(Realtime.Api.Tenant.t(), pid(), query_timeout :: timeout) :: {:ok, pid()} | {:error, any()} + def start(tenant, monitored_pid, query_timeout \\ @default_query_timeout) do Logger.info("Starting replication for Broadcast Changes") - opts = %__MODULE__{tenant_id: tenant.external_id, monitored_pid: monitored_pid} - supervisor_spec = supervisor_spec(tenant) + opts = %__MODULE__{tenant_id: tenant.external_id, monitored_pid: monitored_pid, query_timeout: query_timeout} + supervisor_spec = supervisor_spec(tenant.external_id) child_spec = %{ id: __MODULE__, - start: {Wrapper, :start_link, [opts, init_timeout]}, + start: {__MODULE__, :start_link, [opts]}, restart: :temporary, type: :worker } @@ -114,11 +104,17 @@ defmodule Realtime.Tenants.ReplicationConnection do {:error, %Postgrex.Error{postgres: %{pg_code: pg_code}}} when pg_code in ~w(53300 53400) -> {:error, :max_wal_senders_reached} + {:error, %DBConnection.ConnectionError{}} -> + {:error, :replication_connection_timeout} + error -> error end end + @spec stop(String.t(), pid()) :: :ok | {:error, any()} + def stop(tenant_id, pid), do: DynamicSupervisor.terminate_child(supervisor_spec(tenant_id), pid) + @doc """ Finds replication connection by tenant_id """ @@ -130,42 +126,76 @@ defmodule Realtime.Tenants.ReplicationConnection do end end + def ready?(tenant_id) do + RealtimeWeb.Endpoint.subscribe(Connect.syn_topic(tenant_id)) + # We do a lookup after subscribing because we could've missed a message while subscribing + case Connect.replication_status(tenant_id) do + {:ok, _} -> + true + + _ -> + # Wait for up to 5 seconds for the ready event + receive do + %{event: "ready", payload: %{replication_conn: conn}} when is_pid(conn) -> + true + after + 5_000 -> false + end + end + after + RealtimeWeb.Endpoint.unsubscribe(Connect.syn_topic(tenant_id)) + end + + @spec health_check(pid(), timeout()) :: :ok | no_return() + def health_check(pid, timeout), do: Postgrex.ReplicationConnection.call(pid, :health_check, timeout) + def start_link(%__MODULE__{tenant_id: tenant_id} = attrs) do tenant = Cache.get_tenant_by_external_id(tenant_id) - connection_opts = Database.from_tenant(tenant, "realtime_broadcast_changes", :stop) - - connection_opts = - [ - name: {:via, Registry, {Realtime.Registry.Unique, {__MODULE__, tenant_id}}}, - hostname: connection_opts.hostname, - username: connection_opts.username, - password: connection_opts.password, - database: connection_opts.database, - port: connection_opts.port, - socket_options: connection_opts.socket_options, - ssl: connection_opts.ssl, - backoff_type: :stop, - sync_connect: true, - parameters: [application_name: "realtime_replication_connection"] - ] - - case Postgrex.ReplicationConnection.start_link(__MODULE__, attrs, connection_opts) do - {:ok, pid} -> {:ok, pid} - {:error, {:already_started, pid}} -> {:ok, pid} - {:error, {:bad_return_from_init, {:stop, error}}} -> {:error, error} - {:error, error} -> {:error, error} + + with {:ok, db_settings} <- Database.from_tenant(tenant, "realtime_broadcast_changes", :stop) do + connection_opts = + [ + name: {:via, Registry, {Realtime.Registry.Unique, {__MODULE__, tenant_id}}}, + hostname: db_settings.hostname, + username: db_settings.username, + password: db_settings.password, + database: db_settings.database, + port: db_settings.port, + socket_options: db_settings.socket_options, + ssl: db_settings.ssl, + sync_connect: true, + auto_reconnect: false, + parameters: [application_name: "realtime_replication_connection"] + ] + + case Postgrex.ReplicationConnection.start_link(__MODULE__, attrs, connection_opts) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + {:error, {:bad_return_from_init, {:stop, error}}} -> {:error, error} + {:error, error} -> {:error, error} + end end end @impl true def init(%__MODULE__{tenant_id: tenant_id, monitored_pid: monitored_pid} = state) do + Process.flag(:fullsweep_after, 20) Logger.metadata(external_id: tenant_id, project: tenant_id) Process.monitor(monitored_pid) + slot_name = replication_slot_name(@schema, @table) + + {:ok, _watchdog_pid} = + Realtime.Tenants.ReplicationConnection.Watchdog.start_link( + parent_pid: self(), + tenant_id: tenant_id, + replication_slot_name: slot_name + ) + state = %{ state | publication_name: publication_name(@schema, @table), - replication_slot_name: replication_slot_name(@schema, @table) + replication_slot_name: slot_name } Logger.info("Initializing connection with the status: #{inspect(state, pretty: true)}") @@ -175,40 +205,31 @@ defmodule Realtime.Tenants.ReplicationConnection do @impl true def handle_connect(state) do + # Postgrex.Protocol.connect/1 traps exits due to how DbConnection works + # But ReplicationConnection does not interact with DbConnection so we don't + # want to trap exits + Process.flag(:trap_exit, false) replication_slot_name = replication_slot_name(@schema, @table) Logger.info("Checking if replication slot #{replication_slot_name} exists") query = "SELECT * FROM pg_replication_slots WHERE slot_name = '#{replication_slot_name}'" - {:query, query, %{state | step: :check_replication_slot}} + {:query, query, [timeout: state.query_timeout], %{state | step: :check_replication_slot}} end @impl true - def handle_result([%Postgrex.Result{num_rows: 1}], %__MODULE__{step: :check_replication_slot}) do - {:disconnect, {:shutdown, "Temporary Replication slot already exists and in use"}} + def handle_result([%Postgrex.Result{num_rows: 1}], %__MODULE__{step: :check_replication_slot} = _state) do + Logger.info("Replication slot already exists and in use, deferring connection") + {:disconnect, {:shutdown, :replication_slot_in_use}} end def handle_result([%Postgrex.Result{num_rows: 0}], %__MODULE__{step: :check_replication_slot} = state) do - %__MODULE__{ - output_plugin: output_plugin, - replication_slot_name: replication_slot_name, - step: :check_replication_slot - } = state - - Logger.info("Create replication slot #{replication_slot_name} using plugin #{output_plugin}") - - query = "CREATE_REPLICATION_SLOT #{replication_slot_name} TEMPORARY LOGICAL #{output_plugin} NOEXPORT_SNAPSHOT" - - {:query, query, %{state | step: :check_publication}} - end - - def handle_result([%Postgrex.Result{}], %__MODULE__{step: :check_publication} = state) do %__MODULE__{publication_name: publication_name} = state Logger.info("Check publication #{publication_name} for table #{@schema}.#{@table} exists") query = "SELECT * FROM pg_publication WHERE pubname = '#{publication_name}'" - {:query, query, %{state | step: :create_publication}} + {:query, query, [timeout: state.query_timeout], %{state | step: :create_publication}} end def handle_result([%Postgrex.Result{num_rows: 0}], %__MODULE__{step: :create_publication} = state) do @@ -217,31 +238,105 @@ defmodule Realtime.Tenants.ReplicationConnection do Logger.info("Create publication #{publication_name} for table #{@schema}.#{@table}") query = "CREATE PUBLICATION #{publication_name} FOR TABLE #{@schema}.#{@table}" - {:query, query, %{state | step: :start_replication_slot}} + {:query, query, [timeout: state.query_timeout], %{state | step: :create_replication_slot}} end def handle_result([%Postgrex.Result{num_rows: 1}], %__MODULE__{step: :create_publication} = state) do - {:query, "SELECT 1", %{state | step: :start_replication_slot}} + %__MODULE__{publication_name: publication_name} = state + + Logger.info("Publication #{publication_name} exists, validating contents") + + query = """ + SELECT schemaname, tablename + FROM pg_publication_tables + WHERE pubname = '#{publication_name}' + """ + + {:query, query, [timeout: state.query_timeout], %{state | step: :validate_publication}} + end + + def handle_result([%Postgrex.Result{rows: rows}], %__MODULE__{step: :validate_publication} = state) do + %__MODULE__{publication_name: publication_name} = state + + valid_tables = + Enum.all?(rows, fn [schema, table] -> + schema == @schema and (table == @table or String.starts_with?(table, "#{@table}_")) + end) + + if valid_tables and rows != [] do + {:query, "SELECT 1", [timeout: state.query_timeout], %{state | step: :create_replication_slot}} + else + query = + "DROP PUBLICATION IF EXISTS #{publication_name}; CREATE PUBLICATION #{publication_name} FOR TABLE #{@schema}.#{@table}" + + Logger.warning("Publication #{publication_name} contains unexpected tables. Recreating...") + {:query, query, [timeout: state.query_timeout], %{state | step: :create_replication_slot}} + end end - def handle_result([%Postgrex.Result{}], %__MODULE__{step: :start_replication_slot} = state) do + def handle_result(%Postgrex.Error{postgres: %{message: message}}, %__MODULE__{step: :create_replication_slot}) do + {:disconnect, "Error creating publication: #{message}"} + end + + def handle_result(%Postgrex.Error{message: message}, %__MODULE__{step: :create_replication_slot}) do + {:disconnect, "Error creating publication: #{message}"} + end + + def handle_result(results, %__MODULE__{step: :create_replication_slot} = state) do %__MODULE__{ - proto_version: proto_version, - replication_slot_name: replication_slot_name, - publication_name: publication_name + output_plugin: output_plugin, + replication_slot_name: replication_slot_name } = state - Logger.info( - "Starting stream replication for slot #{replication_slot_name} using publication #{publication_name} and protocol version #{proto_version}" - ) + case Enum.find(results, &match?(%Postgrex.Error{}, &1)) do + %Postgrex.Error{} = error -> + {:disconnect, "Error creating publication: #{error.message}"} + + nil -> + Logger.info("Create replication slot #{replication_slot_name} using plugin #{output_plugin}") - query = - "START_REPLICATION SLOT #{replication_slot_name} LOGICAL 0/0 (proto_version '#{proto_version}', publication_names '#{publication_name}')" + query = "CREATE_REPLICATION_SLOT #{replication_slot_name} TEMPORARY LOGICAL #{output_plugin} NOEXPORT_SNAPSHOT" - {:stream, query, [], %{state | step: :streaming}} + {:query, query, [timeout: state.query_timeout], %{state | step: :start_replication_slot}} + end + end + + def handle_result(%Postgrex.Error{postgres: %{pg_code: pg_code}}, %__MODULE__{step: :start_replication_slot}) + when pg_code in ~w(53300 53400) do + {:disconnect, :max_wal_senders_reached} + end + + def handle_result(%Postgrex.Error{postgres: %{message: message}}, %__MODULE__{step: :start_replication_slot} = _state) do + {:disconnect, "Error starting replication: #{message}"} + end + + def handle_result(%Postgrex.Error{message: message}, %__MODULE__{step: :start_replication_slot} = _state) do + {:disconnect, "Error starting replication: #{message}"} + end + + def handle_result(results, %__MODULE__{step: :start_replication_slot} = state) do + error = Enum.find(results, fn res -> match?(%Postgrex.Error{}, res) end) + + if error do + {:disconnect, "Error starting replication: #{error.message}"} + else + %__MODULE__{ + proto_version: proto_version, + replication_slot_name: replication_slot_name, + publication_name: publication_name + } = state + + Logger.info( + "#{inspect(self())} Starting stream replication for slot #{replication_slot_name} using publication #{publication_name} and protocol version #{proto_version}" + ) + + query = + "START_REPLICATION SLOT #{replication_slot_name} LOGICAL 0/0 (proto_version '#{proto_version}', publication_names '#{publication_name}', binary 'true')" + + {:stream, query, [], %{state | step: :streaming}} + end end - # %Postgrex.Error{message: nil, postgres: %{code: :configuration_limit_exceeded, line: "291", message: "all replication slots are in use", file: "slot.c", unknown: "ERROR", severity: "ERROR", hint: "Free one or increase max_replication_slots.", routine: "ReplicationSlotCreate", pg_code: "53400"}, connection_id: 217538, query: nil} def handle_result(%Postgrex.Error{postgres: %{pg_code: pg_code}}, _state) when pg_code in ~w(53300 53400) do {:disconnect, :max_wal_senders_reached} end @@ -255,19 +350,14 @@ defmodule Realtime.Tenants.ReplicationConnection do %KeepAlive{reply: reply, wal_end: wal_end} = parse(data) wal_end = wal_end + 1 - message = - case reply do - :now -> standby_status(wal_end, wal_end, wal_end, reply) - :later -> hold() - end + message = standby_status(wal_end, wal_end, wal_end, reply) {:noreply, message, state} end def handle_data(data, state) when is_write(data) do %Write{message: message} = parse(data) - message |> decode_message() |> then(&send(self(), &1)) - {:noreply, [], state} + message |> decode_message(state.relations) |> then(&handle_message(&1, state)) end def handle_data(e, state) do @@ -276,17 +366,33 @@ defmodule Realtime.Tenants.ReplicationConnection do end @impl true - def handle_info(%Decoder.Messages.Begin{commit_timestamp: commit_timestamp}, state) do + def handle_call(:health_check, from, state) do + Postgrex.ReplicationConnection.reply(from, :ok) + {:noreply, state} + end + + @impl true + + def handle_info({:DOWN, _, :process, _, _}, _), do: {:disconnect, :shutdown} + def handle_info(_, state), do: {:noreply, state} + + defp handle_message(%Decoder.Messages.Begin{commit_timestamp: commit_timestamp}, state) do latency_committed_at = NaiveDateTime.utc_now() |> NaiveDateTime.diff(commit_timestamp, :millisecond) {:noreply, %{state | latency_committed_at: latency_committed_at}} end - def handle_info(%Decoder.Messages.Relation{} = msg, state) do + defp handle_message(%Decoder.Messages.Relation{} = msg, state) do %Decoder.Messages.Relation{id: id, namespace: namespace, name: name, columns: columns} = msg - %{relations: relations} = state - relation = %{name: name, columns: columns, namespace: namespace} - relations = Map.put(relations, id, relation) - {:noreply, %{state | relations: relations}} + # Only care about relations with namespace=realtime and name starting with messages + if namespace == @schema and String.starts_with?(name, @table) do + %{relations: relations} = state + relation = %{name: name, columns: columns, namespace: namespace} + relations = Map.put(relations, id, relation) + {:noreply, %{state | relations: relations}} + else + Logger.warning("Unexpected relation on schema '#{namespace}' and table '#{name}'") + {:noreply, state} + end rescue e -> log_error("UnableToBroadcastChanges", e) @@ -297,23 +403,43 @@ defmodule Realtime.Tenants.ReplicationConnection do {:noreply, state} end - def handle_info(%Decoder.Messages.Insert{} = msg, state) do + defp handle_message(%Decoder.Messages.Insert{} = msg, state) do %Decoder.Messages.Insert{relation_id: relation_id, tuple_data: tuple_data} = msg %{relations: relations, tenant_id: tenant_id, latency_committed_at: latency_committed_at} = state with %{columns: columns} <- Map.get(relations, relation_id), to_broadcast = tuple_to_map(tuple_data, columns), - {:ok, payload} <- get_or_error(to_broadcast, "payload", :payload_missing), {:ok, inserted_at} <- get_or_error(to_broadcast, "inserted_at", :inserted_at_missing), {:ok, event} <- get_or_error(to_broadcast, "event", :event_missing), {:ok, id} <- get_or_error(to_broadcast, "id", :id_missing), {:ok, topic} <- get_or_error(to_broadcast, "topic", :topic_missing), {:ok, private} <- get_or_error(to_broadcast, "private", :private_missing), + {:ok, encoding, body} <- pick_payload(to_broadcast), %Tenant{} = tenant <- Cache.get_tenant_by_external_id(tenant_id), - broadcast_message = %{topic: topic, event: event, private: private, payload: Map.put_new(payload, "id", id)}, - :ok <- BatchBroadcast.broadcast(nil, tenant, %{messages: [broadcast_message]}, true) do - inserted_at = NaiveDateTime.from_iso8601!(inserted_at) - latency_inserted_at = NaiveDateTime.utc_now() |> NaiveDateTime.diff(inserted_at) + :ok <- Tenants.validate_payload_size(tenant, body), + events_per_second_rate = Tenants.events_per_second_rate(tenant), + :ok <- check_rate_limit(events_per_second_rate) do + tenant_topic = Tenants.tenant_topic(tenant, topic, not private) + + broadcast = %UserBroadcast{ + topic: tenant_topic, + user_event: event, + user_payload: body, + user_payload_encoding: encoding, + metadata: %{"id" => id} + } + + GenCounter.add(events_per_second_rate.id) + + TenantBroadcaster.pubsub_broadcast( + tenant.external_id, + tenant_topic, + broadcast, + RealtimeChannel.MessageDispatcher, + :broadcast + ) + + latency_inserted_at = NaiveDateTime.utc_now(:microsecond) |> NaiveDateTime.diff(inserted_at, :microsecond) Telemetry.execute( [:realtime, :tenants, :broadcast_from_database], @@ -340,17 +466,14 @@ defmodule Realtime.Tenants.ReplicationConnection do {:noreply, state} end - def handle_info({:DOWN, _, :process, _, _}, _), do: {:disconnect, :shutdown} - def handle_info(_, state), do: {:noreply, state} - + defp handle_message(_, state), do: {:noreply, state} @impl true def handle_disconnect(state) do Logger.warning("Disconnecting broadcast changes handler in the step : #{inspect(state.step)}") {:noreply, %{state | step: :disconnected}} end - @spec supervisor_spec(Tenant.t()) :: term() - def supervisor_spec(%Tenant{external_id: tenant_id}) do + defp supervisor_spec(tenant_id) do {:via, PartitionSupervisor, {__MODULE__.DynamicSupervisor, tenant_id}} end @@ -370,16 +493,26 @@ defmodule Realtime.Tenants.ReplicationConnection do |> Enum.zip(columns) |> Map.new(fn {nil, %{name: name}} -> {name, nil} - {value, %{name: name, type: "jsonb"}} -> {name, Jason.decode!(value)} - {value, %{name: name, type: "bool"}} -> {name, value == "t"} + {value, %{name: name, type: "bool"}} -> {name, value} {value, %{name: name}} -> {name, value} end) end + defp check_rate_limit(events_per_second_rate) do + case RateCounter.get(events_per_second_rate) do + {:ok, %{limit: %{triggered: true}}} -> {:error, :too_many_requests} + _ -> :ok + end + end + defp get_or_error(map, key, error_type) do case Map.get(map, key) do nil -> {:error, error_type} value -> {:ok, value} end end + + defp pick_payload(%{"binary_payload" => bin}) when is_binary(bin), do: {:ok, :binary, bin} + defp pick_payload(%{"payload" => json}) when is_binary(json), do: {:ok, :json, json} + defp pick_payload(_), do: {:error, :payload_missing} end diff --git a/lib/realtime/tenants/replication_connection/watchdog.ex b/lib/realtime/tenants/replication_connection/watchdog.ex new file mode 100644 index 000000000..9a046b856 --- /dev/null +++ b/lib/realtime/tenants/replication_connection/watchdog.ex @@ -0,0 +1,101 @@ +defmodule Realtime.Tenants.ReplicationConnection.Watchdog do + @moduledoc """ + Monitors ReplicationConnection health by performing periodic call checks. + If the call times out, logs an error and terminates the ReplicationConnection process to trigger a restart. + + On each interval it also queries the tenant database for replication slot WAL lag. + If the lag exceeds 50% of max_slot_wal_keep_size, the watchdog stops so the + ReplicationConnection (linked to this process) is stopped. + When max_slot_wal_keep_size = -1 (unlimited) the lag check is skipped entirely, + as there is no per-slot enforcement threshold to reason against. + """ + use GenServer + use Realtime.Logs + alias Realtime.Database + alias Realtime.Tenants.Connect + alias Realtime.Tenants.ReplicationConnection + + @default_check_interval :timer.minutes(5) + @default_timeout :timer.minutes(1) + + defstruct [:parent_pid, :tenant_id, :check_interval, :timeout, :replication_slot_name] + + def start_link(opts), do: GenServer.start_link(__MODULE__, opts) + + @impl true + def init(opts) do + parent_pid = Keyword.fetch!(opts, :parent_pid) + tenant_id = Keyword.fetch!(opts, :tenant_id) + + check_interval = + Keyword.get( + opts, + :watchdog_interval, + Application.get_env(:realtime, :replication_watchdog_interval, @default_check_interval) + ) + + timeout = + Keyword.get( + opts, + :watchdog_timeout, + Application.get_env(:realtime, :replication_watchdog_timeout, @default_timeout) + ) + + replication_slot_name = Keyword.get(opts, :replication_slot_name) + + Logger.metadata(external_id: tenant_id, project: tenant_id) + + Process.send_after(self(), :health_check, check_interval) + + state = %__MODULE__{ + parent_pid: parent_pid, + tenant_id: tenant_id, + check_interval: check_interval, + timeout: timeout, + replication_slot_name: replication_slot_name + } + + {:ok, state} + end + + @impl true + def handle_info(:health_check, state) do + try do + case ReplicationConnection.health_check(state.parent_pid, state.timeout) do + :ok -> + case check_slot_lag(state) do + :ok -> + Process.send_after(self(), :health_check, state.check_interval) + {:noreply, state} + + {:error, :lag_too_high} -> + log_error( + "ReplicationSlotLagTooHigh", + "Replication slot lag exceeds 50% of max_slot_wal_keep_size, shutting down" + ) + + {:stop, :slot_lag_too_high, state} + + {:error, reason} -> + log_warning("ReplicationSlotLagCheckSkipped", "Could not check slot lag: #{inspect(reason)}") + Process.send_after(self(), :health_check, state.check_interval) + {:noreply, state} + end + end + catch + :exit, {:timeout, _} -> + log_error("ReplicationConnectionWatchdogTimeout", "ReplicationConnection is not responding") + + {:stop, :watchdog_timeout, state} + end + end + + defp check_slot_lag(%{replication_slot_name: nil}), do: :ok + + defp check_slot_lag(%{tenant_id: tenant_id, replication_slot_name: slot_name}) do + case Connect.get_status(tenant_id) do + {:ok, conn} -> Database.check_replication_slot_lag(conn, slot_name) + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/realtime/tenants/repo.ex b/lib/realtime/tenants/repo.ex new file mode 100644 index 000000000..18c9c893f --- /dev/null +++ b/lib/realtime/tenants/repo.ex @@ -0,0 +1,253 @@ +defmodule Realtime.Tenants.Repo do + @moduledoc """ + Database operations done against the tenant database + """ + use Realtime.Logs + import Ecto.Query + alias Realtime.Repo.Replica + + @doc """ + Lists all records for a given query and converts them into a given struct + """ + @spec all(DBConnection.conn(), Ecto.Queryable.t(), module(), [Postgrex.execute_option()]) :: + {:ok, list(struct())} | {:error, any()} + def all(conn, query, result_struct, opts \\ []) do + conn + |> run_all_query(query, opts) + |> result_to_structs(result_struct) + end + + @doc """ + Fetches one record for a given query and converts it into a given struct + """ + @spec one( + DBConnection.conn(), + Ecto.Query.t(), + module(), + Postgrex.option() | Keyword.t() + ) :: + {:error, any()} | {:ok, struct()} | Ecto.Changeset.t() + def one(conn, query, result_struct, opts \\ []) do + conn + |> run_all_query(query, opts) + |> result_to_single_struct(result_struct, nil) + end + + @doc """ + Inserts a given changeset into the database and converts the result into a given struct + """ + @spec insert( + DBConnection.conn(), + Ecto.Changeset.t(), + module(), + Postgrex.option() | Keyword.t() + ) :: + {:ok, struct()} | {:error, any()} | Ecto.Changeset.t() + def insert(conn, changeset, result_struct, opts \\ []) do + with {:ok, {query, args}} <- insert_query_from_changeset(changeset) do + conn + |> run_query_with_trap(query, args, opts) + |> result_to_single_struct(result_struct, changeset) + end + end + + @doc """ + Inserts all changesets into the database and converts the result into a given list of structs + """ + @spec insert_all_entries( + DBConnection.conn(), + [Ecto.Changeset.t()], + module(), + Postgrex.option() | Keyword.t() + ) :: + {:ok, [struct()]} | {:error, any()} | Ecto.Changeset.t() + def insert_all_entries(conn, changesets, result_struct, opts \\ []) do + with {:ok, {query, args}} <- insert_all_query_from_changeset(changesets) do + conn + |> run_query_with_trap(query, args, opts) + |> result_to_structs(result_struct) + end + end + + @doc """ + Deletes records for a given query and returns the number of deleted records + """ + @spec del(DBConnection.conn(), Ecto.Queryable.t()) :: + {:ok, non_neg_integer()} | {:error, any()} + def del(conn, query) do + with {:ok, %Postgrex.Result{num_rows: num_rows}} <- run_delete_query(conn, query) do + {:ok, num_rows} + end + end + + @doc """ + Updates an entry based on the changeset and returns the updated entry + """ + @spec update(DBConnection.conn(), Ecto.Changeset.t(), module()) :: + {:ok, struct()} | {:error, any()} | Ecto.Changeset.t() + def update(conn, changeset, result_struct, opts \\ []) do + with {:ok, {query, args}} <- update_query_from_changeset(changeset) do + conn + |> run_query_with_trap(query, args, opts) + |> result_to_single_struct(result_struct, changeset) + end + end + + defp result_to_single_struct( + {:error, %Postgrex.Error{postgres: %{code: :unique_violation, constraint: "channels_name_index"}}}, + _struct, + changeset + ) do + Ecto.Changeset.add_error(changeset, :name, "has already been taken") + end + + defp result_to_single_struct({:error, _} = error, _, _), do: error + + defp result_to_single_struct({:ok, %Postgrex.Result{rows: []}}, _, _) do + {:error, :not_found} + end + + defp result_to_single_struct({:ok, %Postgrex.Result{rows: [row], columns: columns}}, struct, _) do + repo_module = Replica.replica() + {:ok, repo_module.load(struct, Enum.zip(columns, row))} + end + + defp result_to_single_struct({:ok, %Postgrex.Result{num_rows: num_rows}}, _, _) do + raise("expected at most one result but got #{num_rows} in result") + end + + defp result_to_structs({:error, _} = error, _), do: error + + defp result_to_structs({:ok, %Postgrex.Result{rows: rows, columns: columns}}, struct) do + repo_module = Replica.replica() + {:ok, Enum.map(rows, &repo_module.load(struct, Enum.zip(columns, &1)))} + end + + defp insert_query_from_changeset(%{valid?: false} = changeset), do: {:error, changeset} + + defp insert_query_from_changeset(changeset) do + schema = changeset.data.__struct__ + source = schema.__schema__(:source) + prefix = schema.__schema__(:prefix) + acc = %{header: [], rows: []} + + %{header: header, rows: rows} = + Enum.reduce(changeset.changes, acc, fn {field, row}, %{header: header, rows: rows} -> + row = + case row do + row when is_boolean(row) -> row + row when is_atom(row) -> Atom.to_string(row) + _ -> row + end + + %{ + header: [Atom.to_string(field) | header], + rows: [row | rows] + } + end) + + table = "\"#{prefix}\".\"#{source}\"" + header = "(#{Enum.map_join(header, ",", &"\"#{&1}\"")})" + + arg_index = + rows + |> Enum.with_index(1) + |> Enum.map_join(",", fn {_, index} -> "$#{index}" end) + + {:ok, {"INSERT INTO #{table} #{header} VALUES (#{arg_index}) RETURNING *", rows}} + end + + defp insert_all_query_from_changeset(changesets) do + invalid = Enum.filter(changesets, &(!&1.valid?)) + + if invalid != [] do + {:error, changesets} + else + [schema] = changesets |> Enum.map(& &1.data.__struct__) |> Enum.uniq() + + source = schema.__schema__(:source) + prefix = schema.__schema__(:prefix) + changes = Enum.map(changesets, & &1.changes) + + %{header: header, rows: rows} = + Enum.reduce(changes, %{header: [], rows: []}, fn v, changes_acc -> + Enum.reduce(v, changes_acc, fn {field, row}, %{header: header, rows: rows} -> + row = + case row do + row when is_boolean(row) -> row + row when is_atom(row) -> Atom.to_string(row) + _ -> row + end + + %{ + header: Enum.uniq([Atom.to_string(field) | header]), + rows: [row | rows] + } + end) + end) + + args_index = + rows + |> Enum.chunk_every(length(header)) + |> Enum.reduce({"", 1}, fn row, {acc, count} -> + arg_index = + row + |> Enum.with_index(count) + |> Enum.map_join("", fn {_, index} -> "$#{index}," end) + |> String.trim_trailing(",") + |> then(&"(#{&1})") + + {"#{acc},#{arg_index}", count + length(row)} + end) + |> elem(0) + |> String.trim_leading(",") + + table = "\"#{prefix}\".\"#{source}\"" + header = "(#{Enum.map_join(header, ",", &"\"#{&1}\"")})" + {:ok, {"INSERT INTO #{table} #{header} VALUES #{args_index} RETURNING *", rows}} + end + end + + defp update_query_from_changeset(%{valid?: false} = changeset), do: {:error, changeset} + + defp update_query_from_changeset(changeset) do + repo_module = Replica.replica() + %Ecto.Changeset{data: %{id: id, __struct__: struct}, changes: changes} = changeset + changes = Keyword.new(changes) + query = from(c in struct, where: c.id == ^id, select: c, update: [set: ^changes]) + {:ok, repo_module.to_sql(:update_all, query)} + end + + defp run_all_query(conn, query, opts) do + repo_module = Replica.replica() + {query, args} = repo_module.to_sql(:all, query) + run_query_with_trap(conn, query, args, opts) + end + + defp run_delete_query(conn, query) do + repo_module = Replica.replica() + {query, args} = repo_module.to_sql(:delete_all, query) + run_query_with_trap(conn, query, args) + end + + defp run_query_with_trap(conn, query, args, opts \\ []) do + Postgrex.query(conn, query, args, opts) + rescue + e -> + log_error("ErrorRunningQuery", e) + {:error, :postgrex_exception} + catch + :exit, {:noproc, {DBConnection.Holder, :checkout, _}} -> + log_error( + "UnableCheckoutConnection", + "Unable to checkout connection, please check your connection pool configuration" + ) + + {:error, :postgrex_exception} + + :exit, reason -> + log_error("UnknownError", reason) + + {:error, :postgrex_exception} + end +end diff --git a/lib/realtime/tenants/repo/migrations/20211116045059_create_realtime_check_filters_trigger.ex b/lib/realtime/tenants/repo/migrations/20211116045059_create_realtime_check_filters_trigger.ex index e673e5124..d996dce19 100644 --- a/lib/realtime/tenants/repo/migrations/20211116045059_create_realtime_check_filters_trigger.ex +++ b/lib/realtime/tenants/repo/migrations/20211116045059_create_realtime_check_filters_trigger.ex @@ -4,7 +4,7 @@ defmodule Realtime.Tenants.Migrations.CreateRealtimeCheckFiltersTrigger do use Ecto.Migration def change do - execute("create function realtime.subscription_check_filters() + execute("create or replace function realtime.subscription_check_filters() returns trigger language plpgsql as $$ @@ -57,6 +57,8 @@ defmodule Realtime.Tenants.Migrations.CreateRealtimeCheckFiltersTrigger do end; $$;") + execute("drop trigger if exists tr_check_filters on realtime.subscription") + execute("create trigger tr_check_filters before insert or update on realtime.subscription for each row diff --git a/lib/realtime/tenants/repo/migrations/20211116050929_create_realtime_quote_wal2json_function.ex b/lib/realtime/tenants/repo/migrations/20211116050929_create_realtime_quote_wal2json_function.ex index 943490979..888659a00 100644 --- a/lib/realtime/tenants/repo/migrations/20211116050929_create_realtime_quote_wal2json_function.ex +++ b/lib/realtime/tenants/repo/migrations/20211116050929_create_realtime_quote_wal2json_function.ex @@ -4,7 +4,7 @@ defmodule Realtime.Tenants.Migrations.CreateRealtimeQuoteWal2jsonFunction do use Ecto.Migration def change do - execute("create function realtime.quote_wal2json(entity regclass) + execute("create or replace function realtime.quote_wal2json(entity regclass) returns text language sql immutable diff --git a/lib/realtime/tenants/repo/migrations/20211116051442_create_realtime_check_equality_op_function.ex b/lib/realtime/tenants/repo/migrations/20211116051442_create_realtime_check_equality_op_function.ex index 1a7408a9e..3b2f8ac92 100644 --- a/lib/realtime/tenants/repo/migrations/20211116051442_create_realtime_check_equality_op_function.ex +++ b/lib/realtime/tenants/repo/migrations/20211116051442_create_realtime_check_equality_op_function.ex @@ -4,7 +4,7 @@ defmodule Realtime.Tenants.Migrations.CreateRealtimeCheckEqualityOpFunction do use Ecto.Migration def change do - execute("create function realtime.check_equality_op( + execute("create or replace function realtime.check_equality_op( op realtime.equality_op, type_ regtype, val_1 text, diff --git a/lib/realtime/tenants/repo/migrations/20211116212300_create_realtime_build_prepared_statement_sql_function.ex b/lib/realtime/tenants/repo/migrations/20211116212300_create_realtime_build_prepared_statement_sql_function.ex index d5a9a05b7..880f74487 100644 --- a/lib/realtime/tenants/repo/migrations/20211116212300_create_realtime_build_prepared_statement_sql_function.ex +++ b/lib/realtime/tenants/repo/migrations/20211116212300_create_realtime_build_prepared_statement_sql_function.ex @@ -6,8 +6,22 @@ defmodule Realtime.Tenants.Migrations.CreateRealtimeBuildPreparedStatementSqlFun def change do execute(""" DO $$ + DECLARE + type_oid oid; BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'wal_column') THEN + SELECT oid INTO type_oid + FROM pg_type + WHERE typname = 'wal_column' AND typnamespace = 'realtime'::regnamespace; + + -- Drop if it exists without the legacy 'type' column (e.g. pre-initialized by supabase-postgres) + IF type_oid IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM pg_attribute WHERE attrelid = (SELECT typrelid FROM pg_type WHERE oid = type_oid) AND attname = 'type' + ) THEN + DROP TYPE realtime.wal_column CASCADE; + type_oid := NULL; + END IF; + + IF type_oid IS NULL THEN CREATE TYPE realtime.wal_column AS ( name text, type text, @@ -19,7 +33,7 @@ defmodule Realtime.Tenants.Migrations.CreateRealtimeBuildPreparedStatementSqlFun END$$; """) - execute("create function realtime.build_prepared_statement_sql( + execute("create or replace function realtime.build_prepared_statement_sql( prepared_statement_name text, entity regclass, columns realtime.wal_column[] diff --git a/lib/realtime/tenants/repo/migrations/20211116213355_create_realtime_cast_function.ex b/lib/realtime/tenants/repo/migrations/20211116213355_create_realtime_cast_function.ex index 30f36e7bd..c1ab70ee9 100644 --- a/lib/realtime/tenants/repo/migrations/20211116213355_create_realtime_cast_function.ex +++ b/lib/realtime/tenants/repo/migrations/20211116213355_create_realtime_cast_function.ex @@ -4,7 +4,7 @@ defmodule Realtime.Tenants.Migrations.CreateRealtimeCastFunction do use Ecto.Migration def change do - execute("create function realtime.cast(val text, type_ regtype) + execute("create or replace function realtime.cast(val text, type_ regtype) returns jsonb immutable language plpgsql diff --git a/lib/realtime/tenants/repo/migrations/20211116213934_create_realtime_is_visible_through_filters_function.ex b/lib/realtime/tenants/repo/migrations/20211116213934_create_realtime_is_visible_through_filters_function.ex index 119e31f35..522d276c9 100644 --- a/lib/realtime/tenants/repo/migrations/20211116213934_create_realtime_is_visible_through_filters_function.ex +++ b/lib/realtime/tenants/repo/migrations/20211116213934_create_realtime_is_visible_through_filters_function.ex @@ -5,7 +5,7 @@ defmodule Realtime.Tenants.Migrations.CreateRealtimeIsVisibleThroughFiltersFunct def change do execute( - "create function realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[]) + "create or replace function realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[]) returns bool language sql immutable diff --git a/lib/realtime/tenants/repo/migrations/20211116214523_create_realtime_apply_rls_function.ex b/lib/realtime/tenants/repo/migrations/20211116214523_create_realtime_apply_rls_function.ex index 8b29d96f8..f0c2f131a 100644 --- a/lib/realtime/tenants/repo/migrations/20211116214523_create_realtime_apply_rls_function.ex +++ b/lib/realtime/tenants/repo/migrations/20211116214523_create_realtime_apply_rls_function.ex @@ -29,7 +29,7 @@ defmodule Realtime.Tenants.Migrations.CreateRealtimeApplyRlsFunction do END$$; """) - execute("create function realtime.apply_rls(wal jsonb, max_record_bytes int = 1024 * 1024) + execute("create or replace function realtime.apply_rls(wal jsonb, max_record_bytes int = 1024 * 1024) returns realtime.wal_rls language plpgsql volatile diff --git a/lib/realtime/tenants/repo/migrations/20211210212804_enable_generic_subscription_claims.ex b/lib/realtime/tenants/repo/migrations/20211210212804_enable_generic_subscription_claims.ex index a372b387e..c86486aae 100644 --- a/lib/realtime/tenants/repo/migrations/20211210212804_enable_generic_subscription_claims.ex +++ b/lib/realtime/tenants/repo/migrations/20211210212804_enable_generic_subscription_claims.ex @@ -7,13 +7,13 @@ defmodule Realtime.Tenants.Migrations.EnableGenericSubscriptionClaims do execute("truncate table realtime.subscription restart identity") execute("alter table realtime.subscription - drop constraint subscription_entity_user_id_filters_key cascade, - drop column email cascade, - drop column created_at cascade") + drop constraint if exists subscription_entity_user_id_filters_key cascade, + drop column if exists email cascade, + drop column if exists created_at cascade") execute("alter table realtime.subscription rename user_id to subscription_id") - execute("create function realtime.to_regrole(role_name text) + execute("create or replace function realtime.to_regrole(role_name text) returns regrole immutable language sql @@ -87,8 +87,8 @@ defmodule Realtime.Tenants.Migrations.EnableGenericSubscriptionClaims do execute("alter type realtime.wal_rls rename attribute users to subscription_ids cascade;") - execute("drop function realtime.apply_rls(jsonb, integer);") - execute("create function realtime.apply_rls(wal jsonb, max_record_bytes int = 1024 * 1024) + execute("drop function if exists realtime.apply_rls(jsonb, integer);") + execute("create or replace function realtime.apply_rls(wal jsonb, max_record_bytes int = 1024 * 1024) returns setof realtime.wal_rls language plpgsql volatile diff --git a/lib/realtime/tenants/repo/migrations/20240401105812_create_realtime_admin_and_move_ownership.ex b/lib/realtime/tenants/repo/migrations/20240401105812_create_realtime_admin_and_move_ownership.ex index bc0672472..c3ef779eb 100644 --- a/lib/realtime/tenants/repo/migrations/20240401105812_create_realtime_admin_and_move_ownership.ex +++ b/lib/realtime/tenants/repo/migrations/20240401105812_create_realtime_admin_and_move_ownership.ex @@ -30,6 +30,6 @@ defmodule Realtime.Tenants.Migrations.CreateRealtimeAdminAndMoveOwnership do execute("ALTER table realtime.presences OWNER TO supabase_realtime_admin") execute("ALTER function realtime.channel_name() owner to supabase_realtime_admin") - execute("GRANT supabase_realtime_admin TO postgres") + execute("GRANT supabase_realtime_admin TO postgres;") end end diff --git a/lib/realtime/tenants/repo/migrations/20240523004032_redefine_authorization_tables.ex b/lib/realtime/tenants/repo/migrations/20240523004032_redefine_authorization_tables.ex index d4b83c9fd..40be701b3 100644 --- a/lib/realtime/tenants/repo/migrations/20240523004032_redefine_authorization_tables.ex +++ b/lib/realtime/tenants/repo/migrations/20240523004032_redefine_authorization_tables.ex @@ -4,9 +4,9 @@ defmodule Realtime.Tenants.Migrations.RedefineAuthorizationTables do use Ecto.Migration def change do - drop table(:broadcasts), mode: :cascade - drop table(:presences), mode: :cascade - drop table(:channels), mode: :cascade + drop_if_exists table(:broadcasts), mode: :cascade + drop_if_exists table(:presences), mode: :cascade + drop_if_exists table(:channels), mode: :cascade create_if_not_exists table(:messages) do add :topic, :text, null: false @@ -31,7 +31,7 @@ defmodule Realtime.Tenants.Migrations.RedefineAuthorizationTables do execute("ALTER table realtime.messages OWNER to supabase_realtime_admin") execute(""" - DROP function realtime.channel_name + DROP function IF EXISTS realtime.channel_name """) execute(""" diff --git a/lib/realtime/tenants/repo/migrations/20241108114728_messages_using_uuid.ex b/lib/realtime/tenants/repo/migrations/20241108114728_messages_using_uuid.ex index 0accb4700..98ea3ed00 100644 --- a/lib/realtime/tenants/repo/migrations/20241108114728_messages_using_uuid.ex +++ b/lib/realtime/tenants/repo/migrations/20241108114728_messages_using_uuid.ex @@ -10,6 +10,6 @@ defmodule Realtime.Tenants.Migrations.MessagesUsingUuid do end execute("ALTER TABLE realtime.messages ADD PRIMARY KEY (id, inserted_at)") - execute("DROP SEQUENCE realtime.messages_id_seq") + execute("DROP SEQUENCE IF EXISTS realtime.messages_id_seq") end end diff --git a/lib/realtime/tenants/repo/migrations/20250905041441_create_messages_replay_index.ex b/lib/realtime/tenants/repo/migrations/20250905041441_create_messages_replay_index.ex new file mode 100644 index 000000000..77afde6e0 --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20250905041441_create_messages_replay_index.ex @@ -0,0 +1,11 @@ +defmodule Realtime.Tenants.Migrations.CreateMessagesReplayIndex do + @moduledoc false + + use Ecto.Migration + + def change do + create_if_not_exists index(:messages, [{:desc, :inserted_at}, :topic], + where: "extension = 'broadcast' and private IS TRUE" + ) + end +end diff --git a/lib/realtime/tenants/repo/migrations/20251103001201_broadcast_send_include_payload_id.ex b/lib/realtime/tenants/repo/migrations/20251103001201_broadcast_send_include_payload_id.ex new file mode 100644 index 000000000..ba526d9e6 --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20251103001201_broadcast_send_include_payload_id.ex @@ -0,0 +1,41 @@ +defmodule Realtime.Tenants.Migrations.BroadcastSendIncludePayloadId do + @moduledoc false + use Ecto.Migration + + # Include ID in the payload if not defined + def change do + execute(""" + CREATE OR REPLACE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true ) RETURNS void + AS $$ + DECLARE + generated_id uuid; + final_payload jsonb; + BEGIN + BEGIN + -- Generate a new UUID for the id + generated_id := gen_random_uuid(); + + -- Check if payload has an 'id' key, if not, add the generated UUID + IF payload ? 'id' THEN + final_payload := payload; + ELSE + final_payload := jsonb_set(payload, '{id}', to_jsonb(generated_id)); + END IF; + + -- Set the topic configuration + EXECUTE format('SET LOCAL realtime.topic TO %L', topic); + + -- Attempt to insert the message + INSERT INTO realtime.messages (id, payload, event, topic, private, extension) + VALUES (generated_id, final_payload, event, topic, private, 'broadcast'); + EXCEPTION + WHEN OTHERS THEN + -- Capture and notify the error + RAISE WARNING 'ErrorSendingBroadcastMessage: %', SQLERRM; + END; + END; + $$ + LANGUAGE plpgsql; + """) + end +end diff --git a/lib/realtime/tenants/repo/migrations/20251120212548_add_action_to_subscriptions.ex b/lib/realtime/tenants/repo/migrations/20251120212548_add_action_to_subscriptions.ex new file mode 100644 index 000000000..5375e912d --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20251120212548_add_action_to_subscriptions.ex @@ -0,0 +1,33 @@ +defmodule Realtime.Tenants.Migrations.AddActionToSubscriptions do + @moduledoc false + use Ecto.Migration + + def up do + execute(""" + ALTER TABLE realtime.subscription + ADD COLUMN action_filter text DEFAULT '*' CHECK (action_filter IN ('*', 'INSERT', 'UPDATE', 'DELETE')); + """) + + execute(""" + CREATE UNIQUE INDEX subscription_subscription_id_entity_filters_action_filter_key on realtime.subscription (subscription_id, entity, filters, action_filter); + """) + + execute(""" + DROP INDEX IF EXISTS "realtime"."subscription_subscription_id_entity_filters_key"; + """) + end + + def down do + execute(""" + ALTER TABLE realtime.subscription DROP COLUMN IF EXISTS action_filter; + """) + + execute(""" + CREATE UNIQUE INDEX subscription_subscription_id_entity_filters_key on realtime.subscription (subscription_id, entity, filters) + """) + + execute(""" + DROP INDEX IF EXISTS "realtime"."subscription_subscription_id_entity_filters_action_filter_key"; + """) + end +end diff --git a/lib/realtime/tenants/repo/migrations/20251120215549_filter_action_postgres_changes.ex b/lib/realtime/tenants/repo/migrations/20251120215549_filter_action_postgres_changes.ex new file mode 100644 index 000000000..6421acb9a --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20251120215549_filter_action_postgres_changes.ex @@ -0,0 +1,619 @@ +defmodule Realtime.Tenants.Migrations.FilterActionPostgresChanges do + @moduledoc false + use Ecto.Migration + + def up do + execute """ + create or replace function realtime.apply_rls(wal jsonb, max_record_bytes int = 1024 * 1024) + returns setof realtime.wal_rls + language plpgsql + volatile + as $$ + declare + -- Regclass of the table e.g. public.notes + entity_ regclass = (quote_ident(wal ->> 'schema') || '.' || quote_ident(wal ->> 'table'))::regclass; + + -- I, U, D, T: insert, update ... + action realtime.action = ( + case wal ->> 'action' + when 'I' then 'INSERT' + when 'U' then 'UPDATE' + when 'D' then 'DELETE' + else 'ERROR' + end + ); + + -- Is row level security enabled for the table + is_rls_enabled bool = relrowsecurity from pg_class where oid = entity_; + + subscriptions realtime.subscription[] = array_agg(subs) + from + realtime.subscription subs + where + subs.entity = entity_ + -- Filter by action early - only get subscriptions interested in this action + -- action_filter column can be: '*' (all), 'INSERT', 'UPDATE', or 'DELETE' + and (subs.action_filter = '*' or subs.action_filter = action::text); + + -- Subscription vars + roles regrole[] = array_agg(distinct us.claims_role::text) + from + unnest(subscriptions) us; + + working_role regrole; + claimed_role regrole; + claims jsonb; + + subscription_id uuid; + subscription_has_access bool; + visible_to_subscription_ids uuid[] = '{}'; + + -- structured info for wal's columns + columns realtime.wal_column[]; + -- previous identity values for update/delete + old_columns realtime.wal_column[]; + + error_record_exceeds_max_size boolean = octet_length(wal::text) > max_record_bytes; + + -- Primary jsonb output for record + output jsonb; + + begin + perform set_config('role', null, true); + + columns = + array_agg( + ( + x->>'name', + x->>'type', + x->>'typeoid', + realtime.cast( + (x->'value') #>> '{}', + coalesce( + (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4 + (x->>'type')::regtype + ) + ), + (pks ->> 'name') is not null, + true + )::realtime.wal_column + ) + from + jsonb_array_elements(wal -> 'columns') x + left join jsonb_array_elements(wal -> 'pk') pks + on (x ->> 'name') = (pks ->> 'name'); + + old_columns = + array_agg( + ( + x->>'name', + x->>'type', + x->>'typeoid', + realtime.cast( + (x->'value') #>> '{}', + coalesce( + (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4 + (x->>'type')::regtype + ) + ), + (pks ->> 'name') is not null, + true + )::realtime.wal_column + ) + from + jsonb_array_elements(wal -> 'identity') x + left join jsonb_array_elements(wal -> 'pk') pks + on (x ->> 'name') = (pks ->> 'name'); + + for working_role in select * from unnest(roles) loop + + -- Update `is_selectable` for columns and old_columns + columns = + array_agg( + ( + c.name, + c.type_name, + c.type_oid, + c.value, + c.is_pkey, + pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT') + )::realtime.wal_column + ) + from + unnest(columns) c; + + old_columns = + array_agg( + ( + c.name, + c.type_name, + c.type_oid, + c.value, + c.is_pkey, + pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT') + )::realtime.wal_column + ) + from + unnest(old_columns) c; + + if action <> 'DELETE' and count(1) = 0 from unnest(columns) c where c.is_pkey then + return next ( + jsonb_build_object( + 'schema', wal ->> 'schema', + 'table', wal ->> 'table', + 'type', action + ), + is_rls_enabled, + -- subscriptions is already filtered by entity + (select array_agg(s.subscription_id) from unnest(subscriptions) as s where claims_role = working_role), + array['Error 400: Bad Request, no primary key'] + )::realtime.wal_rls; + + -- The claims role does not have SELECT permission to the primary key of entity + elsif action <> 'DELETE' and sum(c.is_selectable::int) <> count(1) from unnest(columns) c where c.is_pkey then + return next ( + jsonb_build_object( + 'schema', wal ->> 'schema', + 'table', wal ->> 'table', + 'type', action + ), + is_rls_enabled, + (select array_agg(s.subscription_id) from unnest(subscriptions) as s where claims_role = working_role), + array['Error 401: Unauthorized'] + )::realtime.wal_rls; + + else + output = jsonb_build_object( + 'schema', wal ->> 'schema', + 'table', wal ->> 'table', + 'type', action, + 'commit_timestamp', to_char( + ((wal ->> 'timestamp')::timestamptz at time zone 'utc'), + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + ), + 'columns', ( + select + jsonb_agg( + jsonb_build_object( + 'name', pa.attname, + 'type', pt.typname + ) + order by pa.attnum asc + ) + from + pg_attribute pa + join pg_type pt + on pa.atttypid = pt.oid + where + attrelid = entity_ + and attnum > 0 + and pg_catalog.has_column_privilege(working_role, entity_, pa.attname, 'SELECT') + ) + ) + -- Add "record" key for insert and update + || case + when action in ('INSERT', 'UPDATE') then + jsonb_build_object( + 'record', + ( + select + jsonb_object_agg( + -- if unchanged toast, get column name and value from old record + coalesce((c).name, (oc).name), + case + when (c).name is null then (oc).value + else (c).value + end + ) + from + unnest(columns) c + full outer join unnest(old_columns) oc + on (c).name = (oc).name + where + coalesce((c).is_selectable, (oc).is_selectable) + and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) + ) + ) + else '{}'::jsonb + end + -- Add "old_record" key for update and delete + || case + when action = 'UPDATE' then + jsonb_build_object( + 'old_record', + ( + select jsonb_object_agg((c).name, (c).value) + from unnest(old_columns) c + where + (c).is_selectable + and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) + ) + ) + when action = 'DELETE' then + jsonb_build_object( + 'old_record', + ( + select jsonb_object_agg((c).name, (c).value) + from unnest(old_columns) c + where + (c).is_selectable + and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) + and ( not is_rls_enabled or (c).is_pkey ) -- if RLS enabled, we can't secure deletes so filter to pkey + ) + ) + else '{}'::jsonb + end; + + -- Create the prepared statement + if is_rls_enabled and action <> 'DELETE' then + if (select 1 from pg_prepared_statements where name = 'walrus_rls_stmt' limit 1) > 0 then + deallocate walrus_rls_stmt; + end if; + execute realtime.build_prepared_statement_sql('walrus_rls_stmt', entity_, columns); + end if; + + visible_to_subscription_ids = '{}'; + + for subscription_id, claims in ( + select + subs.subscription_id, + subs.claims + from + unnest(subscriptions) subs + where + subs.entity = entity_ + and subs.claims_role = working_role + and ( + realtime.is_visible_through_filters(columns, subs.filters) + or ( + action = 'DELETE' + and realtime.is_visible_through_filters(old_columns, subs.filters) + ) + ) + ) loop + + if not is_rls_enabled or action = 'DELETE' then + visible_to_subscription_ids = visible_to_subscription_ids || subscription_id; + else + -- Check if RLS allows the role to see the record + perform + -- Trim leading and trailing quotes from working_role because set_config + -- doesn't recognize the role as valid if they are included + set_config('role', trim(both '"' from working_role::text), true), + set_config('request.jwt.claims', claims::text, true); + + execute 'execute walrus_rls_stmt' into subscription_has_access; + + if subscription_has_access then + visible_to_subscription_ids = visible_to_subscription_ids || subscription_id; + end if; + end if; + end loop; + + perform set_config('role', null, true); + + return next ( + output, + is_rls_enabled, + visible_to_subscription_ids, + case + when error_record_exceeds_max_size then array['Error 413: Payload Too Large'] + else '{}' + end + )::realtime.wal_rls; + + end if; + end loop; + + perform set_config('role', null, true); + end; + $$; + """ + end + + def down do + execute """ + create or replace function realtime.apply_rls(wal jsonb, max_record_bytes int = 1024 * 1024) + returns setof realtime.wal_rls + language plpgsql + volatile + as $$ + declare + -- Regclass of the table e.g. public.notes + entity_ regclass = (quote_ident(wal ->> 'schema') || '.' || quote_ident(wal ->> 'table'))::regclass; + + -- I, U, D, T: insert, update ... + action realtime.action = ( + case wal ->> 'action' + when 'I' then 'INSERT' + when 'U' then 'UPDATE' + when 'D' then 'DELETE' + else 'ERROR' + end + ); + + -- Is row level security enabled for the table + is_rls_enabled bool = relrowsecurity from pg_class where oid = entity_; + + subscriptions realtime.subscription[] = array_agg(subs) + from + realtime.subscription subs + where + subs.entity = entity_; + + -- Subscription vars + roles regrole[] = array_agg(distinct us.claims_role::text) + from + unnest(subscriptions) us; + + working_role regrole; + claimed_role regrole; + claims jsonb; + + subscription_id uuid; + subscription_has_access bool; + visible_to_subscription_ids uuid[] = '{}'; + + -- structured info for wal's columns + columns realtime.wal_column[]; + -- previous identity values for update/delete + old_columns realtime.wal_column[]; + + error_record_exceeds_max_size boolean = octet_length(wal::text) > max_record_bytes; + + -- Primary jsonb output for record + output jsonb; + + begin + perform set_config('role', null, true); + + columns = + array_agg( + ( + x->>'name', + x->>'type', + x->>'typeoid', + realtime.cast( + (x->'value') #>> '{}', + coalesce( + (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4 + (x->>'type')::regtype + ) + ), + (pks ->> 'name') is not null, + true + )::realtime.wal_column + ) + from + jsonb_array_elements(wal -> 'columns') x + left join jsonb_array_elements(wal -> 'pk') pks + on (x ->> 'name') = (pks ->> 'name'); + + old_columns = + array_agg( + ( + x->>'name', + x->>'type', + x->>'typeoid', + realtime.cast( + (x->'value') #>> '{}', + coalesce( + (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4 + (x->>'type')::regtype + ) + ), + (pks ->> 'name') is not null, + true + )::realtime.wal_column + ) + from + jsonb_array_elements(wal -> 'identity') x + left join jsonb_array_elements(wal -> 'pk') pks + on (x ->> 'name') = (pks ->> 'name'); + + for working_role in select * from unnest(roles) loop + + -- Update `is_selectable` for columns and old_columns + columns = + array_agg( + ( + c.name, + c.type_name, + c.type_oid, + c.value, + c.is_pkey, + pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT') + )::realtime.wal_column + ) + from + unnest(columns) c; + + old_columns = + array_agg( + ( + c.name, + c.type_name, + c.type_oid, + c.value, + c.is_pkey, + pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT') + )::realtime.wal_column + ) + from + unnest(old_columns) c; + + if action <> 'DELETE' and count(1) = 0 from unnest(columns) c where c.is_pkey then + return next ( + jsonb_build_object( + 'schema', wal ->> 'schema', + 'table', wal ->> 'table', + 'type', action + ), + is_rls_enabled, + -- subscriptions is already filtered by entity + (select array_agg(s.subscription_id) from unnest(subscriptions) as s where claims_role = working_role), + array['Error 400: Bad Request, no primary key'] + )::realtime.wal_rls; + + -- The claims role does not have SELECT permission to the primary key of entity + elsif action <> 'DELETE' and sum(c.is_selectable::int) <> count(1) from unnest(columns) c where c.is_pkey then + return next ( + jsonb_build_object( + 'schema', wal ->> 'schema', + 'table', wal ->> 'table', + 'type', action + ), + is_rls_enabled, + (select array_agg(s.subscription_id) from unnest(subscriptions) as s where claims_role = working_role), + array['Error 401: Unauthorized'] + )::realtime.wal_rls; + + else + output = jsonb_build_object( + 'schema', wal ->> 'schema', + 'table', wal ->> 'table', + 'type', action, + 'commit_timestamp', to_char( + ((wal ->> 'timestamp')::timestamptz at time zone 'utc'), + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + ), + 'columns', ( + select + jsonb_agg( + jsonb_build_object( + 'name', pa.attname, + 'type', pt.typname + ) + order by pa.attnum asc + ) + from + pg_attribute pa + join pg_type pt + on pa.atttypid = pt.oid + where + attrelid = entity_ + and attnum > 0 + and pg_catalog.has_column_privilege(working_role, entity_, pa.attname, 'SELECT') + ) + ) + -- Add "record" key for insert and update + || case + when action in ('INSERT', 'UPDATE') then + jsonb_build_object( + 'record', + ( + select + jsonb_object_agg( + -- if unchanged toast, get column name and value from old record + coalesce((c).name, (oc).name), + case + when (c).name is null then (oc).value + else (c).value + end + ) + from + unnest(columns) c + full outer join unnest(old_columns) oc + on (c).name = (oc).name + where + coalesce((c).is_selectable, (oc).is_selectable) + and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) + ) + ) + else '{}'::jsonb + end + -- Add "old_record" key for update and delete + || case + when action = 'UPDATE' then + jsonb_build_object( + 'old_record', + ( + select jsonb_object_agg((c).name, (c).value) + from unnest(old_columns) c + where + (c).is_selectable + and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) + ) + ) + when action = 'DELETE' then + jsonb_build_object( + 'old_record', + ( + select jsonb_object_agg((c).name, (c).value) + from unnest(old_columns) c + where + (c).is_selectable + and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) + and ( not is_rls_enabled or (c).is_pkey ) -- if RLS enabled, we can't secure deletes so filter to pkey + ) + ) + else '{}'::jsonb + end; + + -- Create the prepared statement + if is_rls_enabled and action <> 'DELETE' then + if (select 1 from pg_prepared_statements where name = 'walrus_rls_stmt' limit 1) > 0 then + deallocate walrus_rls_stmt; + end if; + execute realtime.build_prepared_statement_sql('walrus_rls_stmt', entity_, columns); + end if; + + visible_to_subscription_ids = '{}'; + + for subscription_id, claims in ( + select + subs.subscription_id, + subs.claims + from + unnest(subscriptions) subs + where + subs.entity = entity_ + and subs.claims_role = working_role + and ( + realtime.is_visible_through_filters(columns, subs.filters) + or ( + action = 'DELETE' + and realtime.is_visible_through_filters(old_columns, subs.filters) + ) + ) + ) loop + + if not is_rls_enabled or action = 'DELETE' then + visible_to_subscription_ids = visible_to_subscription_ids || subscription_id; + else + -- Check if RLS allows the role to see the record + perform + -- Trim leading and trailing quotes from working_role because set_config + -- doesn't recognize the role as valid if they are included + set_config('role', trim(both '"' from working_role::text), true), + set_config('request.jwt.claims', claims::text, true); + + execute 'execute walrus_rls_stmt' into subscription_has_access; + + if subscription_has_access then + visible_to_subscription_ids = visible_to_subscription_ids || subscription_id; + end if; + end if; + end loop; + + perform set_config('role', null, true); + + return next ( + output, + is_rls_enabled, + visible_to_subscription_ids, + case + when error_record_exceeds_max_size then array['Error 413: Payload Too Large'] + else '{}' + end + )::realtime.wal_rls; + + end if; + end loop; + + perform set_config('role', null, true); + end; + $$; + """ + end +end diff --git a/lib/realtime/tenants/repo/migrations/20260218120000_fix_bytea_double_encoding_in_cast.ex b/lib/realtime/tenants/repo/migrations/20260218120000_fix_bytea_double_encoding_in_cast.ex new file mode 100644 index 000000000..701745fdc --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20260218120000_fix_bytea_double_encoding_in_cast.ex @@ -0,0 +1,42 @@ +defmodule Realtime.Tenants.Migrations.FixByteaDoubleEncodingInCast do + @moduledoc false + + use Ecto.Migration + + def up do + execute """ + create or replace function realtime.cast(val text, type_ regtype) + returns jsonb + immutable + language plpgsql + as $$ + declare + res jsonb; + begin + if type_::text = 'bytea' then + return to_jsonb(val); + end if; + execute format('select to_jsonb(%L::'|| type_::text || ')', val) into res; + return res; + end + $$; + """ + end + + def down do + execute """ + create or replace function realtime.cast(val text, type_ regtype) + returns jsonb + immutable + language plpgsql + as $$ + declare + res jsonb; + begin + execute format('select to_jsonb(%L::'|| type_::text || ')', val) into res; + return res; + end + $$; + """ + end +end diff --git a/lib/realtime/tenants/repo/migrations/20260326120000_list_changes_with_slot_count.ex b/lib/realtime/tenants/repo/migrations/20260326120000_list_changes_with_slot_count.ex new file mode 100644 index 000000000..dd7f93d38 --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20260326120000_list_changes_with_slot_count.ex @@ -0,0 +1,88 @@ +defmodule Realtime.Tenants.Migrations.ListChangesWithSlotCount do + @moduledoc false + + use Ecto.Migration + + def change do + execute("DROP FUNCTION IF EXISTS realtime.list_changes(name, name, int, int)") + + execute(""" + CREATE FUNCTION realtime.list_changes(publication name, slot_name name, max_changes int, max_record_bytes int) + RETURNS TABLE( + wal jsonb, + is_rls_enabled boolean, + subscription_ids uuid[], + errors text[], + slot_changes_count bigint + ) + LANGUAGE sql + SET log_min_messages TO 'fatal' + AS $$ + WITH pub AS ( + SELECT + concat_ws( + ',', + CASE WHEN bool_or(pubinsert) THEN 'insert' ELSE NULL END, + CASE WHEN bool_or(pubupdate) THEN 'update' ELSE NULL END, + CASE WHEN bool_or(pubdelete) THEN 'delete' ELSE NULL END + ) AS w2j_actions, + coalesce( + string_agg( + realtime.quote_wal2json(format('%I.%I', schemaname, tablename)::regclass), + ',' + ) filter (WHERE ppt.tablename IS NOT NULL AND ppt.tablename NOT LIKE '% %'), + '' + ) AS w2j_add_tables + FROM pg_publication pp + LEFT JOIN pg_publication_tables ppt ON pp.pubname = ppt.pubname + WHERE pp.pubname = publication + GROUP BY pp.pubname + LIMIT 1 + ), + -- MATERIALIZED ensures pg_logical_slot_get_changes is called exactly once + w2j AS MATERIALIZED ( + SELECT x.*, pub.w2j_add_tables + FROM pub, + pg_logical_slot_get_changes( + slot_name, null, max_changes, + 'include-pk', 'true', + 'include-transaction', 'false', + 'include-timestamp', 'true', + 'include-type-oids', 'true', + 'format-version', '2', + 'actions', pub.w2j_actions, + 'add-tables', pub.w2j_add_tables + ) x + ), + -- Count raw slot entries before apply_rls/subscription filter + slot_count AS ( + SELECT count(*)::bigint AS cnt + FROM w2j + WHERE w2j.w2j_add_tables <> '' + ), + -- Apply RLS and filter as before + rls_filtered AS ( + SELECT xyz.wal, xyz.is_rls_enabled, xyz.subscription_ids, xyz.errors + FROM w2j, + realtime.apply_rls( + wal := w2j.data::jsonb, + max_record_bytes := max_record_bytes + ) xyz(wal, is_rls_enabled, subscription_ids, errors) + WHERE w2j.w2j_add_tables <> '' + AND xyz.subscription_ids[1] IS NOT NULL + ) + -- Real rows with slot count attached + SELECT rf.wal, rf.is_rls_enabled, rf.subscription_ids, rf.errors, sc.cnt + FROM rls_filtered rf, slot_count sc + + UNION ALL + + -- Sentinel row: always returned when no real rows exist so Elixir can + -- always read slot_changes_count. Identified by wal IS NULL. + SELECT null, null, null, null, sc.cnt + FROM slot_count sc + WHERE NOT EXISTS (SELECT 1 FROM rls_filtered) + $$; + """) + end +end diff --git a/lib/realtime/tenants/repo/migrations/20260514120000_add_binary_payload_to_messages.ex b/lib/realtime/tenants/repo/migrations/20260514120000_add_binary_payload_to_messages.ex new file mode 100644 index 000000000..b3457bfc3 --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20260514120000_add_binary_payload_to_messages.ex @@ -0,0 +1,39 @@ +defmodule Realtime.Tenants.Migrations.AddBinaryPayloadToMessages do + @moduledoc false + use Ecto.Migration + + def change do + execute("ALTER TABLE realtime.messages ADD COLUMN IF NOT EXISTS binary_payload bytea") + + execute(""" + ALTER TABLE realtime.messages + ADD CONSTRAINT messages_payload_exclusive + CHECK (payload IS NULL OR binary_payload IS NULL) NOT VALID + """) + + execute(""" + CREATE OR REPLACE FUNCTION realtime.send( + payload bytea, + event text, + topic text, + private boolean DEFAULT true + ) RETURNS void AS $$ + DECLARE + generated_id uuid; + BEGIN + BEGIN + generated_id := gen_random_uuid(); + + EXECUTE format('SET LOCAL realtime.topic TO %L', topic); + + INSERT INTO realtime.messages (id, binary_payload, event, topic, private, extension) + VALUES (generated_id, payload, event, topic, private, 'broadcast'); + EXCEPTION + WHEN OTHERS THEN + RAISE WARNING 'ErrorSendingBroadcastMessage: %', SQLERRM; + END; + END; + $$ LANGUAGE plpgsql; + """) + end +end diff --git a/lib/realtime/tenants/repo/migrations/20260527120000_add_select_columns_to_subscriptions.ex b/lib/realtime/tenants/repo/migrations/20260527120000_add_select_columns_to_subscriptions.ex new file mode 100644 index 000000000..5642bdbd0 --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20260527120000_add_select_columns_to_subscriptions.ex @@ -0,0 +1,476 @@ +defmodule Realtime.Tenants.Migrations.AddSelectColumnsToSubscriptions do + @moduledoc false + use Ecto.Migration + + def up do + execute("TRUNCATE TABLE realtime.subscription;") + + execute(""" + DROP INDEX IF EXISTS + realtime.subscription_subscription_id_entity_filters_action_filter_key; + """) + + execute(""" + ALTER TABLE realtime.subscription + ADD COLUMN IF NOT EXISTS selected_columns text[] DEFAULT null; + """) + + execute(""" + CREATE UNIQUE INDEX IF NOT EXISTS + subscription_subscription_id_entity_filters_action_filter_selected_columns_key + ON realtime.subscription + (subscription_id, entity, filters, action_filter, coalesce(selected_columns, '{}')); + """) + + execute(""" + create or replace function realtime.subscription_check_filters() + returns trigger + language plpgsql + as $$ + declare + col_names text[] = coalesce( + array_agg(c.column_name order by c.ordinal_position), + '{}'::text[] + ) + from + information_schema.columns c + where + format('%I.%I', c.table_schema, c.table_name)::regclass = new.entity + and pg_catalog.has_column_privilege( + (new.claims ->> 'role'), + format('%I.%I', c.table_schema, c.table_name)::regclass, + c.column_name, + 'SELECT' + ); + table_col_names text[] = coalesce( + array_agg(pa.attname), + '{}'::text[] + ) + from + pg_attribute pa + where + pa.attrelid = new.entity + and pa.attnum > 0; + filter realtime.user_defined_filter; + col_type regtype; + in_val jsonb; + selected_col text; + begin + for filter in select * from unnest(new.filters) loop + -- Filtered column is valid + if not filter.column_name = any(col_names) then + raise exception 'invalid column for filter %', filter.column_name; + end if; + + -- Type is sanitized and safe for string interpolation + col_type = ( + select atttypid::regtype + from pg_catalog.pg_attribute + where attrelid = new.entity + and attname = filter.column_name + ); + if col_type is null then + raise exception 'failed to lookup type for column %', filter.column_name; + end if; + if filter.op = 'in'::realtime.equality_op then + in_val = realtime.cast(filter.value, (col_type::text || '[]')::regtype); + if coalesce(jsonb_array_length(in_val), 0) > 100 then + raise exception 'too many values for `in` filter. Maximum 100'; + end if; + else + -- raises an exception if value is not coercable to type + perform realtime.cast(filter.value, col_type); + end if; + end loop; + + -- Validate that selected_columns reference columns the role can SELECT + if new.selected_columns is not null then + for selected_col in select * from unnest(new.selected_columns) loop + if not selected_col = any(col_names) then + raise exception 'invalid column for select %', selected_col; + end if; + end loop; + end if; + + -- Apply consistent order to filters so the unique constraint on + -- (subscription_id, entity, filters) can't be tricked by a different filter order + new.filters = coalesce( + array_agg(f order by f.column_name, f.op, f.value), + '{}' + ) from unnest(new.filters) f; + + -- Normalize selected_columns order so ARRAY['a','b'] and ARRAY['b','a'] are + -- treated as the same subscription group in apply_rls + new.selected_columns = ( + select array_agg(c order by c) + from unnest(new.selected_columns) c + ); + + return new; + end; + $$; + """) + + execute(""" + create or replace function realtime.apply_rls(wal jsonb, max_record_bytes int = 1024 * 1024) + returns setof realtime.wal_rls + language plpgsql + volatile + as $$ + declare + -- Regclass of the table e.g. public.notes + entity_ regclass = (quote_ident(wal ->> 'schema') || '.' || quote_ident(wal ->> 'table'))::regclass; + + -- I, U, D, T: insert, update ... + action realtime.action = ( + case wal ->> 'action' + when 'I' then 'INSERT' + when 'U' then 'UPDATE' + when 'D' then 'DELETE' + else 'ERROR' + end + ); + + -- Is row level security enabled for the table + is_rls_enabled bool = relrowsecurity from pg_class where oid = entity_; + + subscriptions realtime.subscription[] = array_agg(subs) + from + realtime.subscription subs + where + subs.entity = entity_ + -- Filter by action early - only get subscriptions interested in this action + -- action_filter column can be: '*' (all), 'INSERT', 'UPDATE', or 'DELETE' + and (subs.action_filter = '*' or subs.action_filter = action::text); + + -- Subscription vars + working_role regrole; + working_selected_columns text[]; + claimed_role regrole; + claims jsonb; + + subscription_id uuid; + subscription_has_access bool; + visible_to_subscription_ids uuid[] = '{}'; + + -- structured info for wal's columns + columns realtime.wal_column[]; + -- previous identity values for update/delete + old_columns realtime.wal_column[]; + + error_record_exceeds_max_size boolean = octet_length(wal::text) > max_record_bytes; + + -- Primary jsonb output for record + output jsonb; + + -- Loop record for iterating unique roles (outer loop) + role_record record; + -- Loop record for iterating unique selected_columns within a role (inner loop) + cols_record record; + -- Subscription ids visible at the role level (before fanning out by selected_columns) + visible_role_sub_ids uuid[] = '{}'; + + begin + perform set_config('role', null, true); + + columns = + array_agg( + ( + x->>'name', + x->>'type', + x->>'typeoid', + realtime.cast( + (x->'value') #>> '{}', + coalesce( + (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4 + (x->>'type')::regtype + ) + ), + (pks ->> 'name') is not null, + true + )::realtime.wal_column + ) + from + jsonb_array_elements(wal -> 'columns') x + left join jsonb_array_elements(wal -> 'pk') pks + on (x ->> 'name') = (pks ->> 'name'); + + old_columns = + array_agg( + ( + x->>'name', + x->>'type', + x->>'typeoid', + realtime.cast( + (x->'value') #>> '{}', + coalesce( + (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4 + (x->>'type')::regtype + ) + ), + (pks ->> 'name') is not null, + true + )::realtime.wal_column + ) + from + jsonb_array_elements(wal -> 'identity') x + left join jsonb_array_elements(wal -> 'pk') pks + on (x ->> 'name') = (pks ->> 'name'); + + for role_record in + select claims_role + from (select distinct claims_role from unnest(subscriptions)) t + order by claims_role::text + loop + working_role := role_record.claims_role; + + -- Update `is_selectable` for columns and old_columns (once per role) + columns = + array_agg( + ( + c.name, + c.type_name, + c.type_oid, + c.value, + c.is_pkey, + pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT') + )::realtime.wal_column + ) + from + unnest(columns) c; + + old_columns = + array_agg( + ( + c.name, + c.type_name, + c.type_oid, + c.value, + c.is_pkey, + pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT') + )::realtime.wal_column + ) + from + unnest(old_columns) c; + + if action <> 'DELETE' and count(1) = 0 from unnest(columns) c where c.is_pkey then + -- Fan out 400 error per distinct selected_columns for this role + for cols_record in + select selected_columns + from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t + order by coalesce(array_to_string(selected_columns, ','), '') + loop + working_selected_columns := cols_record.selected_columns; + return next ( + jsonb_build_object( + 'schema', wal ->> 'schema', + 'table', wal ->> 'table', + 'type', action + ), + is_rls_enabled, + (select array_agg(s.subscription_id) from unnest(subscriptions) as s where s.claims_role = working_role and (s.selected_columns is not distinct from working_selected_columns)), + array['Error 400: Bad Request, no primary key'] + )::realtime.wal_rls; + end loop; + + -- The claims role does not have SELECT permission to the primary key of entity + elsif action <> 'DELETE' and sum(c.is_selectable::int) <> count(1) from unnest(columns) c where c.is_pkey then + -- Fan out 401 error per distinct selected_columns for this role + for cols_record in + select selected_columns + from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t + order by coalesce(array_to_string(selected_columns, ','), '') + loop + working_selected_columns := cols_record.selected_columns; + return next ( + jsonb_build_object( + 'schema', wal ->> 'schema', + 'table', wal ->> 'table', + 'type', action + ), + is_rls_enabled, + (select array_agg(s.subscription_id) from unnest(subscriptions) as s where s.claims_role = working_role and (s.selected_columns is not distinct from working_selected_columns)), + array['Error 401: Unauthorized'] + )::realtime.wal_rls; + end loop; + + else + -- Create the prepared statement (once per role) + if is_rls_enabled and action <> 'DELETE' then + if (select 1 from pg_prepared_statements where name = 'walrus_rls_stmt' limit 1) > 0 then + deallocate walrus_rls_stmt; + end if; + execute realtime.build_prepared_statement_sql('walrus_rls_stmt', entity_, columns); + end if; + + -- Collect all visible subscription IDs for this role (filter check + RLS check) + visible_role_sub_ids = '{}'; + + for subscription_id, claims in ( + select + subs.subscription_id, + subs.claims + from + unnest(subscriptions) subs + where + subs.entity = entity_ + and subs.claims_role = working_role + and ( + realtime.is_visible_through_filters(columns, subs.filters) + or ( + action = 'DELETE' + and realtime.is_visible_through_filters(old_columns, subs.filters) + ) + ) + ) loop + + if not is_rls_enabled or action = 'DELETE' then + visible_role_sub_ids = visible_role_sub_ids || subscription_id; + else + -- Check if RLS allows the role to see the record + perform + -- Trim leading and trailing quotes from working_role because set_config + -- doesn't recognize the role as valid if they are included + set_config('role', trim(both '"' from working_role::text), true), + set_config('request.jwt.claims', claims::text, true); + + execute 'execute walrus_rls_stmt' into subscription_has_access; + + if subscription_has_access then + visible_role_sub_ids = visible_role_sub_ids || subscription_id; + end if; + end if; + end loop; + + perform set_config('role', null, true); + + -- Inner loop: per distinct selected_columns for this role + for cols_record in + select selected_columns + from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t + order by coalesce(array_to_string(selected_columns, ','), '') + loop + working_selected_columns := cols_record.selected_columns; + + output = jsonb_build_object( + 'schema', wal ->> 'schema', + 'table', wal ->> 'table', + 'type', action, + 'commit_timestamp', to_char( + ((wal ->> 'timestamp')::timestamptz at time zone 'utc'), + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + ), + 'columns', ( + select + jsonb_agg( + jsonb_build_object( + 'name', pa.attname, + 'type', pt.typname + ) + order by pa.attnum asc + ) + from + pg_attribute pa + join pg_type pt + on pa.atttypid = pt.oid + left join ( + select unnest(conkey) as pkey_attnum + from pg_constraint + where conrelid = entity_ and contype = 'p' + ) pk on pk.pkey_attnum = pa.attnum + where + attrelid = entity_ + and attnum > 0 + and pg_catalog.has_column_privilege(working_role, entity_, pa.attname, 'SELECT') + and (working_selected_columns is null or pa.attname = any(working_selected_columns) or pk.pkey_attnum is not null) + ) + ) + -- Add "record" key for insert and update + || case + when action in ('INSERT', 'UPDATE') then + jsonb_build_object( + 'record', + ( + select + jsonb_object_agg( + -- if unchanged toast, get column name and value from old record + coalesce((c).name, (oc).name), + case + when (c).name is null then (oc).value + else (c).value + end + ) + from + unnest(columns) c + full outer join unnest(old_columns) oc + on (c).name = (oc).name + where + coalesce((c).is_selectable, (oc).is_selectable) + and (working_selected_columns is null or coalesce((c).name, (oc).name) = any(working_selected_columns) or coalesce((c).is_pkey, (oc).is_pkey)) + and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) + ) + ) + else '{}'::jsonb + end + -- Add "old_record" key for update and delete + || case + when action = 'UPDATE' then + jsonb_build_object( + 'old_record', + ( + select jsonb_object_agg((c).name, (c).value) + from unnest(old_columns) c + where + (c).is_selectable + and (working_selected_columns is null or (c).name = any(working_selected_columns) or (c).is_pkey) + and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) + ) + ) + when action = 'DELETE' then + jsonb_build_object( + 'old_record', + ( + select jsonb_object_agg((c).name, (c).value) + from unnest(old_columns) c + where + (c).is_selectable + and (working_selected_columns is null or (c).name = any(working_selected_columns) or (c).is_pkey) + and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) + and ( not is_rls_enabled or (c).is_pkey ) -- if RLS enabled, we can't secure deletes so filter to pkey + ) + ) + else '{}'::jsonb + end; + + -- Filter visible_role_sub_ids to those matching the current selected_columns group + visible_to_subscription_ids = coalesce( + ( + select array_agg(s.subscription_id) + from unnest(subscriptions) s + where s.claims_role = working_role + and (s.selected_columns is not distinct from working_selected_columns) + and s.subscription_id = any(visible_role_sub_ids) + ), + '{}'::uuid[] + ); + + return next ( + output, + is_rls_enabled, + visible_to_subscription_ids, + case + when error_record_exceeds_max_size then array['Error 413: Payload Too Large'] + else '{}' + end + )::realtime.wal_rls; + end loop; + + end if; + end loop; + + perform set_config('role', null, true); + end; + $$; + """) + end +end diff --git a/lib/realtime/tenants/repo/migrations/20260528120000_wal2json_escape_special_chars.ex b/lib/realtime/tenants/repo/migrations/20260528120000_wal2json_escape_special_chars.ex new file mode 100644 index 000000000..72f2a2b86 --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20260528120000_wal2json_escape_special_chars.ex @@ -0,0 +1,110 @@ +defmodule Realtime.Tenants.Migrations.Wal2jsonEscapeSpecialChars do + @moduledoc false + + use Ecto.Migration + + def change do + execute(~S""" + CREATE OR REPLACE FUNCTION realtime.wal2json_escape_identifier(name text) + RETURNS text + LANGUAGE sql + IMMUTABLE STRICT + AS $$ + -- Prefix `\`, `,`, `.`, and any whitespace with `\` + SELECT regexp_replace(name, '([\\,.[:space:]])', '\\\1', 'g') + $$; + """) + + execute(~S""" + CREATE OR REPLACE FUNCTION realtime.quote_wal2json(entity regclass) + RETURNS text + LANGUAGE sql + IMMUTABLE STRICT + AS $$ + SELECT + realtime.wal2json_escape_identifier(nsp.nspname::text) + || '.' + || realtime.wal2json_escape_identifier(pc.relname::text) + FROM pg_class pc + JOIN pg_namespace nsp ON pc.relnamespace = nsp.oid + WHERE pc.oid = entity + $$; + """) + + execute("DROP FUNCTION IF EXISTS realtime.list_changes(name, name, int, int)") + + execute(""" + CREATE FUNCTION realtime.list_changes(publication name, slot_name name, max_changes int, max_record_bytes int) + RETURNS TABLE( + wal jsonb, + is_rls_enabled boolean, + subscription_ids uuid[], + errors text[], + slot_changes_count bigint + ) + LANGUAGE sql + SET log_min_messages TO 'fatal' + AS $$ + WITH pub AS ( + SELECT + concat_ws( + ',', + CASE WHEN bool_or(pubinsert) THEN 'insert' ELSE NULL END, + CASE WHEN bool_or(pubupdate) THEN 'update' ELSE NULL END, + CASE WHEN bool_or(pubdelete) THEN 'delete' ELSE NULL END + ) AS w2j_actions, + coalesce( + string_agg( + realtime.quote_wal2json(format('%I.%I', schemaname, tablename)::regclass), + ',' + ) filter (WHERE ppt.tablename IS NOT NULL), + '' + ) AS w2j_add_tables + FROM pg_publication pp + LEFT JOIN pg_publication_tables ppt ON pp.pubname = ppt.pubname + WHERE pp.pubname = publication + GROUP BY pp.pubname + LIMIT 1 + ), + -- MATERIALIZED ensures pg_logical_slot_get_changes is called exactly once + w2j AS MATERIALIZED ( + SELECT x.*, pub.w2j_add_tables + FROM pub, + pg_logical_slot_get_changes( + slot_name, null, max_changes, + 'include-pk', 'true', + 'include-transaction', 'false', + 'include-timestamp', 'true', + 'include-type-oids', 'true', + 'format-version', '2', + 'actions', pub.w2j_actions, + 'add-tables', pub.w2j_add_tables + ) x + ), + slot_count AS ( + SELECT count(*)::bigint AS cnt + FROM w2j + WHERE w2j.w2j_add_tables <> '' + ), + rls_filtered AS ( + SELECT xyz.wal, xyz.is_rls_enabled, xyz.subscription_ids, xyz.errors + FROM w2j, + realtime.apply_rls( + wal := w2j.data::jsonb, + max_record_bytes := max_record_bytes + ) xyz(wal, is_rls_enabled, subscription_ids, errors) + WHERE w2j.w2j_add_tables <> '' + AND xyz.subscription_ids[1] IS NOT NULL + ) + SELECT rf.wal, rf.is_rls_enabled, rf.subscription_ids, rf.errors, sc.cnt + FROM rls_filtered rf, slot_count sc + + UNION ALL + + SELECT null, null, null, null, sc.cnt + FROM slot_count sc + WHERE NOT EXISTS (SELECT 1 FROM rls_filtered) + $$; + """) + end +end diff --git a/lib/realtime/tenants/repo/migrations/20260603120000_add_send_binary_function.ex b/lib/realtime/tenants/repo/migrations/20260603120000_add_send_binary_function.ex new file mode 100644 index 000000000..bff3308e4 --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20260603120000_add_send_binary_function.ex @@ -0,0 +1,33 @@ +defmodule Realtime.Tenants.Migrations.AddSendBinaryFunction do + @moduledoc false + use Ecto.Migration + + def change do + execute("DROP FUNCTION IF EXISTS realtime.send(bytea, text, text, boolean)") + + execute(""" + CREATE OR REPLACE FUNCTION realtime.send_binary( + payload bytea, + event text, + topic text, + private boolean DEFAULT true + ) RETURNS void AS $$ + DECLARE + generated_id uuid; + BEGIN + BEGIN + generated_id := gen_random_uuid(); + + EXECUTE format('SET LOCAL realtime.topic TO %L', topic); + + INSERT INTO realtime.messages (id, binary_payload, event, topic, private, extension) + VALUES (generated_id, payload, event, topic, private, 'broadcast'); + EXCEPTION + WHEN OTHERS THEN + RAISE WARNING 'ErrorSendingBroadcastMessage: %', SQLERRM; + END; + END; + $$ LANGUAGE plpgsql; + """) + end +end diff --git a/lib/realtime/tenants/repo/migrations/20260605120000_rename_broadcast_send_warning.ex b/lib/realtime/tenants/repo/migrations/20260605120000_rename_broadcast_send_warning.ex new file mode 100644 index 000000000..314024a0d --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20260605120000_rename_broadcast_send_warning.ex @@ -0,0 +1,62 @@ +defmodule Realtime.Tenants.Migrations.RenameBroadcastSendWarning do + @moduledoc false + use Ecto.Migration + + def change do + execute(""" + CREATE OR REPLACE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true ) RETURNS void + AS $$ + DECLARE + generated_id uuid; + final_payload jsonb; + BEGIN + BEGIN + generated_id := gen_random_uuid(); + + -- Check if payload has an 'id' key, if not, add the generated UUID + IF payload ? 'id' THEN + final_payload := payload; + ELSE + final_payload := jsonb_set(payload, '{id}', to_jsonb(generated_id)); + END IF; + + -- Set the topic configuration + EXECUTE format('SET LOCAL realtime.topic TO %L', topic); + + INSERT INTO realtime.messages (id, payload, event, topic, private, extension) + VALUES (generated_id, final_payload, event, topic, private, 'broadcast'); + EXCEPTION + WHEN OTHERS THEN + RAISE WARNING 'WarnSendingBroadcastMessage: %', SQLERRM; + END; + END; + $$ + LANGUAGE plpgsql; + """) + + execute(""" + CREATE OR REPLACE FUNCTION realtime.send_binary( + payload bytea, + event text, + topic text, + private boolean DEFAULT true + ) RETURNS void AS $$ + DECLARE + generated_id uuid; + BEGIN + BEGIN + generated_id := gen_random_uuid(); + + EXECUTE format('SET LOCAL realtime.topic TO %L', topic); + + INSERT INTO realtime.messages (id, binary_payload, event, topic, private, extension) + VALUES (generated_id, payload, event, topic, private, 'broadcast'); + EXCEPTION + WHEN OTHERS THEN + RAISE WARNING 'WarnSendingBroadcastMessage: %', SQLERRM; + END; + END; + $$ LANGUAGE plpgsql; + """) + end +end diff --git a/lib/realtime/tenants/repo/migrations/20260606110000_subscription_check_filters_use_pg_attribute.ex b/lib/realtime/tenants/repo/migrations/20260606110000_subscription_check_filters_use_pg_attribute.ex new file mode 100644 index 000000000..4b80aeccf --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20260606110000_subscription_check_filters_use_pg_attribute.ex @@ -0,0 +1,82 @@ +defmodule Realtime.Tenants.Migrations.SubscriptionCheckFiltersUsePgAttribute do + @moduledoc false + + use Ecto.Migration + + def change do + execute(""" + create or replace function realtime.subscription_check_filters() + returns trigger + language plpgsql + as $$ + declare + col_names text[] = coalesce( + array_agg(a.attname order by a.attnum), + '{}'::text[] + ) + from + pg_catalog.pg_attribute a + where + a.attrelid = new.entity + and a.attnum > 0 + and not a.attisdropped + and pg_catalog.has_column_privilege( + (new.claims ->> 'role'), + a.attrelid, + a.attnum, + 'SELECT' + ); + filter realtime.user_defined_filter; + col_type regtype; + in_val jsonb; + selected_col text; + begin + for filter in select * from unnest(new.filters) loop + if not filter.column_name = any(col_names) then + raise exception 'invalid column for filter %', filter.column_name; + end if; + + col_type = ( + select atttypid::regtype + from pg_catalog.pg_attribute + where attrelid = new.entity + and attname = filter.column_name + ); + if col_type is null then + raise exception 'failed to lookup type for column %', filter.column_name; + end if; + + if filter.op = 'in'::realtime.equality_op then + in_val = realtime.cast(filter.value, (col_type::text || '[]')::regtype); + if coalesce(jsonb_array_length(in_val), 0) > 100 then + raise exception 'too many values for `in` filter. Maximum 100'; + end if; + else + perform realtime.cast(filter.value, col_type); + end if; + end loop; + + if new.selected_columns is not null then + for selected_col in select * from unnest(new.selected_columns) loop + if not selected_col = any(col_names) then + raise exception 'invalid column for select %', selected_col; + end if; + end loop; + end if; + + new.filters = coalesce( + array_agg(f order by f.column_name, f.op, f.value), + '{}' + ) from unnest(new.filters) f; + + new.selected_columns = ( + select array_agg(c order by c) + from unnest(new.selected_columns) c + ); + + return new; + end; + $$; + """) + end +end diff --git a/lib/realtime/tenants/repo/migrations/20260606120000_setup_supabase_realtime_admin.ex b/lib/realtime/tenants/repo/migrations/20260606120000_setup_supabase_realtime_admin.ex new file mode 100644 index 000000000..a62b3caa2 --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20260606120000_setup_supabase_realtime_admin.ex @@ -0,0 +1,79 @@ +defmodule Realtime.Tenants.Migrations.SetupSupabaseRealtimeAdmin do + @moduledoc false + + use Ecto.Migration + + def change do + execute(""" + DO $$ + BEGIN + ALTER ROLE supabase_realtime_admin WITH NOINHERIT CREATEROLE LOGIN REPLICATION; + ALTER ROLE supabase_realtime_admin SET search_path = public, extensions, realtime; + GRANT CREATE ON DATABASE postgres TO supabase_realtime_admin; + IF current_setting('server_version_num')::int >= 150000 THEN + EXECUTE 'GRANT SET ON PARAMETER log_min_messages TO supabase_realtime_admin'; + END IF; + GRANT anon, authenticated, service_role TO supabase_realtime_admin; + GRANT CREATE, USAGE ON SCHEMA public TO supabase_realtime_admin; + GRANT USAGE ON SCHEMA extensions TO supabase_realtime_admin; + GRANT USAGE ON SCHEMA auth TO supabase_realtime_admin; + GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA auth TO supabase_realtime_admin; + GRANT USAGE ON SCHEMA realtime TO postgres, anon, authenticated, service_role; + GRANT ALL ON SCHEMA realtime TO supabase_realtime_admin WITH GRANT OPTION; + END $$; + """) + + execute("ALTER TABLE realtime.messages OWNER TO supabase_realtime_admin") + execute("ALTER TABLE realtime.subscription OWNER TO supabase_realtime_admin") + execute("ALTER TYPE realtime.action OWNER TO supabase_realtime_admin") + execute("ALTER TYPE realtime.equality_op OWNER TO supabase_realtime_admin") + execute("ALTER TYPE realtime.user_defined_filter OWNER TO supabase_realtime_admin") + execute("ALTER TYPE realtime.wal_column OWNER TO supabase_realtime_admin") + execute("ALTER TYPE realtime.wal_rls OWNER TO supabase_realtime_admin") + execute("ALTER FUNCTION realtime.apply_rls(jsonb, integer) OWNER TO supabase_realtime_admin") + + execute( + "ALTER FUNCTION realtime.broadcast_changes(text, text, text, text, text, record, record, text) OWNER TO supabase_realtime_admin" + ) + + execute( + "ALTER FUNCTION realtime.build_prepared_statement_sql(text, regclass, realtime.wal_column[]) OWNER TO supabase_realtime_admin" + ) + + execute("ALTER FUNCTION realtime.cast(text, regtype) OWNER TO supabase_realtime_admin") + + execute( + "ALTER FUNCTION realtime.check_equality_op(realtime.equality_op, regtype, text, text) OWNER TO supabase_realtime_admin" + ) + + execute( + "ALTER FUNCTION realtime.is_visible_through_filters(realtime.wal_column[], realtime.user_defined_filter[]) OWNER TO supabase_realtime_admin" + ) + + execute("ALTER FUNCTION realtime.list_changes(name, name, integer, integer) OWNER TO supabase_realtime_admin") + execute("ALTER FUNCTION realtime.quote_wal2json(regclass) OWNER TO supabase_realtime_admin") + execute("ALTER FUNCTION realtime.send(jsonb, text, text, boolean) OWNER TO supabase_realtime_admin") + execute("ALTER FUNCTION realtime.send_binary(bytea, text, text, boolean) OWNER TO supabase_realtime_admin") + execute("ALTER FUNCTION realtime.subscription_check_filters() OWNER TO supabase_realtime_admin") + execute("ALTER FUNCTION realtime.to_regrole(text) OWNER TO supabase_realtime_admin") + execute("ALTER FUNCTION realtime.topic() OWNER TO supabase_realtime_admin") + execute("ALTER FUNCTION realtime.wal2json_escape_identifier(text) OWNER TO supabase_realtime_admin") + + # Revoke supabase_realtime_admin from postgres when supautils.policy_grants includes realtime.subscription (supabase/postgres 15.14.1.018 or higher), + # otherwise keep the membership so postgres can manage policies via inheritance. + execute(""" + DO $$ + BEGIN + IF current_setting('supautils.policy_grants', true) LIKE '%realtime.subscription%' THEN + REVOKE supabase_realtime_admin FROM postgres; + ELSE + GRANT supabase_realtime_admin TO postgres; + END IF; + END $$; + """) + + execute("REVOKE CREATE ON SCHEMA realtime FROM postgres") + execute("REVOKE ALL ON realtime.schema_migrations FROM anon, authenticated, service_role, postgres") + execute("GRANT USAGE ON SCHEMA realtime TO postgres WITH GRANT OPTION") + end +end diff --git a/lib/realtime/tenants/single_broadcast.ex b/lib/realtime/tenants/single_broadcast.ex new file mode 100644 index 000000000..279def0fc --- /dev/null +++ b/lib/realtime/tenants/single_broadcast.ex @@ -0,0 +1,267 @@ +defmodule Realtime.Tenants.SingleBroadcast do + @moduledoc """ + Handles single broadcast messages via the /api/broadcast/:topic/events/:event API. + + Unlike the batch API, this API: + - Takes topic and event from URL path + - Sends single message (no batching) + + This module supports both JSON and binary payloads via Content-Type header: + - application/json + - application/octet-stream + """ + use Ecto.Schema + use Realtime.Logs + import Ecto.Changeset + + alias Realtime.Api.Tenant + alias Realtime.GenCounter + alias Realtime.RateCounter + alias Realtime.Tenants + alias Realtime.Tenants.Authorization + alias Realtime.Tenants.Authorization.Policies + alias Realtime.Tenants.Authorization.Policies.BroadcastPolicies + alias Realtime.Tenants.Connect + + alias RealtimeWeb.RealtimeChannel + alias RealtimeWeb.Socket.UserBroadcast + alias RealtimeWeb.TenantBroadcaster + + @primary_key false + embedded_schema do + field :topic, :string + field :event, :string + # map for JSON, binary for binary + field :payload, :any, virtual: true + field :private, :boolean, default: false + # "json" or "binary" + field :content_type, :string + end + + @type content_type :: :json | :binary + + @doc """ + Broadcasts a single message to the specified topic. + + ## Parameters + - `auth_params` - `%Realtime.Tenants.Authorization{}` struct + - `tenant` - Tenant struct + - `topic` - Channel topic from URL (e.g., "room:123") + - `event` - Event name from URL (e.g., "message") + - `private` - Whether this is a private broadcast (requires authorization) + - `payload` - Message payload (map for JSON, binary for binary) + - `content_type` - :json or :binary + """ + @spec broadcast( + Authorization.t(), + Tenant.t(), + String.t(), + String.t(), + boolean(), + any(), + content_type() + ) :: :ok | {:error, term()} | {:error, atom(), String.t()} + def broadcast(_auth_params, %Tenant{suspend: true}, _topic, _event, _private, _payload, _content_type) do + {:error, :forbidden, "Tenant is suspended"} + end + + def broadcast(auth_params, %Tenant{} = tenant, topic, event, private, payload, content_type) do + with %Ecto.Changeset{valid?: true} <- validate_message(topic, event, private, payload, content_type, tenant), + events_per_second_rate = Tenants.events_per_second_rate(tenant), + :ok <- check_rate_limit(events_per_second_rate, tenant) do + if private do + handle_private_message(tenant, auth_params, topic, event, payload, content_type, events_per_second_rate) + else + send_message_and_count(tenant, events_per_second_rate, topic, event, payload, content_type, _public? = true) + :ok + end + else + %Ecto.Changeset{valid?: false} = changeset -> {:error, changeset} + error -> error + end + end + + defp validate_message(topic, event, private, payload, content_type, tenant) do + %__MODULE__{} + |> cast(%{topic: topic, event: event, private: private, content_type: to_string(content_type)}, [ + :topic, + :event, + :private, + :content_type + ]) + |> put_change(:payload, payload) + |> validate_required([:topic, :event, :content_type]) + |> validate_payload_present(content_type, payload) + |> validate_inclusion(:content_type, ["json", "binary"]) + |> validate_payload_size(tenant, content_type) + end + + defp validate_payload_present(changeset, content_type, payload) do + case {content_type, payload} do + # Binary payloads: <<>> is valid, nil is not + {:binary, payload} when is_binary(payload) -> + changeset + + {:binary, nil} -> + add_error(changeset, :payload, "can't be blank") + + # JSON payloads: any value (including nil map) is acceptable if present + {:json, nil} -> + add_error(changeset, :payload, "can't be blank") + + {:json, _} -> + changeset + + _ -> + changeset + end + end + + defp validate_payload_size(changeset, tenant, content_type) do + payload = get_change(changeset, :payload) + + if is_nil(payload) do + changeset + else + case content_type do + :json -> + case Tenants.validate_payload_size(tenant, payload) do + :ok -> changeset + _ -> add_error(changeset, :payload, "Payload size exceeds tenant limit") + end + + :binary when is_binary(payload) -> + # For binary, we check the actual byte size plus overhead + # to match the behavior of validate_payload_size which uses erlang.external_size + # Binary external size is byte_size + some overhead for the term encoding + max_payload_size = tenant.max_payload_size_in_kb * 1000 + 500 + payload_size = :erlang.external_size(payload) + + if payload_size > max_payload_size do + add_error(changeset, :payload, "Payload size exceeds tenant limit") + else + changeset + end + + :binary -> + # Not a binary, will fail validation + changeset + end + end + end + + defp handle_private_message(tenant, auth_params, topic, event, payload, content_type, rate_counter) do + case permissions_for_message(tenant, auth_params, topic) do + {:ok, %Policies{broadcast: %BroadcastPolicies{write: true}}} -> + send_message_and_count(tenant, rate_counter, topic, event, payload, content_type, false) + :ok + + {:ok, %Policies{broadcast: %BroadcastPolicies{write: _}}} -> + {:error, :forbidden, "Unauthorized"} + + {:error, :rls_policy_error, error} -> + log_error("RlsPolicyError", error) + {:error, :unprocessable_entity, "RLS policy error"} + + {:error, :query_canceled, error} -> + log_error("QueryCanceled", error) + {:error, :unprocessable_entity, "Query canceled"} + + {:error, :rpc_error, error} -> + log_error("RpcError", error) + {:error, :internal_server_error, "RPC error"} + + {:error, :missing_partition} -> + log_error("MissingPartition", "Realtime was unable to find the expected messages partition") + {:error, :unprocessable_entity, "Missing messages partition"} + + {:error, :increase_connection_pool} -> + {:error, :too_many_requests, "Connection pool exhausted"} + + {:error, :tenant_database_unavailable} -> + log_error("UnableToConnectToProject", "Realtime was unable to connect to the project database") + {:error, :unprocessable_entity, "Tenant database unavailable"} + + {:error, :initializing} -> + {:error, :unprocessable_entity, "Tenant database initializing"} + + {:error, :tenant_database_connection_initializing} -> + {:error, :unprocessable_entity, "Tenant database connection initializing"} + + {:error, :tenant_db_too_many_connections} -> + log_error("DatabaseLackOfConnections", "Database can't accept more connections, Realtime won't connect") + {:error, :unprocessable_entity, "Tenant database has too many connections"} + + {:error, :connect_rate_limit_reached} -> + {:error, :unprocessable_entity, "Connect rate limit reached"} + + {:error, error} -> + log_error("UnableToSetPolicies", error) + {:error, :internal_server_error, "Unable to authorize broadcast"} + end + end + + defp permissions_for_message(tenant, auth_params, topic) do + with {:ok, db_conn} <- Connect.lookup_or_start_connection(tenant.external_id) do + auth_params = %{auth_params | topic: topic} + Authorization.get_write_authorizations(db_conn, auth_params) + end + end + + defp check_rate_limit(events_per_second_rate, %Tenant{} = tenant) do + %{max_events_per_second: max_events_per_second} = tenant + {:ok, %{avg: events_per_second}} = RateCounter.get(events_per_second_rate) + + if events_per_second >= max_events_per_second do + {:error, :too_many_requests, "You have exceeded your rate limit"} + else + :ok + end + end + + defp send_message_and_count(tenant, events_per_second_rate, topic, event, payload, content_type, public?) do + tenant_topic = Tenants.tenant_topic(tenant, topic, public?) + + broadcast = + case content_type do + :json -> + build_json_broadcast(tenant_topic, event, payload) + + :binary -> + build_binary_broadcast(tenant_topic, event, payload) + end + + GenCounter.add(events_per_second_rate.id) + + TenantBroadcaster.pubsub_broadcast( + tenant.external_id, + tenant_topic, + broadcast, + RealtimeChannel.MessageDispatcher, + :broadcast + ) + end + + defp build_json_broadcast(topic, event, payload) do + %UserBroadcast{ + topic: topic, + user_event: event, + # We don't use Jason.encode_to_iodata because this message will be sent through gen_rpc which will + # call term_to_iovec and then binary_to_term from the remote peer. All of this is more expensive on an + # iolist compared to a single binary. Reconstructing a single binary is cheaper than an iolist + user_payload: Jason.encode!(payload), + user_payload_encoding: :json, + metadata: nil + } + end + + defp build_binary_broadcast(topic, event, binary) do + %UserBroadcast{ + topic: topic, + user_event: event, + user_payload: binary, + user_payload_encoding: :binary, + metadata: nil + } + end +end diff --git a/lib/realtime/user_counter.ex b/lib/realtime/user_counter.ex deleted file mode 100644 index 6190030d9..000000000 --- a/lib/realtime/user_counter.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule Realtime.UsersCounter do - @moduledoc """ - Counts of connected clients for a tenant across the whole cluster or for a single node. - """ - require Logger - - @doc """ - Adds a RealtimeChannel pid to the `:users` scope for a tenant so we can keep track of all connected clients for a tenant. - """ - @spec add(pid(), String.t()) :: :ok - def add(pid, tenant), do: :syn.join(:users, tenant, pid) - - @doc """ - Returns the count of all connected clients for a tenant for the cluster. - """ - @spec tenant_users(String.t()) :: non_neg_integer() - def tenant_users(tenant), do: :syn.member_count(:users, tenant) - - @doc """ - Returns the count of all connected clients for a tenant for a single node. - """ - @spec tenant_users(atom, String.t()) :: non_neg_integer() - def tenant_users(node_name, tenant), do: :syn.member_count(:users, tenant, node_name) -end diff --git a/lib/realtime/users_counter.ex b/lib/realtime/users_counter.ex new file mode 100644 index 000000000..65c320507 --- /dev/null +++ b/lib/realtime/users_counter.ex @@ -0,0 +1,40 @@ +defmodule Realtime.UsersCounter do + @moduledoc """ + Counts of connected clients for a tenant across the whole cluster or for a single node. + """ + alias Forum.Census + + @doc """ + Adds a RealtimeChannel pid to the `:users` scope for a tenant so we can keep track of all connected clients for a tenant. + """ + @spec add(pid(), String.t()) :: :ok + def add(pid, tenant_id) when is_pid(pid) and is_binary(tenant_id) do + :ok = Census.join(:users, tenant_id, pid) + end + + @doc "Return true if pid is already counted for tenant_id" + @spec already_counted?(pid(), String.t()) :: boolean() + def already_counted?(pid, tenant_id), do: Census.local_member?(:users, tenant_id, pid) + + @doc "List all local tenants with connected clients on this node." + @spec local_tenants() :: [String.t()] + def local_tenants(), do: Census.local_groups(:users) + + @doc """ + Returns the count of all connected clients for a tenant for the cluster. + """ + @spec tenant_users(String.t()) :: non_neg_integer() + def tenant_users(tenant_id), do: Census.member_count(:users, tenant_id) + + @doc """ + Returns the counts of all connected clients for all tenants for the cluster. + """ + @spec tenant_counts() :: %{String.t() => non_neg_integer()} + def tenant_counts(), do: Census.member_counts(:users) + + @doc """ + Returns the counts of all connected clients for all tenants for the local node. + """ + @spec local_tenant_counts() :: %{String.t() => non_neg_integer()} + def local_tenant_counts(), do: Census.local_member_counts(:users) +end diff --git a/lib/realtime_web/channels/auth/channels_authorization.ex b/lib/realtime_web/channels/auth/channels_authorization.ex index 56c574f34..e8f72b4d7 100644 --- a/lib/realtime_web/channels/auth/channels_authorization.ex +++ b/lib/realtime_web/channels/auth/channels_authorization.ex @@ -2,7 +2,6 @@ defmodule RealtimeWeb.ChannelsAuthorization do @moduledoc """ Check connection is authorized to access channel """ - require Logger @doc """ Authorize connection to access channel @@ -20,14 +19,15 @@ defmodule RealtimeWeb.ChannelsAuthorization do def authorize_conn(token, jwt_secret, jwt_jwks) do case authorize(token, jwt_secret, jwt_jwks) do {:ok, claims} -> - required = MapSet.new(["role", "exp"]) - claims_keys = claims |> Map.keys() |> MapSet.new() + required = ["role", "exp"] + claims_keys = Map.keys(claims) - if MapSet.subset?(required, claims_keys), + if Enum.all?(required, &(&1 in claims_keys)), do: {:ok, claims}, else: {:error, :missing_claims} - {:error, [message: validation_timer, claim: "exp", claim_val: claim_val]} when is_integer(validation_timer) -> + {:error, [message: validation_timer, claim: "exp", claim_val: claim_val]} + when is_integer(validation_timer) and is_integer(claim_val) -> msg = "Token has expired #{validation_timer - claim_val} seconds ago" {:error, :expired_token, msg} diff --git a/lib/realtime_web/channels/auth/jwt_verification.ex b/lib/realtime_web/channels/auth/jwt_verification.ex index 0828c28c1..9098d2f4c 100644 --- a/lib/realtime_web/channels/auth/jwt_verification.ex +++ b/lib/realtime_web/channels/auth/jwt_verification.ex @@ -2,8 +2,9 @@ defmodule RealtimeWeb.JwtVerification do @moduledoc """ Parse JWT and verify claims """ + # Matching error in Dialyzer when using Joken.peek_claims/1 but {:ok, []} is actually possible and covered by our testing - @dialyzer {:nowarn_function, check_claims_format: 1} + @dialyzer {:no_match, check_claims_format: 1} defmodule JwtAuthToken do @moduledoc false @@ -16,11 +17,16 @@ defmodule RealtimeWeb.JwtVerification do add_claim_validator(claims, claim_key, expected_val) end) |> add_claim_validator("exp") + |> add_claim_validator("iat") end defp add_claim_validator(claims, "exp") do current_time = current_time() - add_claim(claims, "exp", nil, &(&1 > current_time), message: current_time) + add_claim(claims, "exp", nil, &(is_number(&1) and &1 > current_time), message: current_time) + end + + defp add_claim_validator(claims, "iat") do + add_claim(claims, "iat", nil, &is_number/1) end defp add_claim_validator(claims, claim_key, expected_val) do @@ -40,8 +46,12 @@ defmodule RealtimeWeb.JwtVerification do def verify(token, jwt_secret, jwt_jwks) when is_binary(token) do with {:ok, _claims} <- check_claims_format(token), {:ok, header} <- check_header_format(token), - {:ok, signer} <- generate_signer(header, jwt_secret, jwt_jwks) do - JwtAuthToken.verify_and_validate(token, signer) + {:ok, signer} <- generate_signer(header, jwt_secret, jwt_jwks), + {:ok, claims} <- JwtAuthToken.verify_and_validate(token, signer) do + # JWT spec allows exp and iat to be a decimal number. This is uncommon + # though, so most implementation round them to integer to avoid unexpected + # type errors. So, we round it before returning. + {:ok, round_decimal_numbers(claims)} else {:error, _e} = error -> error end @@ -64,6 +74,19 @@ defmodule RealtimeWeb.JwtVerification do end end + defp round_decimal_numbers(claims) do + claims + |> round_claim("exp") + |> round_claim("iat") + end + + defp round_claim(claims, key) do + case Map.get(claims, key) do + value when is_number(value) -> Map.put(claims, key, round(value)) + _ -> claims + end + end + defp generate_signer(%{"alg" => alg, "kid" => kid}, _jwt_secret, %{ "keys" => keys }) diff --git a/lib/realtime_web/channels/payloads/broadcast.ex b/lib/realtime_web/channels/payloads/broadcast.ex index 7feddb043..9712be4d5 100644 --- a/lib/realtime_web/channels/payloads/broadcast.ex +++ b/lib/realtime_web/channels/payloads/broadcast.ex @@ -5,13 +5,17 @@ defmodule RealtimeWeb.Channels.Payloads.Broadcast do use Ecto.Schema import Ecto.Changeset alias RealtimeWeb.Channels.Payloads.Join + alias RealtimeWeb.Channels.Payloads.FlexibleBoolean embedded_schema do - field :ack, :boolean, default: false - field :self, :boolean, default: false + field :ack, FlexibleBoolean, default: false + field :self, FlexibleBoolean, default: false + embeds_one :replay, RealtimeWeb.Channels.Payloads.Broadcast.Replay end def changeset(broadcast, attrs) do - cast(broadcast, attrs, [:ack, :self], message: &Join.error_message/2) + broadcast + |> cast(attrs, [:ack, :self], message: &Join.error_message/2) + |> cast_embed(:replay, invalid_message: "unable to parse, expected a map") end end diff --git a/lib/realtime_web/channels/payloads/broadcast/replay.ex b/lib/realtime_web/channels/payloads/broadcast/replay.ex new file mode 100644 index 000000000..b0a5804a2 --- /dev/null +++ b/lib/realtime_web/channels/payloads/broadcast/replay.ex @@ -0,0 +1,17 @@ +defmodule RealtimeWeb.Channels.Payloads.Broadcast.Replay do + @moduledoc """ + Validate broadcast replay field of the join payload. + """ + use Ecto.Schema + import Ecto.Changeset + alias RealtimeWeb.Channels.Payloads.Join + + embedded_schema do + field :limit, :integer, default: 10 + field :since, :integer, default: 0 + end + + def changeset(broadcast, attrs) do + cast(broadcast, attrs, [:limit, :since], message: &Join.error_message/2) + end +end diff --git a/lib/realtime_web/channels/payloads/config.ex b/lib/realtime_web/channels/payloads/config.ex index 923020174..f244ba665 100644 --- a/lib/realtime_web/channels/payloads/config.ex +++ b/lib/realtime_web/channels/payloads/config.ex @@ -8,15 +8,25 @@ defmodule RealtimeWeb.Channels.Payloads.Config do alias RealtimeWeb.Channels.Payloads.Broadcast alias RealtimeWeb.Channels.Payloads.Presence alias RealtimeWeb.Channels.Payloads.PostgresChange + alias RealtimeWeb.Channels.Payloads.FlexibleBoolean embedded_schema do embeds_one :broadcast, Broadcast embeds_one :presence, Presence embeds_many :postgres_changes, PostgresChange - field :private, :boolean, default: false + field :private, FlexibleBoolean, default: false end def changeset(config, attrs) do + attrs = + attrs + |> Enum.map(fn + {k, v} when is_list(v) -> {k, Enum.filter(v, fn v -> v != nil end)} + {"postgres_changes", nil} -> {"postgres_changes", []} + {k, v} -> {k, v} + end) + |> Map.new() + config |> cast(attrs, [:private], message: &Join.error_message/2) |> cast_embed(:broadcast, invalid_message: "unable to parse, expected a map") diff --git a/lib/realtime_web/channels/payloads/flexible_boolean.ex b/lib/realtime_web/channels/payloads/flexible_boolean.ex new file mode 100644 index 000000000..0738e20c0 --- /dev/null +++ b/lib/realtime_web/channels/payloads/flexible_boolean.ex @@ -0,0 +1,35 @@ +defmodule RealtimeWeb.Channels.Payloads.FlexibleBoolean do + @moduledoc """ + Custom Ecto type that handles boolean values coming as strings. + + Accepts: + - Boolean values (true/false) - used as-is + - Strings "true", "True", "TRUE", etc. - cast to true + - Strings "false", "False", "FALSE", etc. - cast to false + - Any other value - returns error + """ + use Ecto.Type + + @impl true + def type, do: :boolean + + @impl true + def cast(value) when is_boolean(value), do: {:ok, value} + + def cast(value) when is_binary(value) do + case String.downcase(value) do + "true" -> {:ok, true} + "false" -> {:ok, false} + _ -> :error + end + end + + def cast(_), do: :error + + @impl true + def load(value), do: {:ok, value} + + @impl true + def dump(value) when is_boolean(value), do: {:ok, value} + def dump(_), do: :error +end diff --git a/lib/realtime_web/channels/payloads/join.ex b/lib/realtime_web/channels/payloads/join.ex index 6f5e3ef11..0ee61f7c2 100644 --- a/lib/realtime_web/channels/payloads/join.ex +++ b/lib/realtime_web/channels/payloads/join.ex @@ -52,7 +52,10 @@ defmodule RealtimeWeb.Channels.Payloads.Join do type = Keyword.get(meta, :type) if type, - do: "unable to parse, expected #{type}", + do: "unable to parse, expected #{format_type(type)}", else: "unable to parse" end + + defp format_type(RealtimeWeb.Channels.Payloads.FlexibleBoolean), do: :boolean + defp format_type(type), do: type end diff --git a/lib/realtime_web/channels/payloads/presence.ex b/lib/realtime_web/channels/payloads/presence.ex index 53e09047d..7f316a1e7 100644 --- a/lib/realtime_web/channels/payloads/presence.ex +++ b/lib/realtime_web/channels/payloads/presence.ex @@ -5,10 +5,11 @@ defmodule RealtimeWeb.Channels.Payloads.Presence do use Ecto.Schema import Ecto.Changeset alias RealtimeWeb.Channels.Payloads.Join + alias RealtimeWeb.Channels.Payloads.FlexibleBoolean embedded_schema do - field :enabled, :boolean, default: true - field :key, :string, default: UUID.uuid1() + field :enabled, FlexibleBoolean, default: true + field :key, :any, default: UUID.uuid1(), virtual: true end def changeset(presence, attrs) do diff --git a/lib/realtime_web/channels/presence.ex b/lib/realtime_web/channels/presence.ex index f4d378b92..5fcf94a18 100644 --- a/lib/realtime_web/channels/presence.ex +++ b/lib/realtime_web/channels/presence.ex @@ -8,5 +8,5 @@ defmodule RealtimeWeb.Presence do use Phoenix.Presence, otp_app: :realtime, pubsub_server: Realtime.PubSub, - pool_size: 10 + dispatcher: RealtimeWeb.RealtimeChannel.MessageDispatcher end diff --git a/lib/realtime_web/channels/realtime_channel.ex b/lib/realtime_web/channels/realtime_channel.ex index 26c033f5c..b1797ed4f 100644 --- a/lib/realtime_web/channels/realtime_channel.ex +++ b/lib/realtime_web/channels/realtime_channel.ex @@ -5,9 +5,9 @@ defmodule RealtimeWeb.RealtimeChannel do use RealtimeWeb, :channel use RealtimeWeb.RealtimeChannel.Logging - alias RealtimeWeb.SocketDisconnect alias DBConnection.Backoff + alias Realtime.Api.Tenant alias Realtime.Crypto alias Realtime.GenCounter alias Realtime.Helpers @@ -18,8 +18,9 @@ defmodule RealtimeWeb.RealtimeChannel do alias Realtime.Tenants.Authorization alias Realtime.Tenants.Authorization.Policies alias Realtime.Tenants.Authorization.Policies.BroadcastPolicies - alias Realtime.Tenants.Authorization.Policies.PresencePolicies + alias Realtime.Tenants.Cache alias Realtime.Tenants.Connect + alias Realtime.UsersCounter alias RealtimeWeb.Channels.Payloads.Join alias RealtimeWeb.ChannelsAuthorization @@ -29,31 +30,41 @@ defmodule RealtimeWeb.RealtimeChannel do alias RealtimeWeb.RealtimeChannel.Tracker @confirm_token_ms_interval :timer.minutes(5) + @fullsweep_after Application.compile_env!(:realtime, :websocket_fullsweep_after) @impl true def join("realtime:", _params, socket) do - log_error(socket, "TopicNameRequired", "You must provide a topic name") + socket + |> log_error("TopicNameRequired", "You must provide a topic name") + |> join_error() end def join("realtime:" <> sub_topic = topic, params, socket) do %{ - assigns: %{tenant: tenant_id, log_level: log_level, postgres_cdc_module: module}, + assigns: %{tenant: tenant_id, log_level: log_level}, channel_pid: channel_pid, serializer: serializer, transport_pid: transport_pid } = socket + Process.flag(:max_heap_size, max_heap_size()) + Process.flag(:fullsweep_after, @fullsweep_after) Tracker.track(socket.transport_pid) Logger.metadata(external_id: tenant_id, project: tenant_id) Logger.put_process_level(self(), log_level) + presence_enabled? = + case get_in(params, ["config", "presence", "enabled"]) do + enabled when is_boolean(enabled) -> enabled + _ -> false + end + socket = socket |> assign_access_token(params) - |> assign_counter() - |> assign_presence_counter() |> assign(:private?, !!params["config"]["private"]) |> assign(:policies, nil) + |> assign(:presence_enabled?, presence_enabled?) case Join.validate(params) do {:ok, _join} -> @@ -65,31 +76,37 @@ defmodule RealtimeWeb.RealtimeChannel do end with :ok <- SignalHandler.shutdown_in_progress?(), - :ok <- only_private?(tenant_id, socket), - :ok <- limit_joins(socket), - :ok <- limit_channels(socket), - :ok <- limit_max_users(socket), + {:ok, tenant} <- Cache.fetch_tenant_by_external_id(tenant_id), + socket = + assign(socket, :presence_enabled?, presence_enabled?(socket.assigns.presence_enabled?, tenant)), + :ok <- only_private?(tenant, socket), + :ok <- limit_max_users(tenant, transport_pid), + :ok <- limit_joins(tenant, socket), + :ok <- limit_channels(tenant, socket), {:ok, claims, confirm_token_ref} <- confirm_token(socket), socket = assign_authorization_context(socket, sub_topic, claims), {:ok, db_conn} <- Connect.lookup_or_start_connection(tenant_id), - {:ok, socket} <- maybe_assign_policies(sub_topic, db_conn, socket) do + {:ok, socket} <- maybe_assign_policies(sub_topic, db_conn, socket), + {:ok, replayed_message_ids} <- + maybe_replay_messages(params["config"], sub_topic, db_conn, tenant_id, socket.assigns.private?) do tenant_topic = Tenants.tenant_topic(tenant_id, sub_topic, !socket.assigns.private?) # fastlane subscription metadata = - MessageDispatcher.fastlane_metadata(transport_pid, serializer, topic, socket.assigns.log_level, tenant_id) + MessageDispatcher.fastlane_metadata( + transport_pid, + serializer, + topic, + log_level, + tenant_id, + replayed_message_ids + ) RealtimeWeb.Endpoint.subscribe(tenant_topic, metadata: metadata) - - Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant_id) + RealtimeWeb.Endpoint.subscribe("realtime:operations:" <> tenant_id, metadata: metadata) is_new_api = new_api?(params) - # TODO: Default will be moved to false in the future - presence_enabled? = - case get_in(params, ["config", "presence", "enabled"]) do - enabled when is_boolean(enabled) -> enabled - _ -> true - end + presence_enabled? = socket.assigns.presence_enabled? pg_change_params = pg_change_params(is_new_api, params, channel_pid, claims, sub_topic) @@ -99,11 +116,10 @@ defmodule RealtimeWeb.RealtimeChannel do transport_pid: transport_pid, serializer: serializer, topic: topic, - tenant: tenant_id, - module: module + tenant: tenant_id } - postgres_cdc_subscribe(opts) + postgres_cdc_subscribe(tenant, opts) state = %{postgres_changes: add_id_to_postgres_changes(pg_change_params)} @@ -120,11 +136,16 @@ defmodule RealtimeWeb.RealtimeChannel do presence_enabled?: presence_enabled? } + socket = + socket + |> assign_counter(tenant) + |> assign_presence_counter(tenant) + |> assign_client_presence_rate_limit(tenant) + # Start presence and add user if presence is enabled if presence_enabled?, do: send(self(), :sync_presence) - Realtime.UsersCounter.add(transport_pid, tenant_id) - SocketDisconnect.add(tenant_id, socket) + UsersCounter.add(transport_pid, tenant_id) {:ok, state, assign(socket, assigns)} else @@ -139,8 +160,7 @@ defmodule RealtimeWeb.RealtimeChannel do log_error(socket, "Unauthorized", msg) {:error, :too_many_channels} -> - msg = "Too many channels" - log_error(socket, "ChannelRateLimitReached", msg) + {:error, %{reason: "ChannelRateLimitReached: Too many channels"}} {:error, :too_many_connections} -> msg = "Too many connected users" @@ -148,6 +168,7 @@ defmodule RealtimeWeb.RealtimeChannel do {:error, :too_many_joins} -> msg = "ClientJoinRateLimitReached: Too many joins per second" + send(transport_pid, %Phoenix.Socket.Broadcast{event: "disconnect"}) {:error, %{reason: msg}} {:error, :increase_connection_pool} -> @@ -158,6 +179,10 @@ defmodule RealtimeWeb.RealtimeChannel do msg = "Database can't accept more connections, Realtime won't connect" log_error(socket, "DatabaseLackOfConnections", msg) + {:error, :connect_rate_limit_reached} -> + msg = "Too many database connections attempts per second" + log_error(socket, "DatabaseConnectionRateLimitReached", msg) + {:error, :unable_to_set_policies, error} -> log_error(socket, "UnableToSetPolicies", error) {:error, %{reason: "Realtime was unable to connect to the project database"}} @@ -165,6 +190,12 @@ defmodule RealtimeWeb.RealtimeChannel do {:error, :tenant_database_unavailable} -> log_error(socket, "UnableToConnectToProject", "Realtime was unable to connect to the project database") + {:error, :query_canceled, error} -> + log_error(socket, "QueryCanceled", error) + + {:error, :missing_partition} -> + log_error(socket, "MissingPartition", "Realtime was unable to find the expected messages partition") + {:error, :rpc_error, :timeout} -> log_error(socket, "TimeoutOnRpcCall", "Node request timeout") @@ -187,6 +218,7 @@ defmodule RealtimeWeb.RealtimeChannel do log_error(socket, "PrivateOnly", "This project only allows private channels") {:error, :tenant_not_found} -> + send(transport_pid, %Phoenix.Socket.Broadcast{event: "disconnect"}) log_error(socket, "TenantNotFound", "Tenant with the given ID does not exist") {:error, :tenant_suspended} -> @@ -198,13 +230,45 @@ defmodule RealtimeWeb.RealtimeChannel do {:error, :shutdown_in_progress} -> log_error(socket, "RealtimeRestarting", "Realtime is restarting, please standby") + {:error, :failed_to_replay_messages} -> + log_error(socket, "UnableToReplayMessages", "Realtime was unable to replay messages") + + {:error, :invalid_replay_params} -> + log_error(socket, "UnableToReplayMessages", "Replay params are not valid") + + {:error, :invalid_replay_channel} -> + log_error(socket, "UnableToReplayMessages", "Replay is not allowed for public channels") + + {:error, :error_generating_signer} -> + log_error( + socket, + "JwtSignerError", + "Failed to generate JWT signer, check your JWT secret or JWKS configuration" + ) + {:error, error} -> log_error(socket, "UnknownErrorOnChannel", error) {:error, %{reason: "Unknown Error on Channel"}} end + |> join_error() + rescue + e -> + log_error(socket, "UnknownErrorOnChannel", Exception.message(e)) + |> join_error() end @impl true + def handle_info({:replay, messages}, socket) do + for message <- messages do + meta = %{"replayed" => true, "id" => message.id} + payload = %{"payload" => message.payload, "event" => message.event, "type" => "broadcast", "meta" => meta} + + push(socket, "broadcast", payload) + end + + {:noreply, socket} + end + def handle_info(:update_rate_counter, socket) do count(socket) @@ -226,27 +290,11 @@ defmodule RealtimeWeb.RealtimeChannel do {:noreply, assign(socket, %{pg_sub_ref: pg_sub_ref})} end - def handle_info( - %{event: "presence_diff"}, - %{assigns: %{policies: %Policies{presence: %PresencePolicies{read: false}}}} = socket - ) do - Logger.warning("Presence message ignored") - {:noreply, socket} - end - def handle_info(_msg, %{assigns: %{policies: %Policies{broadcast: %BroadcastPolicies{read: false}}}} = socket) do Logger.warning("Broadcast message ignored") {:noreply, socket} end - def handle_info(%{event: "presence_diff", payload: payload} = msg, socket) do - %{presence_rate_counter: presence_rate_counter} = socket.assigns - GenCounter.add(presence_rate_counter.id) - maybe_log_info(socket, msg) - push(socket, "presence_diff", payload) - {:noreply, socket} - end - def handle_info(%{event: type, payload: payload} = msg, socket) do count(socket) maybe_log_info(socket, msg) @@ -257,27 +305,35 @@ defmodule RealtimeWeb.RealtimeChannel do def handle_info(:postgres_subscribe, %{assigns: %{channel_name: channel_name}} = socket) do %{ assigns: %{ - tenant: tenant, + tenant: tenant_id, pg_sub_ref: pg_sub_ref, - pg_change_params: pg_change_params, - postgres_extension: postgres_extension, - postgres_cdc_module: module + pg_change_params: pg_change_params } } = socket Helpers.cancel_timer(pg_sub_ref) - args = Map.put(postgres_extension, "id", tenant) + %Tenant{} = tenant = Cache.get_tenant_by_external_id(tenant_id) + {:ok, module} = PostgresCdc.driver(tenant.postgres_cdc_default) + postgres_extension = PostgresCdc.filter_settings(tenant.postgres_cdc_default, tenant.extensions) + + args = %{"region" => postgres_extension["region"], "id" => tenant_id} case PostgresCdc.connect(module, args) do {:ok, response} -> - case PostgresCdc.after_connect(module, response, postgres_extension, pg_change_params) do + case PostgresCdc.after_connect(module, response, postgres_extension, pg_change_params, tenant_id) do {:ok, _response} -> message = "Subscribed to PostgreSQL" maybe_log_info(socket, message) push_system_message("postgres_changes", socket, "ok", message, channel_name) {:noreply, assign(socket, :pg_sub_ref, nil)} + {:error, {reason, error}} when reason in [:malformed_subscription_params, :subscription_insert_failed] -> + maybe_log_warning(socket, "RealtimeDisabledForConfiguration", error) + push_system_message("postgres_changes", socket, "error", error, channel_name) + # No point in retrying if the params are invalid + {:noreply, assign(socket, :pg_sub_ref, nil)} + error -> maybe_log_warning(socket, "RealtimeDisabledForConfiguration", error) @@ -323,12 +379,6 @@ defmodule RealtimeWeb.RealtimeChannel do end end - def handle_info(:disconnect, %{assigns: %{channel_name: channel_name}} = socket) do - Logger.info("Received operational call to disconnect channel") - push_system_message("system", socket, "ok", "Server requested disconnect", channel_name) - {:stop, :shutdown, socket} - end - def handle_info(:sync_presence, %{assigns: %{presence_enabled?: true}} = socket) do case PresenceHandler.sync(socket) do :ok -> @@ -366,9 +416,20 @@ defmodule RealtimeWeb.RealtimeChannel do {:ok, socket} <- PresenceHandler.handle(payload, db_conn, socket) do {:reply, :ok, socket} else + {:error, :client_rate_limit_exceeded} -> + log_error(socket, "ClientPresenceRateLimitReached", :client_rate_limit_exceeded) + shutdown_response(socket, "Client presence rate limit exceeded") + {:error, :rate_limit_exceeded} -> shutdown_response(socket, "Too many presence messages per second") + {:error, :payload_size_exceeded} -> + shutdown_response(socket, "Track message size exceeded") + + {:error, :invalid_payload} -> + log_error(socket, "InvalidPresencePayload", :invalid_payload) + {:reply, {:error, %{reason: "Presence track payload must be a map"}}, socket} + {:error, error} -> log_error(socket, "UnableToHandlePresence", error) {:reply, :error, socket} @@ -376,12 +437,23 @@ defmodule RealtimeWeb.RealtimeChannel do end def handle_in("presence", payload, %{assigns: %{private?: false}} = socket) do - with {:ok, socket} <- PresenceHandler.handle(payload, socket) do + with {:ok, socket} <- PresenceHandler.handle(payload, nil, socket) do {:reply, :ok, socket} else + {:error, :client_rate_limit_exceeded} -> + log_error(socket, "ClientPresenceRateLimitReached", :client_rate_limit_exceeded) + shutdown_response(socket, "Client presence rate limit exceeded") + {:error, :rate_limit_exceeded} -> shutdown_response(socket, "Too many presence messages per second") + {:error, :payload_size_exceeded} -> + shutdown_response(socket, "Track message size exceeded") + + {:error, :invalid_payload} -> + log_error(socket, "InvalidPresencePayload", :invalid_payload) + {:reply, {:error, %{reason: "Presence track payload must be a map"}}, socket} + {:error, error} -> log_error(socket, "UnableToHandlePresence", error) {:reply, :error, socket} @@ -445,14 +517,25 @@ defmodule RealtimeWeb.RealtimeChannel do {:error, :unable_to_set_policies, _msg} -> shutdown_response(socket, "Realtime was unable to connect to the project database") - {:error, error} -> - shutdown_response(socket, inspect(error)) + {:error, :tenant_database_unavailable} -> + shutdown_response(socket, "Realtime was unable to connect to the project database") + + {:error, :query_canceled, error} -> + log_error(socket, "QueryCanceled", error) + shutdown_response(socket, "Query was cancelled, please try again") + + {:error, :missing_partition} -> + log_error(socket, "MissingPartition", "Realtime was unable to find the expected messages partition") + shutdown_response(socket, "Realtime was unable to connect to the project database") {:error, :rpc_error, :timeout} -> shutdown_response(socket, "Node request timeout") {:error, :rpc_error, reason} -> shutdown_response(socket, "RPC call error: " <> inspect(reason)) + + {:error, error} -> + shutdown_response(socket, inspect(error)) end end @@ -469,7 +552,7 @@ defmodule RealtimeWeb.RealtimeChannel do @impl true def terminate(reason, %{transport_pid: transport_pid}) do - Logger.debug("Channel terminated with reason: #{reason}") + Logger.debug("Channel terminated with reason: #{inspect(reason)}") :telemetry.execute([:prom_ex, :plugin, :realtime, :disconnected], %{}) Tracker.untrack(transport_pid) :ok @@ -484,8 +567,8 @@ defmodule RealtimeWeb.RealtimeChannel do wait end - def limit_joins(%{assigns: %{tenant: tenant, limits: limits}} = socket) do - rate_args = Tenants.joins_per_second_rate(tenant, limits.max_joins_per_second) + def limit_joins(tenant, socket) do + rate_args = Tenants.joins_per_second_rate(tenant) RateCounter.new(rate_args) @@ -503,40 +586,72 @@ defmodule RealtimeWeb.RealtimeChannel do end end - def limit_channels(%{assigns: %{tenant: tenant, limits: limits}, transport_pid: pid}) do + def limit_channels(tenant, %{transport_pid: pid} = socket) do key = Tenants.channels_per_client_key(tenant) + count = Registry.count_match(Realtime.Registry, key, pid) + + cond do + count >= tenant.max_channels_per_client -> + {:error, :too_many_channels} + + count + 1 == tenant.max_channels_per_client -> + Registry.register(Realtime.Registry, key, pid) + log_error(socket, "ChannelRateLimitReached", "Too many channels") + :ok - if Registry.count_match(Realtime.Registry, key, pid) + 1 > limits.max_channels_per_client do - {:error, :too_many_channels} + true -> + Registry.register(Realtime.Registry, key, pid) + :ok + end + end + + defp limit_max_users(tenant, transport_pid) do + if !UsersCounter.already_counted?(transport_pid, tenant.external_id) and + UsersCounter.tenant_users(tenant.external_id) >= tenant.max_concurrent_users do + {:error, :too_many_connections} else - Registry.register(Realtime.Registry, Tenants.channels_per_client_key(tenant), pid) :ok end end - defp limit_max_users(%{assigns: %{limits: %{max_concurrent_users: max_conn_users}, tenant: tenant}}) do - conns = Realtime.UsersCounter.tenant_users(tenant) + defp assign_counter(socket, tenant) do + rate_args = Tenants.events_per_second_rate(tenant) - if conns < max_conn_users, - do: :ok, - else: {:error, :too_many_connections} + RateCounter.new(rate_args) + assign(socket, :rate_counter, rate_args) end - defp assign_counter(%{assigns: %{tenant: tenant, limits: limits}} = socket) do - rate_args = Tenants.events_per_second_rate(tenant, limits.max_events_per_second) + defp assign_presence_counter(socket, tenant) do + rate_args = Tenants.presence_events_per_second_rate(tenant) RateCounter.new(rate_args) - assign(socket, :rate_counter, rate_args) + + assign(socket, :presence_rate_counter, rate_args) end - defp assign_counter(socket), do: socket + defp assign_client_presence_rate_limit(socket, tenant) do + config = Application.get_env(:realtime, :client_presence_rate_limit, max_calls: 5, window_ms: 30_000) - defp assign_presence_counter(%{assigns: %{tenant: tenant, limits: limits}} = socket) do - rate_args = Tenants.presence_events_per_second_rate(tenant, limits.max_events_per_second) + max_calls = + case tenant.max_client_presence_events_per_window do + value when is_integer(value) and value > 0 -> value + _ -> config[:max_calls] + end - RateCounter.new(rate_args) + window_ms = + case tenant.client_presence_window_ms do + value when is_integer(value) and value > 0 -> value + _ -> config[:window_ms] + end - assign(socket, :presence_rate_counter, rate_args) + client_rate_limit = %{ + max_calls: max_calls, + window_ms: window_ms, + counter: 0, + reset_at: nil + } + + assign(socket, :presence_client_rate_limit, client_rate_limit) end defp count(%{assigns: %{rate_counter: counter}}), do: GenCounter.add(counter.id) @@ -677,17 +792,15 @@ defmodule RealtimeWeb.RealtimeChannel do ] end - defp postgres_cdc_subscribe(%{pg_change_params: []}), do: [] + defp postgres_cdc_subscribe(_tenant, %{pg_change_params: []}), do: [] - defp postgres_cdc_subscribe(opts) do + defp postgres_cdc_subscribe(tenant, opts) do %{ is_new_api: is_new_api, pg_change_params: pg_change_params, transport_pid: transport_pid, serializer: serializer, - topic: topic, - tenant: tenant, - module: module + topic: topic } = opts ids = @@ -696,11 +809,12 @@ defmodule RealtimeWeb.RealtimeChannel do end) subscription_metadata = - {:subscriber_fastlane, transport_pid, serializer, ids, topic, tenant, is_new_api} + {:subscriber_fastlane, transport_pid, serializer, ids, topic, is_new_api} metadata = [metadata: subscription_metadata] - PostgresCdc.subscribe(module, pg_change_params, tenant, metadata) + {:ok, module} = PostgresCdc.driver(tenant.postgres_cdc_default) + PostgresCdc.subscribe(module, pg_change_params, tenant.external_id, metadata) send(self(), :postgres_subscribe) @@ -732,8 +846,12 @@ defmodule RealtimeWeb.RealtimeChannel do when not is_nil(topic) do authorization_context = socket.assigns.authorization_context policies = socket.assigns.policies || %Policies{} + presence_enabled? = socket.assigns.presence_enabled? - with {:ok, policies} <- Authorization.get_read_authorizations(policies, db_conn, authorization_context) do + with {:ok, policies} <- + Authorization.get_read_authorizations(policies, db_conn, authorization_context, + presence_enabled?: presence_enabled? + ) do socket = assign(socket, :policies, policies) if match?(%Policies{broadcast: %BroadcastPolicies{read: false}}, socket.assigns.policies), @@ -748,18 +866,64 @@ defmodule RealtimeWeb.RealtimeChannel do {:error, :unauthorized, "You do not have permissions to read from this Channel topic: #{topic}"} - {:error, error} -> + {:error, %_{} = error} -> {:error, :unable_to_set_policies, error} + + other -> + other end end defp maybe_assign_policies(_, _, socket), do: {:ok, assign(socket, policies: nil)} - defp only_private?(tenant_id, %{assigns: %{private?: private?}}) do - tenant = Tenants.Cache.get_tenant_by_external_id(tenant_id) + defp only_private?(tenant, %{assigns: %{private?: private?}}) do + if tenant.private_only and !private? do + {:error, :private_only} + else + :ok + end + end + + defp maybe_replay_messages(%{"broadcast" => %{"replay" => _}}, _sub_topic, _db_conn, _tenant_id, false = _private?) do + {:error, :invalid_replay_channel} + end + + defp maybe_replay_messages( + %{"broadcast" => %{"replay" => replay_params}}, + sub_topic, + db_conn, + tenant_id, + true = _private? + ) + when is_map(replay_params) do + with {:ok, messages, message_ids} <- + Realtime.Messages.replay( + db_conn, + tenant_id, + sub_topic, + replay_params["since"], + replay_params["limit"] || 25 + ) do + # Send to self because we can't write to the socket before finishing the join process + send(self(), {:replay, messages}) + {:ok, message_ids} + end + end + + defp maybe_replay_messages(_, _, _, _, _), do: {:ok, MapSet.new()} - if tenant.private_only and !private?, - do: {:error, :private_only}, - else: :ok + defp presence_enabled?(client_enabled?, %Tenant{presence_enabled: tenant_enabled}) do + client_enabled? || tenant_enabled end + + defp max_heap_size(), do: :persistent_term.get({RealtimeWeb.UserSocket, :websocket_max_heap_size}) + + defp join_error({:error, _} = error) do + Process.sleep(channel_error_backoff_ms()) + error + end + + defp join_error(other), do: other + + defp channel_error_backoff_ms(), do: :persistent_term.get({__MODULE__, :channel_error_backoff_ms}) end diff --git a/lib/realtime_web/channels/realtime_channel/broadcast_handler.ex b/lib/realtime_web/channels/realtime_channel/broadcast_handler.ex index f8e736c2e..8aad778af 100644 --- a/lib/realtime_web/channels/realtime_channel/broadcast_handler.ex +++ b/lib/realtime_web/channels/realtime_channel/broadcast_handler.ex @@ -6,6 +6,7 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandler do import Phoenix.Socket, only: [assign: 3] + alias Realtime.Tenants alias RealtimeWeb.RealtimeChannel alias RealtimeWeb.TenantBroadcaster alias Phoenix.Socket @@ -14,11 +15,13 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandler do alias Realtime.Tenants.Authorization.Policies alias Realtime.Tenants.Authorization.Policies.BroadcastPolicies + @type payload :: map | {String.t(), :json | :binary, binary} + @event_type "broadcast" - @spec handle(map(), Socket.t()) :: {:reply, :ok, Socket.t()} | {:noreply, Socket.t()} + @spec handle(payload, Socket.t()) :: {:reply, :ok, Socket.t()} | {:noreply, Socket.t()} def handle(payload, %{assigns: %{private?: false}} = socket), do: handle(payload, nil, socket) - @spec handle(map(), pid() | nil, Socket.t()) :: {:reply, :ok, Socket.t()} | {:noreply, Socket.t()} + @spec handle(payload, pid() | nil, Socket.t()) :: {:reply, :ok, Socket.t()} | {:noreply, Socket.t()} def handle(payload, db_conn, %{assigns: %{private?: true}} = socket) do %{ assigns: %{ @@ -38,8 +41,23 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandler do |> increment_rate_counter() %{ack_broadcast: ack_broadcast} = socket.assigns - send_message(tenant_id, self_broadcast, tenant_topic, payload) - if ack_broadcast, do: {:reply, :ok, socket}, else: {:noreply, socket} + + res = + case Tenants.validate_payload_size(tenant_id, payload) do + :ok -> send_message(tenant_id, self_broadcast, tenant_topic, payload) + {:error, error} -> {:error, error} + end + + cond do + ack_broadcast && match?({:error, :payload_size_exceeded}, res) -> + {:reply, {:error, :payload_size_exceeded}, socket} + + ack_broadcast -> + {:reply, :ok, socket} + + true -> + {:noreply, socket} + end {:ok, policies} -> {:noreply, assign(socket, :policies, policies)} @@ -48,6 +66,21 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandler do log_error("RlsPolicyError", error) {:noreply, socket} + {:error, :query_canceled, error} -> + log_error("QueryCanceled", error) + {:noreply, socket} + + {:error, :missing_partition} -> + log_error("MissingPartition", "Realtime was unable to find the expected messages partition") + {:noreply, socket} + + {:error, :tenant_database_unavailable} -> + log_error("UnableToConnectToProject", "Realtime was unable to connect to the project database") + {:noreply, socket} + + {:error, :increase_connection_pool} -> + {:noreply, socket} + {:error, error} -> log_error("UnableToSetPolicies", error) {:noreply, socket} @@ -65,29 +98,66 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandler do } = socket socket = increment_rate_counter(socket) - send_message(tenant_id, self_broadcast, tenant_topic, payload) - if ack_broadcast, - do: {:reply, :ok, socket}, - else: {:noreply, socket} + res = + case Tenants.validate_payload_size(tenant_id, payload) do + :ok -> send_message(tenant_id, self_broadcast, tenant_topic, payload) + error -> error + end + + cond do + ack_broadcast && match?({:error, :payload_size_exceeded}, res) -> + {:reply, {:error, :payload_size_exceeded}, socket} + + ack_broadcast -> + {:reply, :ok, socket} + + true -> + {:noreply, socket} + end end defp send_message(tenant_id, self_broadcast, tenant_topic, payload) do - broadcast = %Phoenix.Socket.Broadcast{topic: tenant_topic, event: @event_type, payload: payload} + broadcast = build_broadcast(tenant_topic, payload) if self_broadcast do - TenantBroadcaster.pubsub_broadcast(tenant_id, tenant_topic, broadcast, RealtimeChannel.MessageDispatcher) + TenantBroadcaster.pubsub_broadcast( + tenant_id, + tenant_topic, + broadcast, + RealtimeChannel.MessageDispatcher, + :broadcast + ) else TenantBroadcaster.pubsub_broadcast_from( tenant_id, self(), tenant_topic, broadcast, - RealtimeChannel.MessageDispatcher + RealtimeChannel.MessageDispatcher, + :broadcast ) end end + # No idea why Dialyzer is complaining here + @dialyzer {:nowarn_function, build_broadcast: 2} + + # Message payload was built by V2 Serializer which was originally UserBroadcastPush + # We are not using the metadata for anything just yet. + defp build_broadcast(topic, {user_event, user_payload_encoding, user_payload, _metadata}) do + %RealtimeWeb.Socket.UserBroadcast{ + topic: topic, + user_event: user_event, + user_payload_encoding: user_payload_encoding, + user_payload: user_payload + } + end + + defp build_broadcast(topic, payload) do + %Phoenix.Socket.Broadcast{topic: topic, event: @event_type, payload: payload} + end + defp increment_rate_counter(%{assigns: %{policies: %Policies{broadcast: %BroadcastPolicies{write: false}}}} = socket) do socket end diff --git a/lib/realtime_web/channels/realtime_channel/logging.ex b/lib/realtime_web/channels/realtime_channel/logging.ex index 296dce1bc..177dcb910 100644 --- a/lib/realtime_web/channels/realtime_channel/logging.ex +++ b/lib/realtime_web/channels/realtime_channel/logging.ex @@ -20,8 +20,7 @@ defmodule RealtimeWeb.RealtimeChannel.Logging do {:error, %{reason: binary}} def log_error(socket, code, msg) do msg = build_msg(code, msg) - emit_system_error(:error, code) - log(socket, :error, msg) + log(socket, :error, code, msg) {:error, %{reason: msg}} end @@ -32,75 +31,91 @@ defmodule RealtimeWeb.RealtimeChannel.Logging do {:error, %{reason: binary}} def log_warning(socket, code, msg) do msg = build_msg(code, msg) - log(socket, :warning, msg) + log(socket, :warning, code, msg) {:error, %{reason: msg}} end @doc """ - Logs an error if the log level is set to error + Logs an error if the log level is set to error. + + Accepts an optional `throttle: {max_count, window_ms}` option to limit + how many times the log is emitted per tenant+code within the given time window. """ - @spec maybe_log_error(socket :: Phoenix.Socket.t(), code :: binary(), msg :: any()) :: {:error, %{reason: binary}} - def maybe_log_error(socket, code, msg), do: maybe_log(socket, :error, code, msg) + @spec maybe_log_error(socket :: Phoenix.Socket.t(), code :: binary(), msg :: any(), opts :: keyword()) :: + {:error, %{reason: binary}} + def maybe_log_error(socket, code, msg, opts \\ []), do: maybe_log(socket, :error, code, msg, opts) @doc """ - Logs a warning if the log level is set to warning + Logs a warning if the log level is set to warning. + + Accepts an optional `throttle: {max_count, window_ms}` option to limit + how many times the log is emitted per tenant+code within the given time window. """ - @spec maybe_log_warning(socket :: Phoenix.Socket.t(), code :: binary(), msg :: any()) :: {:error, %{reason: binary}} - def maybe_log_warning(socket, code, msg), do: maybe_log(socket, :warning, code, msg) + @spec maybe_log_warning(socket :: Phoenix.Socket.t(), code :: binary(), msg :: any(), opts :: keyword()) :: + {:error, %{reason: binary}} + def maybe_log_warning(socket, code, msg, opts \\ []), do: maybe_log(socket, :warning, code, msg, opts) @doc """ - Logs an info if the log level is set to info + Logs an info if the log level is set to info. """ @spec maybe_log_info(socket :: Phoenix.Socket.t(), msg :: any()) :: :ok - def maybe_log_info(socket, msg), do: maybe_log(socket, :info, nil, msg) + def maybe_log_info(socket, msg), do: maybe_log(socket, :info, nil, msg, []) - defp build_msg(code, msg) do - msg = stringify!(msg) - if code, do: "#{code}: #{msg}", else: msg - end + defp build_msg(nil, msg), do: stringify!(msg) + defp build_msg(code, msg), do: "#{code}: #{stringify!(msg)}" - defp log(%{assigns: %{tenant: tenant, access_token: access_token}}, level, msg) do + defp log(%{assigns: assigns}, level, code, msg) do + tenant = assigns.tenant Logger.metadata(external_id: tenant, project: tenant) - if level in [:error, :warning], do: update_metadata_with_token_claims(access_token) - Logger.log(level, msg) + enrich_metadata(level, Map.get(assigns, :access_token)) + Logger.log(level, msg, error_code: code) + emit_telemetry(level, code, tenant) end - defp maybe_log(%{assigns: %{log_level: log_level}} = socket, level, code, msg) do - msg = build_msg(code, msg) - emit_system_error(level, code) - if Logger.compare_levels(log_level, level) != :gt, do: log(socket, level, msg) - if level in [:error, :warning], do: {:error, %{reason: msg}}, else: :ok + defp enrich_metadata(level, token) when level in [:error, :warning], + do: update_metadata_with_token_claims(token) + + defp enrich_metadata(_level, _token), do: :ok + + defp emit_telemetry(:error, code, tenant), + do: Telemetry.execute([:realtime, :channel, :error], %{count: 1}, %{code: code, tenant: tenant}) + + defp emit_telemetry(_level, _code, _tenant), do: :ok + + defp maybe_log(%{assigns: %{log_level: log_level}} = socket, level, code, msg, opts) do + built_msg = build_msg(code, msg) + if Logger.compare_levels(log_level, level) != :gt, do: do_log(socket, level, code, built_msg, opts) + if level in [:error, :warning], do: {:error, %{reason: built_msg}}, else: :ok end - @system_errors [ - "UnableToSetPolicies", - "InitializingProjectConnection", - "DatabaseConnectionIssue", - "UnknownErrorOnChannel" - ] + defp do_log(socket, level, code, msg, []), do: log(socket, level, code, msg) - def system_errors, do: @system_errors + defp do_log(%{assigns: %{tenant: tenant}} = socket, level, code, msg, throttle: {max_count, window_ms}) do + key = {tenant, level, code} - defp emit_system_error(:error, code) when code in @system_errors, - do: Telemetry.execute([:realtime, :channel, :error], %{code: code}, %{code: code}) + case Cachex.get(Realtime.LogThrottle, key) do + {:ok, nil} -> + Cachex.put(Realtime.LogThrottle, key, 1, expire: window_ms) + log(socket, level, code, msg) - defp emit_system_error(_, _), do: nil + {:ok, count} when count < max_count -> + Cachex.incr(Realtime.LogThrottle, key) + log(socket, level, code, msg) + + _ -> + emit_telemetry(level, code, tenant) + end + end defp stringify!(msg) when is_binary(msg), do: msg defp stringify!(msg), do: inspect(msg, pretty: true) - defp update_metadata_with_token_claims(nil), do: nil + defp update_metadata_with_token_claims(nil), do: :ok defp update_metadata_with_token_claims(token) do case Joken.peek_claims(token) do - {:ok, claims} -> - sub = Map.get(claims, "sub") - exp = Map.get(claims, "exp") - iss = Map.get(claims, "iss") - Logger.metadata(sub: sub, exp: exp, iss: iss) - - _ -> - nil + {:ok, claims} -> Logger.metadata(sub: claims["sub"], exp: claims["exp"], iss: claims["iss"]) + _ -> :ok end end end diff --git a/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex b/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex index b5db97f95..5ab3cb1f1 100644 --- a/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex +++ b/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex @@ -4,41 +4,66 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcher do """ require Logger + alias Phoenix.Socket.Broadcast + alias RealtimeWeb.Socket.UserBroadcast - def fastlane_metadata(fastlane_pid, serializer, topic, :info, tenant_id) do - {:realtime_channel_fastlane, fastlane_pid, serializer, topic, {:log, tenant_id}} + def fastlane_metadata(fastlane_pid, serializer, topic, log_level, tenant_id, replayed_message_ids \\ MapSet.new()) do + {:rc_fastlane, fastlane_pid, serializer, topic, log_level, tenant_id, replayed_message_ids} end - def fastlane_metadata(fastlane_pid, serializer, topic, _log_level, _tenant_id) do - {:realtime_channel_fastlane, fastlane_pid, serializer, topic} - end + @presence_diff "presence_diff" @doc """ This dispatch function caches encoded messages if fastlane is used It also sends an :update_rate_counter to the subscriber and it can conditionally log + + fastlane_pid is the actual socket transport pid """ - @spec dispatch(list, pid, Phoenix.Socket.Broadcast.t()) :: :ok - def dispatch(subscribers, from, %Phoenix.Socket.Broadcast{} = msg) do - # fastlane_pid is the actual socket transport pid - # This reduce caches the serialization and bypasses the channel process going straight to the - # transport process + @spec dispatch(list, pid, Broadcast.t() | UserBroadcast.t()) :: :ok + def dispatch(subscribers, from, %Broadcast{event: @presence_diff} = msg) do + {_cache, count} = + Enum.reduce(subscribers, {%{}, 0}, fn + {pid, _}, {cache, count} when pid == from -> + {cache, count} + + {_pid, {:rc_fastlane, fastlane_pid, serializer, join_topic, log_level, tenant_id, _replayed_message_ids}}, + {cache, count} -> + maybe_log(log_level, join_topic, msg, tenant_id) + + cache = do_dispatch(msg, fastlane_pid, serializer, join_topic, cache, tenant_id, log_level) + {cache, count + 1} + + {pid, _}, {cache, count} -> + send(pid, msg) + {cache, count} + end) + + tenant_id = tenant_id(subscribers) + increment_presence_counter(tenant_id, msg.event, count) + + :ok + end + + def dispatch(subscribers, from, msg) do + message_id = message_id(msg) - # Credo doesn't like that we don't use the result aggregation _ = Enum.reduce(subscribers, %{}, fn {pid, _}, cache when pid == from -> cache - {pid, {:realtime_channel_fastlane, fastlane_pid, serializer, join_topic}}, cache -> - send(pid, :update_rate_counter) - do_dispatch(msg, fastlane_pid, serializer, join_topic, cache) + {pid, {:rc_fastlane, fastlane_pid, serializer, join_topic, log_level, tenant_id, replayed_message_ids}}, + cache -> + if already_replayed?(message_id, replayed_message_ids) do + # skip already replayed message + cache + else + send(pid, :update_rate_counter) - {pid, {:realtime_channel_fastlane, fastlane_pid, serializer, join_topic, {:log, tenant_id}}}, cache -> - send(pid, :update_rate_counter) - log = "Received message on #{join_topic} with payload: #{inspect(msg, pretty: true)}" - Logger.info(log, external_id: tenant_id, project: tenant_id) + maybe_log(log_level, join_topic, msg, tenant_id) - do_dispatch(msg, fastlane_pid, serializer, join_topic, cache) + do_dispatch(msg, fastlane_pid, serializer, join_topic, cache, tenant_id, log_level) + end {pid, _}, cache -> send(pid, msg) @@ -48,18 +73,71 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcher do :ok end - defp do_dispatch(msg, fastlane_pid, serializer, join_topic, cache) do + defp maybe_log(:info, join_topic, msg, tenant_id) when is_struct(msg) do + log = "Received message on #{join_topic} with payload: #{inspect(msg, pretty: true)}" + Logger.info(log, external_id: tenant_id, project: tenant_id) + end + + defp maybe_log(:info, join_topic, msg, tenant_id) when is_binary(msg) do + log = "Received message on #{join_topic}. #{msg}" + Logger.info(log, external_id: tenant_id, project: tenant_id) + end + + defp maybe_log(_level, _join_topic, _msg, _tenant_id), do: :ok + + defp do_dispatch(msg, fastlane_pid, serializer, join_topic, cache, tenant_id, log_level) do case cache do - %{^serializer => encoded_msg} -> + %{{^serializer, ^join_topic} => {:ok, encoded_msg}} -> send(fastlane_pid, encoded_msg) cache + %{{^serializer, ^join_topic} => {:error, _reason}} -> + # We do nothing at this stage. It has been already logged depending on the log level + cache + %{} -> # Use the original topic that was joined without the external_id msg = %{msg | topic: join_topic} - encoded_msg = serializer.fastlane!(msg) - send(fastlane_pid, encoded_msg) - Map.put(cache, serializer, encoded_msg) + + result = + case fastlane!(serializer, msg) do + {:ok, encoded_msg} -> + send(fastlane_pid, encoded_msg) + {:ok, encoded_msg} + + {:error, reason} -> + maybe_log(log_level, join_topic, reason, tenant_id) + {:error, reason} + end + + Map.put(cache, {serializer, join_topic}, result) + end + end + + # We have to convert because V1 does not know how to process UserBroadcast + defp fastlane!(Phoenix.Socket.V1.JSONSerializer = serializer, %UserBroadcast{} = msg) do + with {:ok, msg} <- UserBroadcast.convert_to_json_broadcast(msg) do + {:ok, serializer.fastlane!(msg)} end end + + defp fastlane!(serializer, msg), do: {:ok, serializer.fastlane!(msg)} + + defp tenant_id([{_pid, {:rc_fastlane, _, _, _, _, tenant_id, _}} | _]), do: tenant_id + defp tenant_id(_), do: nil + + defp increment_presence_counter(tenant_id, "presence_diff", count) when is_binary(tenant_id) do + tenant_id + |> Realtime.Tenants.presence_events_per_second_key() + |> Realtime.GenCounter.add(count) + end + + defp increment_presence_counter(_tenant_id, _event, _count), do: :ok + + defp message_id(%UserBroadcast{metadata: %{"id" => id}}), do: id + defp message_id(%Broadcast{payload: %{"meta" => %{"id" => id}}}), do: id + defp message_id(_), do: nil + + defp already_replayed?(nil, _replayed_message_ids), do: false + defp already_replayed?(message_id, replayed_message_ids), do: MapSet.member?(replayed_message_ids, message_id) end diff --git a/lib/realtime_web/channels/realtime_channel/presence_handler.ex b/lib/realtime_web/channels/realtime_channel/presence_handler.ex index 00ce77c02..295875d22 100644 --- a/lib/realtime_web/channels/realtime_channel/presence_handler.ex +++ b/lib/realtime_web/channels/realtime_channel/presence_handler.ex @@ -52,28 +52,38 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do end end - @spec handle(map(), Socket.t()) :: - {:ok, Socket.t()} | {:error, :rls_policy_error | :unable_to_set_policies | :rate_limit_exceeded} - def handle(_, %{assigns: %{presence_enabled?: false}} = socket), do: {:ok, socket} - def handle(payload, socket) when not is_private?(socket), do: handle(payload, nil, socket) - @spec handle(map(), pid() | nil, Socket.t()) :: {:ok, Socket.t()} - | {:error, :rls_policy_error | :unable_to_set_policies | :rate_limit_exceeded | :unable_to_track_presence} - def handle(_, _, %{assigns: %{presence_enabled?: false}} = socket), do: {:ok, socket} - + | {:error, + :invalid_payload + | :rls_policy_error + | :query_canceled + | :missing_partition + | :tenant_database_unavailable + | :unable_to_set_policies + | :increase_connection_pool + | :rate_limit_exceeded + | :client_rate_limit_exceeded + | :unable_to_track_presence + | :payload_size_exceeded} def handle(%{"event" => event} = payload, db_conn, socket) do event = String.downcase(event, :ascii) - handle_presence_event(event, payload, db_conn, socket) + + with {:ok, socket} <- limit_client_presence_event(socket) do + handle_presence_event(event, payload, db_conn, socket) + else + {:error, :client_rate_limit_exceeded} = error -> error + end end - def handle(_payload, _db_conn, socket), do: {:ok, socket} + def handle(_, _, socket), do: {:ok, socket} - defp handle_presence_event("track", payload, _db_conn, socket) when not is_private?(socket) do + defp handle_presence_event("track", payload, _, socket) when not is_private?(socket) do track(socket, payload) end - defp handle_presence_event("track", payload, db_conn, socket) when is_nil(socket.assigns.policies.presence.write) do + defp handle_presence_event("track", payload, db_conn, socket) + when is_private?(socket) and is_nil(socket.assigns.policies.presence.write) do %{assigns: %{authorization_context: authorization_context, policies: policies}} = socket case Authorization.get_write_authorizations(policies, db_conn, authorization_context) do @@ -85,6 +95,20 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do log_error("RlsPolicyError", error) {:error, :rls_policy_error} + {:error, :query_canceled, error} -> + log_error("QueryCanceled", error) + {:error, :query_canceled} + + {:error, :missing_partition} -> + log_error("MissingPartition", "Realtime was unable to find the expected messages partition") + {:error, :missing_partition} + + {:error, :tenant_database_unavailable} -> + {:error, :tenant_database_unavailable} + + {:error, :increase_connection_pool} -> + {:error, :increase_connection_pool} + {:error, error} -> log_error("UnableToSetPolicies", error) {:error, :unable_to_set_policies} @@ -102,7 +126,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do defp handle_presence_event("untrack", _, _, socket) do %{assigns: %{presence_key: presence_key, tenant_topic: tenant_topic}} = socket :ok = Presence.untrack(self(), tenant_topic, presence_key) - {:ok, socket} + {:ok, assign(socket, :presence_track_payload, nil)} end defp handle_presence_event(event, _, _, _) do @@ -114,18 +138,35 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do %{assigns: %{presence_key: presence_key, tenant_topic: tenant_topic}} = socket payload = Map.get(payload, "payload", %{}) - with :ok <- limit_presence_event(socket), + with :ok <- check_track_payload(socket.assigns, payload), + tenant <- Tenants.Cache.get_tenant_by_external_id(socket.assigns.tenant), + :ok <- validate_payload_size(tenant, payload), + _ <- RealtimeWeb.TenantBroadcaster.collect_payload_size(socket.assigns.tenant, payload, :presence), + :ok <- limit_presence_event(socket), {:ok, _} <- Presence.track(self(), tenant_topic, presence_key, payload) do + socket = + socket + |> assign(:presence_enabled?, true) + |> assign(:presence_track_payload, payload) + {:ok, socket} else + {:error, :no_payload_change} -> + # no-op if payload hasn't changed + {:ok, socket} + {:error, {:already_tracked, pid, _, _}} -> case Presence.update(pid, tenant_topic, presence_key, payload) do - {:ok, _} -> {:ok, socket} - {:error, _} -> {:error, :unable_to_track_presence} + {:ok, _} -> + socket = assign(socket, :presence_track_payload, payload) + {:ok, socket} + + {:error, _} -> + {:error, :unable_to_track_presence} end - {:error, :rate_limit_exceeded} -> - {:error, :rate_limit_exceeded} + {:error, reason} when reason in [:invalid_payload, :rate_limit_exceeded, :payload_size_exceeded] -> + {:error, reason} {:error, error} -> log_error("UnableToTrackPresence", error) @@ -133,6 +174,10 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do end end + defp check_track_payload(_assigns, payload) when not is_map(payload), do: {:error, :invalid_payload} + defp check_track_payload(%{presence_track_payload: payload}, payload), do: {:error, :no_payload_change} + defp check_track_payload(_assigns, _payload), do: :ok + defp presence_dirty_list(topic) do [{:pool_size, size}] = :ets.lookup(Presence, :pool_size) @@ -143,10 +188,9 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do end defp limit_presence_event(socket) do - %{assigns: %{presence_rate_counter: presence_counter, tenant: tenant_id}} = socket + %{assigns: %{presence_rate_counter: presence_counter, tenant: _tenant_id}} = socket {:ok, rate_counter} = RateCounter.get(presence_counter) - - tenant = Tenants.Cache.get_tenant_by_external_id(tenant_id) + tenant = Tenants.Cache.get_tenant_by_external_id(socket.assigns.tenant) if rate_counter.avg > tenant.max_presence_events_per_second do {:error, :rate_limit_exceeded} @@ -155,4 +199,30 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do :ok end end + + defp limit_client_presence_event(socket) do + %{assigns: %{presence_client_rate_limit: limit_config}} = socket + + current_time = System.monotonic_time(:millisecond) + + # Check if we need to reset the window + cond do + is_nil(limit_config.reset_at) or current_time > limit_config.reset_at -> + # Start new window or reset expired window + updated_limit_config = %{limit_config | counter: 1, reset_at: current_time + limit_config.window_ms} + updated_socket = assign(socket, :presence_client_rate_limit, updated_limit_config) + {:ok, updated_socket} + + limit_config.counter >= limit_config.max_calls -> + {:error, :client_rate_limit_exceeded} + + true -> + # Increment counter + updated_limit_config = %{limit_config | counter: limit_config.counter + 1} + updated_socket = assign(socket, :presence_client_rate_limit, updated_limit_config) + {:ok, updated_socket} + end + end + + defp validate_payload_size(tenant, payload), do: Tenants.validate_payload_size(tenant, payload) end diff --git a/lib/realtime_web/channels/socket_disconnect.ex b/lib/realtime_web/channels/socket_disconnect.ex deleted file mode 100644 index 360f63ab4..000000000 --- a/lib/realtime_web/channels/socket_disconnect.ex +++ /dev/null @@ -1,62 +0,0 @@ -defmodule RealtimeWeb.SocketDisconnect do - @moduledoc """ - Handles the distributed disconnection of sockets for a given tenant. It also ensures that there are no repeated registrations of the same transport PID for a given tenant. - """ - use Realtime.Logs - - alias Phoenix.Socket - alias Realtime.Api.Tenant - alias Realtime.Tenants - - @doc """ - Adds a socket to the registry associated to a tenant. - It will register the transport PID and a list of channel PIDs associated with a given transport pid. - """ - @spec add(binary(), Socket.t()) :: :ok | {:error, term()} - def add(tenant_external_id, %Socket{transport_pid: transport_pid}) when is_binary(tenant_external_id) do - transport_pid_exists_match_spec = [ - { - {tenant_external_id, :"$1", :"$2"}, - [{:==, :"$2", transport_pid}], - [:"$1"] - } - ] - - case Registry.select(__MODULE__.Registry, transport_pid_exists_match_spec) do - [] -> {:ok, _} = Registry.register(__MODULE__.Registry, tenant_external_id, transport_pid) - _ -> nil - end - - :ok - end - - @doc """ - Disconnects all sockets associated with a given tenant across all nodes in the cluster. - """ - @spec distributed_disconnect(Tenant.t() | binary()) :: list(:ok | :error) - def distributed_disconnect(%Tenant{external_id: external_id}), do: distributed_disconnect(external_id) - - def distributed_disconnect(external_id) do - [Node.self() | Node.list()] - |> :erpc.multicall(__MODULE__, :disconnect, [external_id], 5000) - |> Enum.map(fn {res, _} -> res end) - end - - @doc """ - Disconnects all sockets associated with a given tenant on the current node. - """ - @spec disconnect(binary()) :: :ok | :error - def disconnect(%Tenant{external_id: external_id}), do: disconnect(external_id) - - def disconnect(tenant_external_id) do - Logger.metadata(external_id: tenant_external_id, project: tenant_external_id) - Logger.warning("Disconnecting all sockets for tenant #{tenant_external_id}") - Tenants.broadcast_operation_event(:disconnect, tenant_external_id) - - pids = Registry.lookup(__MODULE__.Registry, tenant_external_id) - for {_, pid} <- pids, Process.alive?(pid), do: Process.exit(pid, :shutdown) - Registry.unregister(__MODULE__.Registry, tenant_external_id) - - :ok - end -end diff --git a/lib/realtime_web/channels/tenant_rate_limiters.ex b/lib/realtime_web/channels/tenant_rate_limiters.ex new file mode 100644 index 000000000..2101ac945 --- /dev/null +++ b/lib/realtime_web/channels/tenant_rate_limiters.ex @@ -0,0 +1,43 @@ +defmodule RealtimeWeb.TenantRateLimiters do + @moduledoc """ + Rate limiters for tenants. + """ + require Logger + alias Realtime.UsersCounter + alias Realtime.Tenants + alias Realtime.RateCounter + alias Realtime.Api.Tenant + + @spec check_tenant(Realtime.Api.Tenant.t()) :: :ok | {:error, :too_many_connections | :too_many_joins} + def check_tenant(tenant) do + with :ok <- max_concurrent_users_check(tenant) do + max_joins_per_second_check(tenant) + end + end + + defp max_concurrent_users_check(%Tenant{max_concurrent_users: max_conn_users, external_id: external_id}) do + total_conn_users = UsersCounter.tenant_users(external_id) + + if total_conn_users < max_conn_users, + do: :ok, + else: {:error, :too_many_connections} + end + + defp max_joins_per_second_check(%Tenant{max_joins_per_second: max_joins_per_second} = tenant) do + rate_args = Tenants.joins_per_second_rate(tenant.external_id, max_joins_per_second) + + RateCounter.new(rate_args) + + case RateCounter.get(rate_args) do + {:ok, %{limit: %{triggered: false}}} -> + :ok + + {:ok, %{limit: %{triggered: true}}} -> + {:error, :too_many_joins} + + error -> + Logger.error("UnknownErrorOnCounter: #{inspect(error)}") + {:error, error} + end + end +end diff --git a/lib/realtime_web/channels/user_socket.ex b/lib/realtime_web/channels/user_socket.ex index 09dd15906..2f8e44aa8 100644 --- a/lib/realtime_web/channels/user_socket.ex +++ b/lib/realtime_web/channels/user_socket.ex @@ -1,16 +1,18 @@ defmodule RealtimeWeb.UserSocket do - use Phoenix.Socket + use RealtimeWeb.Socket use Realtime.Logs alias Realtime.Api.Tenant alias Realtime.Crypto alias Realtime.Database - alias Realtime.PostgresCdc alias Realtime.Tenants + alias RealtimeWeb.TenantRateLimiters alias RealtimeWeb.ChannelsAuthorization alias RealtimeWeb.RealtimeChannel alias RealtimeWeb.RealtimeChannel.Logging + alias RealtimeWeb.RealtimeChannel.MessageDispatcher + ## Channels channel "realtime:*", RealtimeChannel @@ -22,6 +24,29 @@ defmodule RealtimeWeb.UserSocket do @spec subscribers_id(String.t()) :: String.t() def subscribers_id(tenant), do: "user_socket:" <> tenant + @spec disconnect(binary()) :: :ok + def disconnect(tenant_external_id) do + Logger.warning("Disconnecting all sockets for tenant #{tenant_external_id}", + external_id: tenant_external_id, + project: tenant_external_id + ) + + disconnect_msg = %Phoenix.Socket.Broadcast{ + event: "system", + payload: %{extension: "system", status: "ok", message: "Server requested disconnect"} + } + + Phoenix.PubSub.broadcast!( + Realtime.PubSub, + "realtime:operations:" <> tenant_external_id, + disconnect_msg, + MessageDispatcher + ) + + Phoenix.PubSub.broadcast(Realtime.PubSub, subscribers_id(tenant_external_id), :socket_drain) + :ok + end + @impl true def connect(params, socket, opts) do %{uri: %{host: host}, x_headers: headers} = opts @@ -39,39 +64,20 @@ defmodule RealtimeWeb.UserSocket do |> assign(:log_level, log_level) |> assign(:access_token, token) - with %Tenant{ - jwt_secret: jwt_secret, - jwt_jwks: jwt_jwks, - postgres_cdc_default: postgres_cdc_default, - suspend: false - } = tenant <- Tenants.Cache.get_tenant_by_external_id(external_id), - token when is_binary(token) <- token, + with {:ok, + %Tenant{ + jwt_secret: jwt_secret, + jwt_jwks: jwt_jwks, + suspend: false + } = tenant} <- Tenants.Cache.fetch_tenant_by_external_id(external_id), + {:ok, token} <- validate_token(token), jwt_secret_dec <- Crypto.decrypt!(jwt_secret), {:ok, claims} <- ChannelsAuthorization.authorize_conn(token, jwt_secret_dec, jwt_jwks), - {:ok, postgres_cdc_module} <- PostgresCdc.driver(postgres_cdc_default) do - %Tenant{ - extensions: extensions, - max_concurrent_users: max_conn_users, - max_events_per_second: max_events_per_second, - max_bytes_per_second: max_bytes_per_second, - max_joins_per_second: max_joins_per_second, - max_channels_per_client: max_channels_per_client, - postgres_cdc_default: postgres_cdc_default - } = tenant - + :ok <- TenantRateLimiters.check_tenant(tenant) do assigns = %RealtimeChannel.Assigns{ claims: claims, jwt_secret: jwt_secret, jwt_jwks: jwt_jwks, - limits: %{ - max_concurrent_users: max_conn_users, - max_events_per_second: max_events_per_second, - max_bytes_per_second: max_bytes_per_second, - max_joins_per_second: max_joins_per_second, - max_channels_per_client: max_channels_per_client - }, - postgres_extension: PostgresCdc.filter_settings(postgres_cdc_default, extensions), - postgres_cdc_module: postgres_cdc_module, tenant: external_id, log_level: log_level, tenant_token: token, @@ -82,30 +88,44 @@ defmodule RealtimeWeb.UserSocket do {:ok, assign(socket, assigns)} else - nil -> + {:error, :tenant_not_found} -> log_error("TenantNotFound", "Tenant not found: #{external_id}") - {:error, :tenant_not_found} + connect_error(:tenant_not_found) - %Tenant{suspend: true} -> + {:ok, %Tenant{suspend: true}} -> Logging.log_error(socket, "RealtimeDisabledForTenant", "Realtime disabled for this tenant") - {:error, :tenant_suspended} + connect_error(:tenant_suspended) + + {:error, :missing_api_key} -> + log_error("MissingAPIKey", "API key is missing or not a valid string") + connect_error(:missing_api_key) {:error, :expired_token, msg} -> Logging.maybe_log_warning(socket, "InvalidJWTToken", msg) - {:error, :expired_token} + connect_error(:expired_token) {:error, :missing_claims} -> msg = "Fields `role` and `exp` are required in JWT" Logging.maybe_log_warning(socket, "InvalidJWTToken", msg) - {:error, :missing_claims} + connect_error(:missing_claims) {:error, :token_malformed} -> log_error("MalformedJWT", "The token provided is not a valid JWT") - {:error, :token_malformed} + connect_error(:token_malformed) + + {:error, :too_many_connections} -> + msg = "Too many connected users" + Logging.log_error(socket, "ConnectionRateLimitReached", msg) + connect_error(:too_many_connections) + + {:error, :too_many_joins} -> + msg = "Too many joins per second" + Logging.log_error(socket, "JoinsRateLimitReached", msg) + connect_error(:too_many_joins) error -> log_error("ErrorConnectingToWebsocket", error) - error + connect_error(error) end end @@ -122,4 +142,14 @@ defmodule RealtimeWeb.UserSocket do _ -> @default_log_level end end + + defp validate_token(token) when is_binary(token), do: {:ok, token} + defp validate_token(_), do: {:error, :missing_api_key} + + defp connect_error(reason) do + Process.sleep(connect_error_backoff_ms()) + {:error, reason} + end + + defp connect_error_backoff_ms(), do: :persistent_term.get({__MODULE__, :connect_error_backoff_ms}) end diff --git a/lib/realtime_web/controllers/broadcast_single_controller.ex b/lib/realtime_web/controllers/broadcast_single_controller.ex new file mode 100644 index 000000000..5d8e8afd7 --- /dev/null +++ b/lib/realtime_web/controllers/broadcast_single_controller.ex @@ -0,0 +1,113 @@ +defmodule RealtimeWeb.BroadcastSingleController do + @moduledoc """ + Controller for single broadcast API endpoint. + + This API sends a single broadcast message using URL path parameters for topic and event. + Supports both JSON and binary payloads via Content-Type header. + """ + use RealtimeWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias Realtime.Tenants.Authorization + alias Realtime.Tenants.SingleBroadcast + alias RealtimeWeb.OpenApiSchemas.EmptyResponse + alias RealtimeWeb.OpenApiSchemas.BroadcastSingleJsonParams + alias RealtimeWeb.OpenApiSchemas.BroadcastSingleBinaryParams + alias RealtimeWeb.OpenApiSchemas.TooManyRequestsResponse + alias RealtimeWeb.OpenApiSchemas.UnprocessableEntityResponse + + action_fallback(RealtimeWeb.FallbackController) + + operation(:broadcast, + summary: "Broadcasts a single message", + parameters: [ + token: [ + in: :header, + name: "Authorization", + schema: %OpenApiSpex.Schema{type: :string}, + required: true, + example: + "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2ODAxNjIxNTR9.U9orU6YYqXAtpF8uAiw6MS553tm4XxRzxOhz2IwDhpY" + ], + topic: [ + in: :path, + name: "topic", + schema: %OpenApiSpex.Schema{type: :string}, + required: true, + example: "room:123" + ], + event: [ + in: :path, + name: "event", + schema: %OpenApiSpex.Schema{type: :string}, + required: true, + example: "message", + description: "Event name for the broadcast" + ], + private: [ + in: :query, + name: "private", + schema: %OpenApiSpex.Schema{type: :boolean}, + required: false, + example: false, + description: "Whether this is a private broadcast (requires RLS authorization). Defaults to false." + ] + ], + request_body: %OpenApiSpex.RequestBody{ + description: "Broadcast message payload. Supports both JSON and binary formats.", + required: true, + content: %{ + "application/json" => %OpenApiSpex.MediaType{ + schema: BroadcastSingleJsonParams, + example: %{"text" => "hello world", "user" => "alice"} + }, + "application/octet-stream" => %OpenApiSpex.MediaType{ + schema: BroadcastSingleBinaryParams + } + } + }, + responses: %{ + 202 => EmptyResponse.response(), + 401 => EmptyResponse.response(), + 415 => EmptyResponse.response(), + 422 => UnprocessableEntityResponse.response(), + 429 => TooManyRequestsResponse.response() + } + ) + + def broadcast( + %{assigns: %{tenant: tenant}, body_params: %{"_binary" => binary}} = conn, + %{"topic" => topic, "event" => event} = params + ) do + private = parse_private(params["private"]) + auth_params = build_auth_params(conn, tenant) + + with :ok <- SingleBroadcast.broadcast(auth_params, tenant, topic, event, private, binary, :binary) do + send_resp(conn, :accepted, "") + end + end + + def broadcast(%{assigns: %{tenant: tenant}} = conn, %{"topic" => topic, "event" => event} = params) do + private = parse_private(params["private"]) + payload = conn.body_params + auth_params = build_auth_params(conn, tenant) + + with :ok <- SingleBroadcast.broadcast(auth_params, tenant, topic, event, private, payload, :json) do + send_resp(conn, :accepted, "") + end + end + + defp build_auth_params(conn, tenant) do + Authorization.build_authorization_params(%{ + tenant_id: tenant.external_id, + headers: conn.req_headers, + claims: conn.assigns.claims, + role: conn.assigns.role, + sub: conn.assigns.sub + }) + end + + defp parse_private("true"), do: true + defp parse_private(true), do: true + defp parse_private(_), do: false +end diff --git a/lib/realtime_web/controllers/fallback_controller.ex b/lib/realtime_web/controllers/fallback_controller.ex index d83d1d681..681c83a13 100644 --- a/lib/realtime_web/controllers/fallback_controller.ex +++ b/lib/realtime_web/controllers/fallback_controller.ex @@ -11,10 +11,12 @@ defmodule RealtimeWeb.FallbackController do import RealtimeWeb.ErrorHelpers def call(conn, {:error, :not_found}) do + log_error("TenantNotFound", "Tenant not found") + conn |> put_status(:not_found) |> put_view(RealtimeWeb.ErrorView) - |> render("error.json", message: "Not found") + |> render("error.json", message: "not found") end def call(conn, {:error, %Ecto.Changeset{} = changeset}) do @@ -29,15 +31,8 @@ defmodule RealtimeWeb.FallbackController do |> render("error.json", changeset: changeset) end - def call(conn, {:error, _}) do - conn - |> put_status(:unauthorized) - |> put_view(RealtimeWeb.ErrorView) - |> render("error.json", message: "Unauthorized") - end - def call(conn, {:error, status, message}) when is_atom(status) and is_binary(message) do - log_error("UnprocessableEntity", message) + if status == :unprocessable_entity, do: log_error("UnprocessableEntity", message) conn |> put_status(status) @@ -45,16 +40,11 @@ defmodule RealtimeWeb.FallbackController do |> render("error.json", message: message) end - def call(conn, %Ecto.Changeset{valid?: true} = changeset) do - log_error( - "UnprocessableEntity", - Ecto.Changeset.traverse_errors(changeset, &translate_error/1) - ) - + def call(conn, {:error, _}) do conn - |> put_status(:unprocessable_entity) - |> put_view(RealtimeWeb.ChangesetView) - |> render("error.json", changeset: changeset) + |> put_status(:unauthorized) + |> put_view(RealtimeWeb.ErrorView) + |> render("error.json", message: "Unauthorized") end def call(conn, %Ecto.Changeset{valid?: false} = changeset) do diff --git a/lib/realtime_web/controllers/metrics_controller.ex b/lib/realtime_web/controllers/metrics_controller.ex index 19509e21b..b34fa03b2 100644 --- a/lib/realtime_web/controllers/metrics_controller.ex +++ b/lib/realtime_web/controllers/metrics_controller.ex @@ -2,41 +2,72 @@ defmodule RealtimeWeb.MetricsController do use RealtimeWeb, :controller require Logger alias Realtime.PromEx + alias Realtime.TenantPromEx alias Realtime.GenRpc def index(conn, _) do + serve_metrics(conn, [Node.self() | Node.list()], "cluster") + end + + def region(conn, %{"region" => region}) do + serve_metrics(conn, Realtime.Nodes.region_nodes(region), "region=#{region}") + end + + defp serve_metrics(conn, nodes, label) do + conn = + conn + |> put_resp_content_type("text/plain") + |> send_chunked(200) + + {time, conn} = :timer.tc(fn -> collect_metrics(nodes, conn) end, :millisecond) + Logger.info("Collected #{label} metrics in #{time} milliseconds") + + conn + end + + defp collect_metrics(nodes, conn) do + bump_max_heap_size() timeout = Application.fetch_env!(:realtime, :metrics_rpc_timeout) - cluster_metrics = - Node.list() - |> Task.async_stream( - fn node -> - {node, GenRpc.call(node, PromEx, :get_compressed_metrics, [], timeout: timeout)} - end, - timeout: :infinity - ) - |> Enum.reduce(PromEx.get_metrics(), fn {_, {node, response}}, acc -> - case response do - {:error, :rpc_error, reason} -> - Logger.error("Cannot fetch metrics from the node #{inspect(node)} because #{inspect(reason)}") - acc - - metrics -> - acc <> uncompress(metrics) + nodes + |> Task.async_stream( + fn node -> + {node, GenRpc.call(node, __MODULE__, :get_metrics, [], timeout: timeout)} + end, + timeout: :infinity + ) + |> Enum.reduce(conn, fn + {:ok, {node, {:error, :rpc_error, reason}}}, acc_conn -> + Logger.error("Cannot fetch metrics from the node #{inspect(node)} because #{inspect(reason)}") + acc_conn + + {:ok, {_node, metrics}}, acc_conn -> + case chunk(acc_conn, metrics) do + {:ok, acc_conn} -> + :erlang.garbage_collect() + acc_conn + + {:error, reason} -> + Logger.error("Cannot stream metrics chunk because #{inspect(reason)}") + acc_conn end - end) - conn - |> put_resp_content_type("text/plain") - |> send_resp(200, cluster_metrics) + {:exit, reason}, acc_conn -> + Logger.error("Metrics collection task exited: #{inspect(reason)}") + acc_conn + end) end - defp uncompress(compressed_data) do - :zlib.uncompress(compressed_data) - rescue - error -> - Logger.error("Failed to decompress metrics data: #{inspect(error)}") - # Return empty string to not impact the aggregated metrics - "" + def get_metrics do + bump_max_heap_size() + [PromEx.get_global_metrics(), TenantPromEx.get_metrics()] + end + + defp bump_max_heap_size do + system_max_heap_size = :erlang.system_info(:max_heap_size)[:size] + + if is_integer(system_max_heap_size) and system_max_heap_size > 0 do + Process.flag(:max_heap_size, system_max_heap_size * 3) + end end end diff --git a/lib/realtime_web/controllers/tenant_controller.ex b/lib/realtime_web/controllers/tenant_controller.ex index 4beb6f209..4234f1146 100644 --- a/lib/realtime_web/controllers/tenant_controller.ex +++ b/lib/realtime_web/controllers/tenant_controller.ex @@ -22,13 +22,13 @@ defmodule RealtimeWeb.TenantController do alias RealtimeWeb.OpenApiSchemas.TenantResponse alias RealtimeWeb.OpenApiSchemas.TenantResponseList alias RealtimeWeb.OpenApiSchemas.UnauthorizedResponse - alias RealtimeWeb.SocketDisconnect + alias RealtimeWeb.UserSocket @stop_timeout 10_000 action_fallback(RealtimeWeb.FallbackController) - plug :set_observability_attributes when action in [:show, :edit, :update, :delete, :reload, :health] + plug :set_observability_attributes when action in [:show, :edit, :update, :delete, :reload, :shutdown, :health] operation(:index, summary: "List tenants", @@ -77,13 +77,8 @@ defmodule RealtimeWeb.TenantController do tenant = Api.get_tenant_by_external_id(id) case tenant do - %Tenant{} = tenant -> - render(conn, "show.json", tenant: tenant) - - nil -> - conn - |> put_status(404) - |> render("not_found.json", tenant: nil) + %Tenant{} = tenant -> render(conn, "show.json", tenant: tenant) + nil -> {:error, :not_found} end end @@ -137,7 +132,7 @@ defmodule RealtimeWeb.TenantController do ) def update(conn, %{"tenant_id" => external_id, "tenant" => tenant_params}) do - tenant = Api.get_tenant_by_external_id(external_id) + tenant = Api.get_tenant_by_external_id(external_id, use_replica?: false) case tenant do nil -> @@ -160,7 +155,7 @@ defmodule RealtimeWeb.TenantController do end tenant -> - with {:ok, %Tenant{} = tenant} <- Api.update_tenant(tenant, tenant_params) do + with {:ok, %Tenant{} = tenant} <- Api.update_tenant_by_external_id(tenant.external_id, tenant_params) do conn |> put_status(:ok) |> put_resp_header("location", Routes.tenant_path(conn, :show, tenant)) @@ -192,16 +187,16 @@ defmodule RealtimeWeb.TenantController do def delete(conn, %{"tenant_id" => tenant_id}) do stop_all_timeout = Enum.count(PostgresCdc.available_drivers()) * 1_000 - with %Tenant{} = tenant <- Api.get_tenant_by_external_id(tenant_id, :primary), - _ <- Tenants.suspend_tenant_by_external_id(tenant_id), + with %Tenant{} = tenant <- Api.get_tenant_by_external_id(tenant_id, use_replica: false), + _ <- RealtimeWeb.UserSocket.disconnect(tenant_id), true <- Api.delete_tenant_by_external_id(tenant_id), - true <- Cache.distributed_invalidate_tenant_cache(tenant_id), + :ok <- Cache.distributed_invalidate_tenant_cache(tenant_id), + :ok <- Connect.shutdown(tenant_id), :ok <- PostgresCdc.stop_all(tenant, stop_all_timeout), :ok <- Database.replication_slot_teardown(tenant) do send_resp(conn, 204, "") else nil -> - log_error("TenantNotFound", "Tenant not found") send_resp(conn, 204, "") err -> @@ -231,18 +226,45 @@ defmodule RealtimeWeb.TenantController do ) def reload(conn, %{"tenant_id" => tenant_id}) do - case Tenants.get_tenant_by_external_id(tenant_id) do + case Api.get_tenant_by_external_id(tenant_id, use_replica?: false) do nil -> - log_error("TenantNotFound", "Tenant not found") - - conn - |> put_status(404) - |> render("not_found.json", tenant: nil) + {:error, :not_found} tenant -> PostgresCdc.stop_all(tenant, @stop_timeout) Connect.shutdown(tenant.external_id) - SocketDisconnect.disconnect(tenant.external_id) + UserSocket.disconnect(tenant.external_id) + send_resp(conn, 204, "") + end + end + + operation(:shutdown, + summary: "Shutdowns the Connect module for a tenant", + parameters: [ + token: [ + in: :header, + name: "Authorization", + schema: %OpenApiSpex.Schema{type: :string}, + required: true, + example: + "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2ODAxNjIxNTR9.U9orU6YYqXAtpF8uAiw6MS553tm4XxRzxOhz2IwDhpY" + ], + tenant_id: [in: :path, description: "Tenant ID", type: :string] + ], + responses: %{ + 204 => EmptyResponse.response(), + 403 => EmptyResponse.response(), + 404 => NotFoundResponse.response() + } + ) + + def shutdown(conn, %{"tenant_id" => tenant_id}) do + case Api.get_tenant_by_external_id(tenant_id, use_replica?: false) do + nil -> + {:error, :not_found} + + tenant -> + Connect.shutdown(tenant.external_id) send_resp(conn, 204, "") end end @@ -269,18 +291,9 @@ defmodule RealtimeWeb.TenantController do def health(conn, %{"tenant_id" => tenant_id}) do case Tenants.health_check(tenant_id) do - {:ok, response} -> - json(conn, %{data: response}) - - {:error, %{healthy: false} = response} -> - json(conn, %{data: response}) - - {:error, :tenant_not_found} -> - log_error("TenantNotFound", "Tenant not found") - - conn - |> put_status(404) - |> render("not_found.json", tenant: nil) + {:ok, response} -> json(conn, %{data: response}) + {:error, %{healthy: false} = response} -> json(conn, %{data: response}) + {:error, :tenant_not_found} -> {:error, :not_found} end end diff --git a/lib/realtime_web/dashboard/feature_flags.ex b/lib/realtime_web/dashboard/feature_flags.ex new file mode 100644 index 000000000..bd37b22f2 --- /dev/null +++ b/lib/realtime_web/dashboard/feature_flags.ex @@ -0,0 +1,221 @@ +defmodule RealtimeWeb.Dashboard.FeatureFlags do + @moduledoc """ + Phoenix LiveDashboard page for managing feature flags. + + Provides a UI to create, toggle, and delete global feature flags, and to + search for a tenant and override the flag value for that specific tenant. + """ + + use Phoenix.LiveDashboard.PageBuilder + + alias Realtime.Api + alias Realtime.FeatureFlags + alias Realtime.Tenants.Cache, as: TenantsCache + + @impl true + def menu_link(_, _), do: {:ok, "Feature Flags"} + + @impl true + def mount(_params, _, socket) do + {:ok, reset_tenant_state(assign(socket, flags: Api.list_feature_flags()))} + end + + @impl true + def handle_event("toggle", %{"id" => id}, socket) do + flag = Enum.find(socket.assigns.flags, &(&1.id == id)) + + case Api.upsert_feature_flag(%{name: flag.name, enabled: !flag.enabled}) do + {:ok, updated} -> + flags = Enum.map(socket.assigns.flags, fn f -> if f.id == id, do: updated, else: f end) + {:noreply, assign(socket, flags: flags)} + + {:error, _} -> + {:noreply, socket} + end + end + + @impl true + def handle_event("create", %{"name" => name}, socket) when name != "" do + case Api.upsert_feature_flag(%{name: String.trim(name), enabled: false}) do + {:ok, flag} -> + flags = Enum.sort_by([flag | socket.assigns.flags], & &1.name) + {:noreply, assign(socket, flags: flags)} + + {:error, _} -> + {:noreply, socket} + end + end + + @impl true + def handle_event("create", _params, socket), do: {:noreply, socket} + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + flag = Enum.find(socket.assigns.flags, &(&1.id == id)) + + case Api.delete_feature_flag(flag) do + {:ok, _} -> + {:noreply, assign(socket, flags: Enum.reject(socket.assigns.flags, &(&1.id == id)))} + + {:error, _} -> + {:noreply, socket} + end + end + + @impl true + def handle_event("open_tenant_manager", %{"id" => id}, socket) do + {:noreply, reset_tenant_state(socket, managing_id: id)} + end + + @impl true + def handle_event("close_tenant_manager", _params, socket) do + {:noreply, reset_tenant_state(socket)} + end + + @impl true + def handle_event("search_tenant", %{"tenant_id" => tenant_id}, socket) do + case TenantsCache.get_tenant_by_external_id(String.trim(tenant_id)) do + nil -> + {:noreply, assign(socket, found_tenant: nil, tenant_error: "Tenant not found", tenant_search: tenant_id)} + + tenant -> + {:noreply, assign(socket, found_tenant: tenant, tenant_error: nil, tenant_search: tenant_id)} + end + end + + @impl true + def handle_event("set_tenant_flag", %{"flag_name" => flag_name, "enabled" => enabled}, socket) do + tenant = socket.assigns.found_tenant + + case FeatureFlags.set_tenant_flag(flag_name, tenant.external_id, enabled == "true") do + {:ok, updated_tenant} -> + {:noreply, assign(socket, found_tenant: updated_tenant)} + + {:error, _} -> + {:noreply, assign(socket, tenant_error: "Failed to update tenant flag")} + end + end + + defp reset_tenant_state(socket, extra \\ []) do + assign(socket, [managing_id: nil, tenant_search: "", found_tenant: nil, tenant_error: nil] ++ extra) + end + + @impl true + def render(assigns) do + ~H""" +
+
Feature Flags
+ +
+ + +
+ + + + + + + + + + + <%= for flag <- @flags do %> + + + + + + <%= if @managing_id == flag.id do %> + + + + <% end %> + <% end %> + +
NameStatusActions
<%= flag.name %> +
+ + + <%= if flag.enabled, do: "Enabled", else: "Disabled" %> + +
+
+
+ + +
+
+
+ Tenant flag: <%= flag.name %> + +
+ +
+ + +
+ + <%= if @tenant_error do %> +

<%= @tenant_error %>

+ <% end %> + + <%= if @found_tenant do %> + <% flag_enabled = Map.get(@found_tenant.feature_flags, flag.name, flag.enabled) %> +
+ <%= @found_tenant.external_id %> + +
+ + + <%= if flag_enabled, do: "Enabled", else: "Disabled" %> + +
+
+ <% end %> +
+
+ """ + end +end diff --git a/lib/realtime_web/dashboard/node_info.ex b/lib/realtime_web/dashboard/node_info.ex new file mode 100644 index 000000000..f9cc6b9c5 --- /dev/null +++ b/lib/realtime_web/dashboard/node_info.ex @@ -0,0 +1,111 @@ +defmodule RealtimeWeb.Dashboard.NodeInfo do + @moduledoc """ + Live Dashboard page showing Realtime-specific information about all nodes in the cluster. + + Provides region and read-replica routing per node — + context the built-in Home page and node picker do not expose. + """ + use Phoenix.LiveDashboard.PageBuilder + + @impl true + def menu_link(_, _), do: {:ok, "Node Info"} + + @impl true + def mount(_params, _session, socket) do + nodes = collect_all_nodes() + {:ok, assign(socket, nodes: nodes)} + end + + @impl true + def handle_event("refresh", _params, socket) do + {:noreply, assign(socket, nodes: collect_all_nodes())} + end + + @impl true + def render(assigns) do + ~H""" +
+
+
Node Info
+ +
+

+ Region details for every node in the cluster. + Use this alongside the node picker (top-right) to identify which node to inspect. +

+ + <%= for node_data <- @nodes do %> +
+
+ <%= node_data.name %> +
+ <%= if node_data.current do %> + current + <% end %> + <%= if node_data.error do %> + unreachable + <% else %> + connected + <% end %> +
+
+ <%= if node_data.error do %> +
<%= node_data.error %>
+ <% else %> +
+ + + + + + + + +
Realtime
Region<%= node_data.region || "not set" %>
Master Region<%= node_data.master_region || "not set" %>
Is Master<%= node_data.is_master %>
Read Replica<%= node_data.read_replica %>
+
+ <% end %> +
+ <% end %> +
+ """ + end + + defp collect_all_nodes do + current = node() + all = [current | Node.list()] + Enum.map(all, &fetch_node_data(&1, &1 == current)) + end + + defp fetch_node_data(node_name, is_current) do + base = %{name: node_name, current: is_current, error: nil} + + result = + if is_current do + {:ok, gather_local_info()} + else + case :rpc.call(node_name, __MODULE__, :gather_local_info, [], 5_000) do + {:badrpc, reason} -> {:error, "RPC failed: #{inspect(reason)}"} + info -> {:ok, info} + end + end + + case result do + {:ok, info} -> Map.merge(base, info) + {:error, msg} -> Map.put(base, :error, msg) + end + end + + def gather_local_info do + region = Application.get_env(:realtime, :region) + master_region = Application.get_env(:realtime, :master_region) || region + replica_module = Realtime.Repo.Replica.replica() + replica_host = Application.get_env(:realtime, replica_module, [])[:hostname] + + %{ + region: region, + master_region: master_region, + is_master: region == master_region, + read_replica: replica_host || "not set" + } + end +end diff --git a/lib/realtime_web/dashboard/process_dump.ex b/lib/realtime_web/dashboard/process_dump.ex index d29bd2069..f64161aea 100644 --- a/lib/realtime_web/dashboard/process_dump.ex +++ b/lib/realtime_web/dashboard/process_dump.ex @@ -1,4 +1,4 @@ -defmodule Realtime.Dashboard.ProcessDump do +defmodule RealtimeWeb.Dashboard.ProcessDump do @moduledoc """ Live Dashboard page to dump the current processes tree """ diff --git a/lib/realtime_web/dashboard/recon_trace.ex b/lib/realtime_web/dashboard/recon_trace.ex new file mode 100644 index 000000000..82cad941f --- /dev/null +++ b/lib/realtime_web/dashboard/recon_trace.ex @@ -0,0 +1,808 @@ +defmodule RealtimeWeb.Dashboard.ReconTrace do + @moduledoc false + use Phoenix.LiveDashboard.PageBuilder + + alias Phoenix.LiveView.JS + + @impl true + def menu_link(_, _), do: {:ok, "Recon Trace"} + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign( + tracing: false, + collector_pid: nil, + mod: "", + fun: "_", + arity: "_", + max_calls: "100", + scope: "local", + entry_count: 0, + entries: [], + sort_by: nil, + error: nil, + mod_suggestions: [], + module_functions: [], + fun_suggestions: [], + arity_options: [] + ) + |> stream(:entries, [])} + end + + def terminate(_reason, socket) do + if socket.assigns.tracing, do: do_stop(socket.assigns.collector_pid) + :ok + end + + @impl true + def handle_event("start", params, socket) do + case parse_and_start(params, self()) do + {:ok, collector_pid} -> + {:noreply, + socket + |> assign( + tracing: true, + collector_pid: collector_pid, + entry_count: 0, + entries: [], + sort_by: nil, + error: nil + ) + |> stream(:entries, [], reset: true)} + + {:error, reason} -> + {:noreply, assign(socket, error: reason)} + end + end + + def handle_event("stop", _params, socket) do + do_stop(socket.assigns.collector_pid) + {:noreply, assign(socket, tracing: false, collector_pid: nil)} + end + + def handle_event("clear", _params, socket) do + {:noreply, + socket + |> assign(entry_count: 0, entries: [], sort_by: nil) + |> stream(:entries, [], reset: true)} + end + + def handle_event("sort_by", %{"field" => field}, socket) do + sort_by = if field == "none", do: nil, else: String.to_existing_atom(field) + sorted = sort_entries(socket.assigns.entries, sort_by) + + {:noreply, + socket + |> assign(sort_by: sort_by) + |> stream(:entries, sorted, reset: true)} + end + + def handle_event("field_changed", %{"_target" => ["mod"], "mod" => val} = params, socket) do + suggestions = + if String.length(val) >= 2 do + downcased = String.downcase(val) + + :code.all_loaded() + |> Enum.flat_map(fn + {mod, _} when is_atom(mod) -> [Atom.to_string(mod)] + _ -> [] + end) + |> Enum.filter(&String.contains?(String.downcase(&1), downcased)) + |> Enum.sort() + |> Enum.take(15) + else + [] + end + + {:noreply, + assign(socket, + mod: val, + mod_suggestions: suggestions, + fun: Map.get(params, "fun", "_"), + arity: Map.get(params, "arity", "_"), + module_functions: [], + fun_suggestions: [], + arity_options: [] + )} + end + + def handle_event("field_changed", %{"_target" => ["fun"], "fun" => val} = params, socket) do + suggestions = + if val != "" && socket.assigns.module_functions != [] do + downcased = String.downcase(val) + + socket.assigns.module_functions + |> Enum.map(fn {name, _arity} -> Atom.to_string(name) end) + |> Enum.uniq() + |> Enum.filter(&String.contains?(String.downcase(&1), downcased)) + |> Enum.sort() + |> Enum.take(15) + else + [] + end + + {:noreply, + assign(socket, + fun: val, + fun_suggestions: suggestions, + mod: Map.get(params, "mod", socket.assigns.mod), + arity: "_", + arity_options: [] + )} + end + + def handle_event("field_changed", %{"_target" => ["arity"], "arity" => val} = params, socket) do + {:noreply, + assign(socket, + arity: val, + mod: Map.get(params, "mod", socket.assigns.mod), + fun: Map.get(params, "fun", socket.assigns.fun) + )} + end + + def handle_event("field_changed", params, socket) do + {:noreply, + assign(socket, + mod: Map.get(params, "mod", socket.assigns.mod), + fun: Map.get(params, "fun", socket.assigns.fun), + arity: Map.get(params, "arity", socket.assigns.arity), + max_calls: Map.get(params, "max_calls", socket.assigns.max_calls), + scope: Map.get(params, "scope", socket.assigns.scope) + )} + end + + def handle_event("select_mod", %{"mod" => mod_name}, socket) do + display_name = + case mod_name do + "Elixir." <> rest -> rest + other -> ":" <> other + end + + module_functions = load_module_functions(display_name) + + {:noreply, + assign(socket, + mod: display_name, + mod_suggestions: [], + module_functions: module_functions, + fun: "_", + arity: "_", + fun_suggestions: [], + arity_options: [] + )} + end + + def handle_event("select_fun", %{"fun" => fun_name}, socket) do + fun_atom = String.to_atom(fun_name) + + arity_options = + socket.assigns.module_functions + |> Enum.filter(fn {name, _} -> name == fun_atom end) + |> Enum.map(fn {_, arity} -> arity end) + |> Enum.uniq() + |> Enum.sort() + + {:noreply, + assign(socket, + fun: fun_name, + fun_suggestions: [], + arity_options: arity_options, + arity: "_" + )} + end + + @impl true + def handle_info({:raw_trace_call, pid, mod, fun, args, ts, proc_info}, socket) do + entry = build_entry(pid, mod, fun, args, ts, proc_info) + entries = [entry | socket.assigns.entries] + + {:noreply, + socket + |> assign(entries: entries, entry_count: socket.assigns.entry_count + 1) + |> stream_insert(:entries, entry, at: 0)} + end + + def handle_info({:raw_trace_return, pid, mod, fun, arity, return_val, return_ts}, socket) do + pid_str = pid_to_string(pid) + mod_str = mod_to_string(mod) + + case Enum.find(socket.assigns.entries, fn e -> + e.pid == pid_str and e.mod == mod_str and e.fun == fun and e.arity == arity and e.status == :calling + end) do + nil -> + {:noreply, socket} + + entry -> + duration_us = System.convert_time_unit(return_ts - entry.called_at, :native, :microsecond) + + return_value = + try do + format_value(return_val) + rescue + _ -> {:scalar, "error", "(failed to format return value)"} + end + + updated = %{entry | return_value: return_value, duration_us: duration_us, status: :returned} + entries = Enum.map(socket.assigns.entries, fn e -> if e.id == updated.id, do: updated, else: e end) + + {:noreply, + socket + |> assign(entries: entries) + |> stream_insert(:entries, updated)} + end + end + + def handle_info(_msg, socket), do: {:noreply, socket} + + @impl true + def render(assigns) do + ~H""" +
+
Recon Trace
+

+ Traces function calls on the current node using :recon_trace. + Stopping — or navigating away — always clears all trace flags + to restore production to a clean state. +

+ + <%= if @error do %> +
<%= @error %>
+ <% end %> + +
+
+ + + <%= if @mod_suggestions != [] do %> +
    + <%= for mod_name <- @mod_suggestions do %> + + <% end %> +
+ <% end %> +
+
+ + + <%= if @fun_suggestions != [] do %> +
    + <%= for fun_name <- @fun_suggestions do %> + + <% end %> +
+ <% end %> +
+
+ + <%= if @arity_options != [] do %> + + <% else %> + + <% end %> +
+
+ + +
+
+ + +
+
+ <%= if @tracing do %> + + <% else %> + + <% end %> + <%= if @entry_count > 0 do %> + + <% end %> +
+
+ + <%= if @tracing do %> +
+ Tracing active — rate-limited to <%= @max_calls %> calls. + Navigate away or click Stop to clear all trace flags. +
+ <% end %> + +
+ <%= @entry_count %> call(s) captured +
+ Sort by +
+ <%= for {label, field} <- [{"Time", "none"}, {"Memory", "memory"}, {"Reductions", "reductions"}, {"Msg Queue", "message_queue_len"}, {"Binary Mem", "binary_memory"}] do %> + <% active = to_string(@sort_by) == field || (field == "none" && is_nil(@sort_by)) %> + + <% end %> +
+
+
+ +
+ <%= for {dom_id, entry} <- @streams.entries do %> +
+
+
+
+
+ <%= entry.pid %> + <%= entry.mod %>.<%= entry.fun %>/<%= entry.arity %> + <%= if entry.status == :calling do %> + calling… + <% else %> + <%= entry.duration_us %>µs + <% end %> +
+
+
+ + <%= entry.timestamp %> +
+ <%= if entry.memory do %> +
+ + memory + <%= format_bytes(entry.memory) %> +
+
+ + reductions + <%= entry.reductions %> +
+
+ + msg queue + <%= entry.message_queue_len %> +
+
+ + binary mem + <%= format_bytes(entry.binary_memory) %> +
+ <% end %> +
+
+ +
+
+ +
+ <% end %> +
+
+ """ + end + + @badge_style "font-size: 0.6rem; background: #e0e7ff; color: #4338ca; border-radius: 3px; padding: 0 5px; font-weight: 700; white-space: nowrap;" + @key_style "color: #7c3aed; font-size: 0.78rem; font-family: monospace; flex-shrink: 0;" + @indent_style "padding-left: 12px; border-left: 2px solid #e5e7eb; margin-top: 4px; display: none;" + @row_style "display: flex; gap: 8px; align-items: flex-start; padding: 3px 0; border-bottom: 1px solid #f3f4f6;" + @toggle_btn "display: inline-flex; align-items: center; gap: 6px; background: #f1f5f9; border: 1px solid #cbd5e1; border-radius: 5px; padding: 2px 8px 2px 6px; cursor: pointer; font-size: 0.75rem; color: #334155; font-weight: 500;" + @chevron {:safe, + ""} + + defp tree_assigns(assigns, extra) do + assign( + assigns, + [ + badge: @badge_style, + key: @key_style, + indent: @indent_style, + row: @row_style, + btn: @toggle_btn, + chevron: @chevron + ] ++ extra + ) + end + + defp render_value(assigns, {:struct, cid, name, fields}) do + assigns = tree_assigns(assigns, cid: cid, name: name, fields: fields) + + ~H""" +
+ +
+ <%= for {k, v} <- @fields do %> +
+ <%= k %> +
<%= render_value(assigns, v) %>
+
+ <% end %> +
+
+ """ + end + + defp render_value(assigns, {:map, cid, fields}) do + assigns = tree_assigns(assigns, cid: cid, fields: fields) + + ~H""" +
+ +
+ <%= for {k, v} <- @fields do %> +
+ <%= k %> +
<%= render_value(assigns, v) %>
+
+ <% end %> +
+
+ """ + end + + defp render_value(assigns, {:list, _cid, []}) do + assigns = assign(assigns, badge: @badge_style) + ~H"list[0]" + end + + defp render_value(assigns, {:list, cid, items}) do + assigns = tree_assigns(assigns, cid: cid, items: items) + + ~H""" +
+ +
+ <%= for {item, j} <- Enum.with_index(@items) do %> +
+ [<%= j %>] +
<%= render_value(assigns, item) %>
+
+ <% end %> +
+
+ """ + end + + defp render_value(assigns, {:tuple, cid, elements}) do + assigns = tree_assigns(assigns, cid: cid, elements: elements) + + ~H""" +
+ +
+ <%= for {el, j} <- Enum.with_index(@elements) do %> +
+ <%= j %> +
<%= render_value(assigns, el) %>
+
+ <% end %> +
+
+ """ + end + + defp render_value(assigns, {:scalar, type, str}) do + assigns = assign(assigns, type: type, str: str, badge: @badge_style) + + ~H""" + + <%= @type %> + <%= @str %> + + """ + end + + defp load_module_functions(mod_str) do + case parse_module(mod_str) do + {:ok, mod} -> + if function_exported?(mod, :__info__, 1) do + mod.__info__(:functions) + else + try do + :erlang.apply(mod, :module_info, [:exports]) + rescue + _ -> [] + end + end + + _ -> + [] + end + end + + defp parse_and_start(params, parent) do + mod = Map.get(params, "mod", "") + fun = Map.get(params, "fun", "_") + max_calls = Map.get(params, "max_calls", "100") + scope = Map.get(params, "scope", "local") + + with {:ok, mod_atom} <- parse_module(mod), + {:ok, max_val} <- parse_max_calls(max_calls) do + fun_atom = parse_fun(fun) + scope_atom = if scope == "global", do: :global, else: :local + io_server = spawn_link(fn -> io_discard_loop() end) + + formatter_fun = fn + {:trace, pid, :call, {m, f, args}} -> + ts = System.monotonic_time() + proc_info = Process.info(pid, [:memory, :reductions, :message_queue_len, :binary]) + send(parent, {:raw_trace_call, pid, m, f, args, ts, proc_info}) + "" + + {:trace, pid, :return_from, {m, f, a}, return_val} -> + ts = System.monotonic_time() + send(parent, {:raw_trace_return, pid, m, f, a, return_val, ts}) + "" + + _ -> + "" + end + + try do + :recon_trace.calls( + {mod_atom, fun_atom, :return_trace}, + max_val, + formatter: formatter_fun, + io_server: io_server, + scope: scope_atom + ) + + {:ok, io_server} + rescue + e -> + Process.exit(io_server, :kill) + {:error, "trace failed: #{inspect(e)}"} + end + end + end + + defp do_stop(io_server) do + :recon_trace.clear() + if is_pid(io_server) && Process.alive?(io_server), do: Process.exit(io_server, :kill) + end + + defp io_discard_loop do + receive do + {:io_request, from, ref, _} -> send(from, {:io_reply, ref, :ok}) + _ -> :ok + end + + io_discard_loop() + end + + defp build_entry(pid, mod, fun, args, ts, proc_info) do + proc = + case proc_info do + nil -> + %{memory: nil, reductions: nil, message_queue_len: nil, binary_memory: nil} + + info -> + binary_memory = Enum.reduce(info[:binary], 0, fn {_, size, _}, acc -> acc + size end) + + %{ + memory: info[:memory], + reductions: info[:reductions], + message_queue_len: info[:message_queue_len], + binary_memory: binary_memory + } + end + + %{ + id: System.unique_integer([:monotonic, :positive]), + pid: pid_to_string(pid), + mod: mod_to_string(mod), + fun: fun, + arity: length(args), + args: Enum.map(args, &format_value/1), + called_at: ts, + timestamp: Time.utc_now() |> Time.truncate(:millisecond) |> Time.to_string(), + duration_us: nil, + return_value: nil, + status: :calling, + memory: proc.memory, + reductions: proc.reductions, + message_queue_len: proc.message_queue_len, + binary_memory: proc.binary_memory + } + end + + defp pid_to_string(pid), do: pid |> inspect() |> String.replace("#PID", "") + defp mod_to_string(mod), do: mod |> Atom.to_string() |> String.replace_prefix("Elixir.", "") + + defp format_value(val) when is_struct(val) do + name = mod_to_string(val.__struct__) + + fields = + try do + val + |> Map.to_list() + |> Enum.reject(fn {k, _} -> k == :__struct__ end) + |> Enum.map(fn {k, v} -> {Atom.to_string(k), format_value(v)} end) + rescue + _ -> [{"(error)", {:scalar, "error", "failed to inspect struct fields"}}] + end + + {:struct, System.unique_integer([:positive]), name, fields} + end + + defp format_value(val) when is_map(val) do + fields = + try do + Enum.map(val, fn {k, v} -> {inspect(k), format_value(v)} end) + rescue + _ -> [{"(error)", {:scalar, "error", "failed to inspect map"}}] + end + + {:map, System.unique_integer([:positive]), fields} + end + + defp format_value(val) when is_list(val) do + try do + {:list, System.unique_integer([:positive]), Enum.map(val, &format_value/1)} + rescue + _ -> {:scalar, "list", inspect(val, limit: 30)} + end + end + + defp format_value(val) when is_tuple(val) do + {:tuple, System.unique_integer([:positive]), Enum.map(Tuple.to_list(val), &format_value/1)} + end + + defp format_value(nil), do: {:scalar, "nil", "nil"} + defp format_value(true), do: {:scalar, "boolean", "true"} + defp format_value(false), do: {:scalar, "boolean", "false"} + defp format_value(val) when is_atom(val), do: {:scalar, "atom", inspect(val)} + defp format_value(val) when is_integer(val), do: {:scalar, "integer", Integer.to_string(val)} + defp format_value(val) when is_float(val), do: {:scalar, "float", inspect(val)} + + defp format_value(val) when is_binary(val) do + if String.printable?(val) do + {:scalar, "string", inspect(val, printable_limit: 300)} + else + {:scalar, "binary", inspect(val, limit: 30)} + end + end + + defp format_value(val) when is_pid(val), do: {:scalar, "pid", inspect(val)} + defp format_value(val) when is_reference(val), do: {:scalar, "reference", inspect(val)} + defp format_value(val) when is_port(val), do: {:scalar, "port", inspect(val)} + defp format_value(val) when is_function(val), do: {:scalar, "function", inspect(val)} + defp format_value(val), do: {:scalar, "term", inspect(val, pretty: true, limit: 30, printable_limit: 300)} + + defp sort_entries(entries, nil), do: entries + + defp sort_entries(entries, field) do + Enum.sort(entries, fn a, b -> + case {a[field], b[field]} do + {nil, _} -> false + {_, nil} -> true + {va, vb} -> va >= vb + end + end) + end + + defp parse_module(""), do: {:error, "Module is required"} + + defp parse_module(":" <> erlang_mod) do + try do + {:ok, String.to_existing_atom(erlang_mod)} + rescue + _ -> {:error, "Unknown Erlang module: :#{erlang_mod}"} + end + end + + defp parse_module(elixir_mod) do + try do + {:ok, String.to_existing_atom("Elixir." <> elixir_mod)} + rescue + _ -> + try do + {:ok, String.to_existing_atom(elixir_mod)} + rescue + _ -> {:error, "Unknown module: #{elixir_mod}. Make sure it is loaded."} + end + end + end + + defp parse_fun("_"), do: :_ + defp parse_fun(""), do: :_ + + defp parse_fun(f) do + try do + String.to_existing_atom(f) + rescue + _ -> {:error, "Unknown function: #{f}"} + end + end + + defp parse_max_calls(m) do + case Integer.parse(m) do + {n, ""} when n > 0 -> {:ok, n} + _ -> {:error, "Max calls must be a positive integer (e.g. 100)"} + end + end + + defp format_bytes(nil), do: "—" + defp format_bytes(b) when b >= 1_048_576, do: "#{Float.round(b / 1_048_576, 1)}MB" + defp format_bytes(b) when b >= 1_024, do: "#{Float.round(b / 1_024, 1)}KB" + defp format_bytes(b), do: "#{b}B" +end diff --git a/lib/realtime_web/dashboard/sql_inspector.ex b/lib/realtime_web/dashboard/sql_inspector.ex new file mode 100644 index 000000000..b18ee2f56 --- /dev/null +++ b/lib/realtime_web/dashboard/sql_inspector.ex @@ -0,0 +1,237 @@ +defmodule RealtimeWeb.Dashboard.SqlInspector do + @moduledoc """ + Live Dashboard page for running read-only SQL queries against the Realtime database. + + Queries are executed inside a transaction that is always rolled back, with + `SET TRANSACTION READ ONLY` enforced at the database level. Column values whose + names suggest sensitive data (passwords, secrets, tokens, keys, etc.) are + replaced with "***" before display. + """ + use Phoenix.LiveDashboard.PageBuilder + + require Logger + + alias Realtime.Repo.Replica + + @query_timeout 30_000 + @max_rows 1_000 + + @sensitive_patterns ~w(password passwd secret token jwt key credential private salt hash) + + @impl true + def menu_link(_, _), do: {:ok, "SQL Inspector"} + + @impl true + def mount(_params, _session, socket) do + {:ok, + assign(socket, + sql: "", + result: nil, + error: nil, + max_rows: @max_rows, + sort_col: nil, + sort_dir: :asc, + display_rows: [], + row_count: 0 + )} + end + + @impl true + def handle_event("run_query", %{"sql" => sql}, socket) do + sql = String.trim(sql) + + case execute_read_only(sql) do + {:ok, result} -> + {:noreply, + assign(socket, + result: result, + error: nil, + sql: sql, + sort_col: nil, + sort_dir: :asc, + display_rows: result.rows, + row_count: length(result.rows) + )} + + {:error, msg} -> + Logger.warning("SqlInspector query error: #{msg}") + {:noreply, assign(socket, error: msg, result: nil, sql: sql, display_rows: [], row_count: 0)} + end + end + + @impl true + def handle_event("sort", %{"col" => col}, socket) do + %{result: result, sort_col: current_col, sort_dir: current_dir} = socket.assigns + sort_dir = if current_col == col and current_dir == :asc, do: :desc, else: :asc + col_idx = Enum.find_index(result.columns, &(&1 == col)) + + display_rows = + Enum.sort_by(result.rows, &Enum.at(&1, col_idx), fn a, b -> + cmp = compare_cells(a, b) + if sort_dir == :asc, do: cmp != :gt, else: cmp != :lt + end) + + {:noreply, assign(socket, sort_col: col, sort_dir: sort_dir, display_rows: display_rows)} + end + + @impl true + def render(assigns) do + ~H""" +
+
SQL Inspector
+

+ Read-only SELECT queries only. Sensitive column values are masked. Results capped at <%= @max_rows %> rows. +

+ +
+
+ +
+
+ + Tip: Cmd/Ctrl + Enter to run +
+
+ + <%= if @error do %> +
+ Error: <%= @error %> +
+ <% end %> + + <%= if @result do %> +
+ <%= if @result.rows == [] do %> +

Query returned 0 rows.

+ <% else %> +

+ <%= @row_count %> row(s) returned + <%= if @row_count == @max_rows do %> + (results truncated at <%= @max_rows %>) + <% end %> +

+
+ + + + <%= for col <- @result.columns do %> + + <% end %> + + + + <%= for {row, row_idx} <- Enum.with_index(@display_rows) do %> + + <%= for cell <- row do %> + + <% end %> + + <% end %> + +
+ <%= col %> + <%= cond do %> + <% @sort_col == col and @sort_dir == :asc -> %> ↑ + <% @sort_col == col and @sort_dir == :desc -> %> ↓ + <% true -> %> + <% end %> +
+ <%= cell %> +
+
+ <% end %> +
+ <% end %> +
+ """ + end + + defp execute_read_only(sql) do + with :ok <- validate_select_only(sql) do + stripped = String.trim_trailing(sql, ";") + limited_sql = "SELECT * FROM (#{stripped}) AS _q LIMIT #{@max_rows}" + repo = Replica.replica() + + repo.transaction( + fn -> + Ecto.Adapters.SQL.query!(repo, "SET TRANSACTION READ ONLY", []) + + case Ecto.Adapters.SQL.query(repo, limited_sql, [], timeout: @query_timeout) do + {:ok, result} -> repo.rollback({:ok, mask_sensitive_columns(result)}) + {:error, %{postgres: %{message: message}}} -> repo.rollback({:error, message}) + {:error, reason} -> repo.rollback({:error, inspect(reason)}) + end + end, + timeout: @query_timeout + ) + |> case do + {:error, {:ok, result}} -> {:ok, result} + {:error, {:error, message}} -> {:error, message} + end + end + end + + defp validate_select_only(sql) do + normalized = String.downcase(sql) + + cond do + normalized == "" -> + {:error, "Query cannot be empty"} + + not (String.starts_with?(normalized, "select") or String.starts_with?(normalized, "with")) -> + {:error, "Only SELECT queries are allowed (may start with WITH for CTEs)"} + + true -> + :ok + end + end + + defp mask_sensitive_columns(%{columns: columns, rows: rows} = result) do + sensitive_indices = + columns + |> Enum.with_index() + |> Enum.filter(fn {col, _} -> sensitive_column?(col) end) + |> Enum.into(MapSet.new(), fn {_, idx} -> idx end) + + masked_rows = + Enum.map(rows, fn row -> + Enum.map(Enum.with_index(row), fn {val, idx} -> + if MapSet.member?(sensitive_indices, idx), do: "***", else: format_cell(val) + end) + end) + + %{result | rows: masked_rows} + end + + defp sensitive_column?(name) do + lower = String.downcase(name) + Enum.any?(@sensitive_patterns, &String.contains?(lower, &1)) + end + + defp compare_cells("NULL", "NULL"), do: :eq + defp compare_cells("NULL", _), do: :gt + defp compare_cells(_, "NULL"), do: :lt + defp compare_cells(a, b) when a < b, do: :lt + defp compare_cells(a, b) when a > b, do: :gt + defp compare_cells(_, _), do: :eq + + defp format_cell(nil), do: "NULL" + defp format_cell(%NaiveDateTime{} = val), do: NaiveDateTime.to_string(val) + defp format_cell(%DateTime{} = val), do: DateTime.to_string(val) + defp format_cell(%Date{} = val), do: Date.to_string(val) + defp format_cell(%Time{} = val), do: Time.to_string(val) + defp format_cell(val) when is_binary(val), do: if(String.valid?(val), do: val, else: Base.encode16(val, case: :lower)) + defp format_cell(val), do: inspect(val) +end diff --git a/lib/realtime_web/dashboard/tenant_info.ex b/lib/realtime_web/dashboard/tenant_info.ex new file mode 100644 index 000000000..eddb20a07 --- /dev/null +++ b/lib/realtime_web/dashboard/tenant_info.ex @@ -0,0 +1,200 @@ +defmodule RealtimeWeb.Dashboard.TenantInfo do + @moduledoc """ + Live Dashboard page to inspect tenant and extension information by external_id. + Secrets (jwt_secret and encrypted extension fields) are never displayed. + """ + use Phoenix.LiveDashboard.PageBuilder + use Realtime.Logs + + alias Realtime.Api + alias Realtime.Api.Tenant + alias Realtime.Crypto + alias Realtime.Database + + @application_name "realtime_dashboard_tenant_info" + + @impl true + def menu_link(_, _), do: {:ok, "Tenant Info"} + + @impl true + def mount(_params, _, socket) do + {:ok, assign(socket, external_id: "", tenant: nil, pg_version: nil, error: nil)} + end + + @impl true + def handle_params(%{"external_id" => ref}, _uri, socket) when ref != "" do + ref = String.trim(ref) + + case Api.get_tenant_by_external_id(ref) do + nil -> + {:noreply, assign(socket, external_id: ref, tenant: nil, pg_version: nil, error: "Tenant not found")} + + %Tenant{} = tenant -> + {:noreply, + assign(socket, + external_id: ref, + tenant: prepare_tenant(tenant), + pg_version: fetch_pg_version(tenant), + error: nil + )} + end + end + + def handle_params(_params, _uri, socket) do + {:noreply, assign(socket, external_id: "", tenant: nil, pg_version: nil, error: nil)} + end + + @impl true + def handle_event("lookup", %{"external_id" => ref}, socket) do + ref = String.trim(ref) + {:noreply, push_patch(socket, to: "/admin/dashboard/tenant_info?external_id=#{URI.encode(ref)}")} + end + + @impl true + def render(assigns) do + ~H""" +
+
Tenant Info
+ +
+ + +
+ + <%= if @error do %> +

<%= @error %>

+ <% end %> + + <%= if @tenant do %> +
Tenant
+ + + + + + + + + + + + + + + + + + + + + + +
external_id<%= @tenant.external_id %>
name<%= @tenant.name %>
suspend<%= @tenant.suspend %>
private_only<%= @tenant.private_only %>
presence_enabled<%= @tenant.presence_enabled %>
postgres_cdc_default<%= @tenant.postgres_cdc_default %>
broadcast_adapter<%= @tenant.broadcast_adapter %>
max_concurrent_users<%= @tenant.max_concurrent_users %>
max_events_per_second<%= @tenant.max_events_per_second %>
max_bytes_per_second<%= @tenant.max_bytes_per_second %>
max_channels_per_client<%= @tenant.max_channels_per_client %>
max_joins_per_second<%= @tenant.max_joins_per_second %>
max_presence_events_per_second<%= @tenant.max_presence_events_per_second %>
max_payload_size_in_kb<%= @tenant.max_payload_size_in_kb %>
max_client_presence_events_per_window<%= @tenant.max_client_presence_events_per_window %>
client_presence_window_ms<%= @tenant.client_presence_window_ms %>
migrations_ran<%= @tenant.migrations_ran %>
inserted_at<%= @tenant.inserted_at %>
updated_at<%= @tenant.updated_at %>
+ +
Database
+ + + + + + + +
postgres_version + <%= case @pg_version do %> + <% nil -> %> + <% {:ok, version} -> %><%= version %> + <% {:error, msg} -> %><%= msg %> + <% end %> +
+ + <%= for ext <- @tenant.extensions do %> +
Extension: <%= ext.type %>
+ + + <%= for {key, value} <- ext.settings do %> + + <% end %> + + + +
<%= key %><%= value %>
inserted_at<%= ext.inserted_at %>
updated_at<%= ext.updated_at %>
+ <% end %> + <% end %> +
+ """ + end + + @secret_settings ["db_password", "db_pass_realtime"] + @encrypted_settings ["db_host", "db_port", "db_name", "db_user", "db_user_realtime"] + + defp prepare_tenant(tenant) do + %{tenant | extensions: Enum.map(tenant.extensions, &prepare_extension/1)} + end + + defp prepare_extension(ext) do + settings = + ext.settings + |> Map.drop(@secret_settings) + |> Enum.map(fn + {key, value} when key in @encrypted_settings -> {key, Crypto.decrypt!(value)} + {key, value} -> {key, value} + end) + + resolved_host = + case List.keyfind(settings, "db_host", 0) do + {"db_host", host} -> resolve_host(host) + nil -> nil + end + + settings = + settings + |> then(fn s -> + if resolved_host, do: [{"db_host_resolved", resolved_host} | s], else: s + end) + |> Enum.sort_by(&elem(&1, 0)) + + %{ext | settings: settings} + end + + defp fetch_pg_version(%Tenant{} = tenant) do + with {:ok, settings} <- Database.from_tenant(tenant, @application_name, :stop), + {:ok, conn} <- Database.connect_db(settings), + {:ok, %{rows: [[version]]}} <- Postgrex.query(conn, "SELECT version()", []) do + {:ok, version} + else + {:error, reason} -> + log_warning("TenantInfoPgVersionFailed", reason) + {:error, "Failed to query postgres version: #{inspect(reason)}"} + end + end + + defp resolve_host(host) do + host_charlist = String.to_charlist(host) + + v4 = + case :inet.getaddrs(host_charlist, :inet) do + {:ok, ips} -> ips + _ -> [] + end + + v6 = + case :inet.getaddrs(host_charlist, :inet6) do + {:ok, ips} -> ips + _ -> [] + end + + ips = (v4 ++ v6) |> Enum.map(&:inet.ntoa/1) |> Enum.map(&to_string/1) + + case ips do + [] -> "unresolved" + _ -> Enum.join(ips, ", ") + end + end +end diff --git a/lib/realtime_web/dashboard/tenant_migrations.ex b/lib/realtime_web/dashboard/tenant_migrations.ex new file mode 100644 index 000000000..47c4a0c28 --- /dev/null +++ b/lib/realtime_web/dashboard/tenant_migrations.ex @@ -0,0 +1,468 @@ +defmodule RealtimeWeb.Dashboard.TenantMigrations do + @moduledoc """ + Live Dashboard page to inspect tenant migrations state. + + Requires `pgdelta` on `$PATH`. + + Regenerate the catalog snapshot with `mix realtime.export_tenant_db_catalog`. + """ + use Phoenix.LiveDashboard.PageBuilder + use Realtime.Logs + + alias Realtime.Api + alias Realtime.Api.Tenant + alias Realtime.Database + alias Realtime.Tenants.Migrations + + @pg_delta_filter ~s""" + { + "and": [ + {"*/schema": "realtime"}, + {"not": {"table/is_partition": true}}, + {"not": {"and": [{"objectType": "rls_policy"}, {"operation": "drop"}]}} + ] + } + """ + # Apply changes using a superuser + @application_name "realtime_migrations" + @catalog_major 17 + @query_timeout 60_000 + @schema_migrations_query "SELECT version, inserted_at FROM realtime.schema_migrations ORDER BY version DESC" + + @impl true + def menu_link(_, _), do: {:ok, "Tenant Migrations"} + + @impl true + def mount(_params, _session, socket) do + {:ok, + assign(socket, + external_id: "", + tenant: nil, + schema_migrations: nil, + pg_delta: nil, + catalog_version: nil, + error: nil + )} + end + + @impl true + def handle_params(%{"external_id" => ref}, _uri, socket) when ref != "" do + ref = String.trim(ref) + + with %Tenant{} = tenant <- Api.get_tenant_by_external_id(ref), + {:ok, settings} <- Database.from_tenant(tenant, @application_name, :stop), + {:ok, db_conn} <- Database.connect_db(settings) do + {:noreply, + assign(socket, + external_id: ref, + tenant: tenant, + schema_migrations: fetch_schema_migrations(db_conn), + pg_delta: run_pg_delta(settings), + catalog_version: @catalog_major, + error: nil + )} + else + nil -> + {:noreply, assign_error(socket, ref, "Tenant not found")} + + {:error, reason} -> + log_warning("TenantMigrationsConnectFailed", reason) + {:noreply, assign_error(socket, ref, "Failed to connect to tenant DB: #{inspect(reason)}")} + end + end + + def handle_params(_params, _uri, socket) do + {:noreply, + assign(socket, + external_id: "", + tenant: nil, + schema_migrations: nil, + pg_delta: nil, + catalog_version: nil, + error: nil + )} + end + + @impl true + def handle_event("lookup", %{"external_id" => ref}, socket) do + ref = String.trim(ref) + {:noreply, push_patch(socket, to: "/admin/dashboard/tenant_migrations?external_id=#{URI.encode(ref)}")} + end + + @impl true + def handle_event("apply_plan", _params, socket) do + %{tenant: %Tenant{} = tenant, external_id: ref, pg_delta: {:ok, %{status: :changes, sql: sql}}} = socket.assigns + + case apply_pg_delta(tenant, sql) do + :ok -> + {:noreply, push_patch(socket, to: "/admin/dashboard/tenant_migrations?external_id=#{URI.encode(ref)}")} + + {:error, msg} -> + {:noreply, assign(socket, error: msg)} + end + end + + @impl true + def handle_event("backfill_migrations", _params, socket) do + %{tenant: %Tenant{} = tenant, external_id: ref, pg_delta: {:ok, %{status: :no_changes}}} = socket.assigns + + case backfill_schema_migrations(tenant) do + :ok -> + {:noreply, push_patch(socket, to: "/admin/dashboard/tenant_migrations?external_id=#{URI.encode(ref)}")} + + {:error, msg} -> + {:noreply, assign(socket, error: msg)} + end + end + + @impl true + def render(assigns) do + ~H""" +
+
Tenant Migrations
+

+ Inspect a tenant's applied migrations and drift against the catalog schema snapshot. +

+ +
+ + +
+ + <%= if @error do %> +

<%= @error %>

+ <% end %> + + <%= if @tenant do %> +
realtime.schema_migrations
+ <%= schema_migrations(@schema_migrations) %> + +
pg-delta plan vs catalog (PG<%= @catalog_version %>)
+ <%= pg_delta_plan(@pg_delta) %> + + <%= backfill_section(@pg_delta, @schema_migrations) %> + <% end %> +
+ """ + end + + defp schema_migrations(nil) do + assigns = %{} + ~H"" + end + + defp schema_migrations({:error, msg}) do + assigns = %{msg: msg} + + ~H""" +

<%= @msg %>

+ """ + end + + defp schema_migrations({:ok, rows}) do + assigns = %{rows: rows} + + ~H""" +

<%= length(@rows) %> row(s)

+
+ + + + + + + + + <%= for {[version, inserted_at], idx} <- Enum.with_index(@rows) do %> + + + + + <% end %> + +
+ version + + inserted_at +
+ <%= version %> + + <%= inserted_at %> +
+
+ """ + end + + defp pg_delta_plan(nil) do + assigns = %{} + ~H"" + end + + defp pg_delta_plan({:error, msg}) do + assigns = %{msg: msg} + + ~H""" +
+ Error: +
<%= @msg %>
+
+ """ + end + + defp pg_delta_plan({:ok, %{status: :no_changes}}) do + assigns = %{} + + ~H""" +
+ No drift detected. Tenant schema matches the catalog. +
+ """ + end + + defp pg_delta_plan({:ok, %{status: :changes, sql: sql}}) do + assigns = %{sql: sql} + + ~H""" +
+ Drift detected between tenant and catalog. + The SQL below is reconciliation plan generated by pg-delta and it may contain errors and/or destructive statements. + Review every statement before running it. +
+
+ +
<%= @sql %>
+
+
+ +
+ """ + end + + defp backfill_section(pg_delta, schema_migrations) do + total = length(Migrations.migrations()) + + {show, applied} = + case {pg_delta, schema_migrations} do + {{:ok, %{status: :no_changes}}, {:ok, rows}} -> + applied = length(rows) + {applied < total, applied} + + _ -> + {false, nil} + end + + assigns = %{show: show, applied: applied, total: total} + + ~H""" +
+
Backfill schema_migrations
+
+ realtime.schema_migrations has <%= @applied %> of <%= @total %> versions applied and pg-delta reports no drift. + Backfill inserts the missing rows and sets tenants.migrations_ran to <%= @total %>. +
+
+ +
+
+ """ + end + + @doc false + def backfill_schema_migrations(%Tenant{external_id: external_id} = tenant) do + versions = Enum.map(Migrations.migrations(), fn {v, _mod} -> v end) + total = length(versions) + + with {:ok, settings} <- Database.from_tenant(tenant, @application_name, :stop), + {:ok, db_conn} <- Database.connect_db(settings), + :ok <- insert_versions(db_conn, versions), + {:ok, _} <- Api.update_migrations_ran(external_id, total) do + :ok + else + {:error, %{postgres: %{message: message}}} -> + log_warning("TenantMigrationsBackfillFailed", message) + {:error, "Backfill failed: #{message}"} + + {:error, reason} -> + log_warning("TenantMigrationsBackfillFailed", reason) + {:error, "Backfill failed: #{inspect(reason)}"} + end + end + + defp insert_versions(db_conn, versions) do + backfill_insert = + "INSERT INTO realtime.schema_migrations (version, inserted_at) VALUES ($1, NOW()) ON CONFLICT (version) DO NOTHING" + + Enum.reduce_while(versions, :ok, fn version, _acc -> + case Postgrex.query(db_conn, backfill_insert, [version], timeout: @query_timeout) do + {:ok, _} -> {:cont, :ok} + {:error, _} = err -> {:halt, err} + end + end) + end + + defp assign_error(socket, ref, msg) do + assign(socket, + external_id: ref, + tenant: nil, + schema_migrations: nil, + pg_delta: nil, + catalog_version: nil, + error: msg + ) + end + + defp fetch_schema_migrations(db_conn) do + case Postgrex.query(db_conn, @schema_migrations_query, [], timeout: @query_timeout) do + {:ok, %{rows: rows}} -> + {:ok, Enum.map(rows, fn [v, ts] -> [to_string(v), format_ts(ts)] end)} + + {:error, %{postgres: %{message: message}}} -> + log_warning("TenantMigrationsSchemaMigrationsQueryError", message) + {:error, message} + + {:error, reason} -> + log_warning("TenantMigrationsSchemaMigrationsQueryFailed", reason) + {:error, inspect(reason)} + end + end + + defp format_ts(%NaiveDateTime{} = t), do: NaiveDateTime.to_string(t) + defp format_ts(%DateTime{} = t), do: DateTime.to_string(t) + defp format_ts(other), do: to_string(other) + + @doc false + def postgres_url(%Database{} = db) do + sslmode = if db.ssl, do: "require", else: "disable" + + hostname = + case :inet.parse_ipv6strict_address(String.to_charlist(db.hostname)) do + {:ok, _} -> "[#{db.hostname}]" + _ -> db.hostname + end + + IO.iodata_to_binary([ + "postgresql://", + URI.encode_www_form(db.username), + ":", + URI.encode_www_form(db.password), + "@", + hostname, + ":", + Integer.to_string(db.port), + "/", + URI.encode_www_form(db.database), + "?sslmode=", + sslmode + ]) + end + + @doc false + # Used for debugging + def pg_delta_filter, do: @pg_delta_filter + + defp catalog_path do + Application.app_dir(:realtime, "priv/repo/tenant_db_catalog_#{@catalog_major}.json") + end + + defp run_pg_delta(%Database{} = settings) do + case System.find_executable("pgdelta") do + nil -> + log_warning("TenantMigrationsPgDeltaMissing", "pgdelta not found on PATH") + {:error, "pgdelta not found on PATH"} + + path -> + catalog = catalog_path() + + args = [ + "plan", + "--source", + postgres_url(settings), + "--target", + catalog, + "--filter", + pg_delta_filter(), + "--format", + "sql" + ] + + env = [ + {"PGDELTA_CONNECTION_TIMEOUT_MS", Integer.to_string(@query_timeout)}, + {"PGDELTA_CONNECT_TIMEOUT_MS", Integer.to_string(@query_timeout)} + ] + + case System.cmd(path, args, stderr_to_stdout: true, env: env) do + {output, 0} -> + {:ok, %{status: :no_changes, sql: output}} + + {output, 2} -> + {:ok, %{status: :changes, sql: output}} + + {output, code} -> + log_warning("TenantMigrationsPgDeltaNonZeroExit", "exit #{code}: #{output}") + {:error, "pg-delta exited #{code}:\n#{output}"} + end + end + end + + @doc false + def apply_pg_delta(%Tenant{external_id: external_id} = tenant, sql) do + opts = [query_type: :text, timeout: @query_timeout] + versions = Enum.map(Migrations.migrations(), fn {v, _mod} -> v end) + total = length(versions) + + with {:ok, settings} <- Database.from_tenant(tenant, @application_name, :stop), + {:ok, db_conn} <- Database.connect_db(settings), + {:ok, _} <- Postgrex.query(db_conn, sql, [], opts), + :ok <- insert_versions(db_conn, versions), + {:ok, _} <- Api.update_migrations_ran(external_id, total) do + :ok + else + {:error, %{postgres: %{message: message}}} -> + log_warning("TenantMigrationsApplyFailed", message) + {:error, "Apply failed: #{message}"} + + {:error, reason} -> + log_warning("TenantMigrationsApplyFailed", reason) + {:error, "Apply failed: #{inspect(reason)}"} + end + end +end diff --git a/lib/realtime_web/endpoint.ex b/lib/realtime_web/endpoint.ex index 917ab65b9..353146530 100644 --- a/lib/realtime_web/endpoint.ex +++ b/lib/realtime_web/endpoint.ex @@ -11,14 +11,25 @@ defmodule RealtimeWeb.Endpoint do signing_salt: "5OUq5X4H" ] + @fullsweep_after Application.compile_env!(:realtime, :websocket_fullsweep_after) + socket "/socket", RealtimeWeb.UserSocket, websocket: [ connect_info: [:peer_data, :uri, :x_headers], - fullsweep_after: 20, - max_frame_size: 8_000_000, + fullsweep_after: @fullsweep_after, + max_frame_size: 5_000_000, + # https://github.com/ninenines/cowboy/blob/24d32de931a0c985ff7939077463fc8be939f0e9/doc/src/manual/cowboy_websocket.asciidoc#L228 + # active_n: The number of packets Cowboy will request from the socket at once. + # This can be used to tweak the performance of the server. Higher values reduce + # the number of times Cowboy need to request more packets from the port driver at + # the expense of potentially higher memory being used. + active_n: 100, + # Skip validating UTF8 for faster frame processing. + # Currently all text frames are handled only with JSON which already requires UTF-8 + validate_utf8: false, serializer: [ {Phoenix.Socket.V1.JSONSerializer, "~> 1.0.0"}, - {Phoenix.Socket.V2.JSONSerializer, "~> 2.0.0"} + {RealtimeWeb.Socket.V2Serializer, "~> 2.0.0"} ] ], longpoll: [ @@ -56,10 +67,24 @@ defmodule RealtimeWeb.Endpoint do cookie_key: "request_logger" plug BaggageRequestId, baggage_key: BaggageRequestId.baggage_key() - plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Telemetry, + event_prefix: [:phoenix, :endpoint], + log: {__MODULE__, :log_level, []} + + # Disables logging for routes /healthcheck and /api/tenants/:tenant_id/health when DISABLE_HEALTHCHECK_LOGGING=true + def log_level(%{path_info: ["healthcheck"]}) do + if Application.get_env(:realtime, :disable_healthcheck_logging, false), do: false, else: :info + end + + def log_level(%{path_info: ["api", "tenants", _, "health"]}) do + if Application.get_env(:realtime, :disable_healthcheck_logging, false), do: false, else: :info + end + + def log_level(_), do: :info plug Plug.Parsers, - parsers: [:urlencoded, :multipart, :json], + parsers: [:urlencoded, :multipart, :json, RealtimeWeb.Plugs.Parsers.OctetStream], pass: ["*/*"], json_decoder: Phoenix.json_library() diff --git a/lib/realtime_web/live/feature_flags_live/index.ex b/lib/realtime_web/live/feature_flags_live/index.ex new file mode 100644 index 000000000..cdb720865 --- /dev/null +++ b/lib/realtime_web/live/feature_flags_live/index.ex @@ -0,0 +1,81 @@ +defmodule RealtimeWeb.FeatureFlagsLive.Index do + use RealtimeWeb, :live_view + + alias Realtime.Api + alias RealtimeWeb.Endpoint + + @impl true + def mount(_params, _session, socket) do + if connected?(socket), do: Endpoint.subscribe("feature_flags") + + {:ok, assign(socket, flags: Api.list_feature_flags(), new_name: "")} + end + + @impl true + def handle_params(_params, _url, socket) do + {:noreply, assign(socket, :page_title, "Feature Flags")} + end + + @impl true + def handle_event("toggle", %{"id" => id}, socket) do + flag = Enum.find(socket.assigns.flags, &(&1.id == id)) + + case Api.upsert_feature_flag(%{name: flag.name, enabled: !flag.enabled}) do + {:ok, updated} -> + Endpoint.broadcast_from(self(), "feature_flags", "updated", updated) + flags = Enum.map(socket.assigns.flags, fn f -> if f.id == id, do: updated, else: f end) + {:noreply, assign(socket, flags: flags)} + + {:error, _} -> + {:noreply, socket} + end + end + + @impl true + def handle_event("create", %{"name" => name}, socket) when name != "" do + case Api.upsert_feature_flag(%{name: String.trim(name), enabled: false}) do + {:ok, flag} -> + Endpoint.broadcast_from(self(), "feature_flags", "updated", flag) + flags = Enum.sort_by([flag | socket.assigns.flags], & &1.name) + {:noreply, assign(socket, flags: flags, new_name: "")} + + {:error, _changeset} -> + {:noreply, socket} + end + end + + @impl true + def handle_event("create", _params, socket), do: {:noreply, socket} + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + flag = Enum.find(socket.assigns.flags, &(&1.id == id)) + + case Api.delete_feature_flag(flag) do + {:ok, _} -> + Endpoint.broadcast_from(self(), "feature_flags", "deleted", %{name: flag.name}) + {:noreply, assign(socket, flags: Enum.reject(socket.assigns.flags, &(&1.id == id)))} + + {:error, _} -> + {:noreply, socket} + end + end + + @impl true + def handle_info(%Phoenix.Socket.Broadcast{event: "updated", payload: updated}, socket) do + flags = + if Enum.any?(socket.assigns.flags, &(&1.id == updated.id)) do + Enum.map(socket.assigns.flags, fn f -> if f.id == updated.id, do: updated, else: f end) + else + Enum.sort_by([updated | socket.assigns.flags], & &1.name) + end + + {:noreply, assign(socket, flags: flags)} + end + + @impl true + def handle_info(%Phoenix.Socket.Broadcast{event: "deleted", payload: %{name: name}}, socket) do + flags = Enum.reject(socket.assigns.flags, &(&1.name == name)) + {:noreply, assign(socket, flags: flags)} + end +end diff --git a/lib/realtime_web/live/feature_flags_live/index.html.heex b/lib/realtime_web/live/feature_flags_live/index.html.heex new file mode 100644 index 000000000..0eb694a5e --- /dev/null +++ b/lib/realtime_web/live/feature_flags_live/index.html.heex @@ -0,0 +1,66 @@ +<.h1>Feature Flags + +
+
+ + +
+ + + + + + + + + + + <%= for flag <- @flags do %> + + + + + + <% end %> + +
NameStatusActions
<%= flag.name %> +
+ + + <%= if flag.enabled, do: "Enabled", else: "Disabled" %> + +
+
+ +
+
diff --git a/lib/realtime_web/live/status_live/index.ex b/lib/realtime_web/live/status_live/index.ex index 8a2d32054..f55eddfa5 100644 --- a/lib/realtime_web/live/status_live/index.ex +++ b/lib/realtime_web/live/status_live/index.ex @@ -3,11 +3,18 @@ defmodule RealtimeWeb.StatusLive.Index do alias Realtime.Latency.Payload alias Realtime.Nodes + alias RealtimeWeb.Endpoint @impl true def mount(_params, _session, socket) do - if connected?(socket), do: RealtimeWeb.Endpoint.subscribe("admin:cluster") - {:ok, assign(socket, pings: default_pings(), nodes: Enum.count(all_nodes()))} + if connected?(socket), do: Endpoint.subscribe("admin:cluster") + + socket = + socket + |> assign(nodes: Enum.count(all_nodes())) + |> stream(:pings, default_pings()) + + {:ok, socket} end @impl true @@ -17,17 +24,14 @@ defmodule RealtimeWeb.StatusLive.Index do @impl true def handle_info(%Phoenix.Socket.Broadcast{payload: %Payload{} = payload}, socket) do - pair = payload.from_node <> "_" <> payload.node - payload = %{pair => payload} - - pings = Map.merge(socket.assigns.pings, payload) + pair = pair_id(payload.from_node, payload.node) - {:noreply, assign(socket, pings: pings)} + {:noreply, stream(socket, :pings, [%{id: pair, payload: payload}])} end defp apply_action(socket, :index, _params) do socket - |> assign(:page_title, "Status - Supabase Realtime") + |> assign(:page_title, "Realtime Status") end defp all_nodes do @@ -35,9 +39,14 @@ defmodule RealtimeWeb.StatusLive.Index do end defp default_pings do - for n <- all_nodes(), f <- all_nodes(), into: %{} do - pair = n <> "_" <> f - {pair, %Payload{from_node: f, latency: "Loading...", node: n, timestamp: "Loading..."}} + for n <- all_nodes(), f <- all_nodes() do + pair = pair_id(f, n) + + %{id: pair, payload: %Payload{from_node: f, latency: "Loading...", node: n, timestamp: "Loading..."}} end end + + defp pair_id(from, to) do + from <> "_" <> to + end end diff --git a/lib/realtime_web/live/status_live/index.html.heex b/lib/realtime_web/live/status_live/index.html.heex index 645001714..11137e776 100644 --- a/lib/realtime_web/live/status_live/index.html.heex +++ b/lib/realtime_web/live/status_live/index.html.heex @@ -1,16 +1,16 @@ <.h1>Supabase Realtime: Multiplayer Edition + <.h2>Cluster Status +

Understand the latency between nodes across the Realtime cluster.

-
- <%= for {_pair, p} <- @pings do %> -
-
From: <%= p.from_region %> - <%= p.from_node %>
-
To: <%= p.region %> - <%= p.node %>
-
<%= p.latency %> ms
-
<%= p.timestamp %>
-
- <% end %> +
+
+
From: <%= p.payload.from_region %> - <%= p.payload.from_node %>
+
To: <%= p.payload.region %> - <%= p.payload.node %>
+
<%= p.payload.latency %> ms
+
<%= p.payload.timestamp %>
+
diff --git a/lib/realtime_web/open_api_schemas.ex b/lib/realtime_web/open_api_schemas.ex index d5fa9dbb0..108a91ff9 100644 --- a/lib/realtime_web/open_api_schemas.ex +++ b/lib/realtime_web/open_api_schemas.ex @@ -55,6 +55,28 @@ defmodule RealtimeWeb.OpenApiSchemas do def params, do: {"Tenant Batch Params", "application/json", __MODULE__} end + defmodule BroadcastSingleJsonParams do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + description: "JSON payload - any valid JSON object", + example: %{"text" => "hello world", "user" => "alice"} + }) + end + + defmodule BroadcastSingleBinaryParams do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :string, + format: :binary, + description: "Binary payload - raw binary data" + }) + end + defmodule TenantParams do @moduledoc false require OpenApiSpex @@ -111,6 +133,20 @@ defmodule RealtimeWeb.OpenApiSchemas do type: :number, description: "Maximum payload size in KB" }, + max_client_presence_events_per_window: %Schema{ + type: :number, + description: "Maximum client presence events (overrides environment default when set)", + nullable: true + }, + client_presence_window_ms: %Schema{ + type: :number, + description: "Client presence rate limit window in milliseconds (overrides environment default when set)", + nullable: true + }, + presence_enabled: %Schema{ + type: :boolean, + description: "When true, presence is enabled for clients that do not explicitly opt in" + }, extensions: %Schema{ type: :array, items: %Schema{ @@ -214,6 +250,16 @@ defmodule RealtimeWeb.OpenApiSchemas do } } }, + max_client_presence_events_per_window: %Schema{ + type: :number, + description: "Maximum client presence events (overrides environment default when set)", + nullable: true + }, + client_presence_window_ms: %Schema{ + type: :number, + description: "Client presence rate limit window in milliseconds (overrides environment default when set)", + nullable: true + }, inserted_at: %Schema{type: :string, format: "date-time", description: "Insert timestamp"}, extensions: %Schema{ type: :array, @@ -313,18 +359,25 @@ defmodule RealtimeWeb.OpenApiSchemas do type: :boolean, description: "Indicates if Realtime has an active connection to the tenant database" }, + replication_connected: %Schema{ + type: :boolean, + description: "Indicates if Realtime has an active replication connection for broadcast changes" + }, connected_cluster: %Schema{ type: :integer, description: "The count of currently connected clients for a tenant on the Realtime cluster" } }, required: [ - :external_id, - :jwt_secret + :healthy, + :db_connected, + :replication_connected, + :connected_cluster ], example: %{ healthy: true, db_connected: true, + replication_connected: true, connected_cluster: 10 } }) diff --git a/lib/realtime_web/plugs/assign_tenant.ex b/lib/realtime_web/plugs/assign_tenant.ex index 69b52e8ab..4b48631e1 100644 --- a/lib/realtime_web/plugs/assign_tenant.ex +++ b/lib/realtime_web/plugs/assign_tenant.ex @@ -5,14 +5,12 @@ defmodule RealtimeWeb.Plugs.AssignTenant do import Plug.Conn import Phoenix.Controller, only: [json: 2] - require Logger - alias Realtime.Api - alias Realtime.Api.Tenant alias Realtime.Database alias Realtime.GenCounter alias Realtime.RateCounter alias Realtime.Tenants + alias Realtime.Tenants.Cache def init(opts) do opts @@ -20,7 +18,7 @@ defmodule RealtimeWeb.Plugs.AssignTenant do def call(%Plug.Conn{host: host} = conn, _opts) do with {:ok, external_id} <- Database.get_external_id(host), - %Tenant{} = tenant <- Api.get_tenant_by_external_id(external_id) do + {:ok, tenant} <- Cache.fetch_tenant_by_external_id(external_id) do Logger.metadata(external_id: external_id, project: external_id) OpenTelemetry.Tracer.set_attributes(external_id: external_id) @@ -32,7 +30,7 @@ defmodule RealtimeWeb.Plugs.AssignTenant do assign(conn, :tenant, tenant) else - nil -> error_response(conn, "Tenant not found in database") + _ -> error_response(conn, "Tenant not found in database") end end diff --git a/lib/realtime_web/plugs/auth_tenant.ex b/lib/realtime_web/plugs/auth_tenant.ex index 11bf2e0bc..a077977d4 100644 --- a/lib/realtime_web/plugs/auth_tenant.ex +++ b/lib/realtime_web/plugs/auth_tenant.ex @@ -2,8 +2,6 @@ defmodule RealtimeWeb.AuthTenant do @moduledoc """ Authorization plug to ensure that only authorized clients can connect to the their tenant's endpoints. """ - require Logger - import Plug.Conn import Phoenix.Controller, only: [json: 2] @@ -42,6 +40,9 @@ defmodule RealtimeWeb.AuthTenant do [] -> nil + [""] -> + nil + [value | _] -> [bearer, token] = value |> String.split(" ") bearer = String.downcase(bearer) diff --git a/lib/realtime_web/plugs/baggage_request_id.ex b/lib/realtime_web/plugs/baggage_request_id.ex index c23616f82..7cfc0b274 100644 --- a/lib/realtime_web/plugs/baggage_request_id.ex +++ b/lib/realtime_web/plugs/baggage_request_id.ex @@ -8,7 +8,6 @@ defmodule RealtimeWeb.Plugs.BaggageRequestId do def baggage_key, do: Application.get_env(:realtime, :request_id_baggage_key, "request-id") - require Logger alias Plug.Conn @behaviour Plug diff --git a/lib/realtime_web/plugs/parsers/octet_stream.ex b/lib/realtime_web/plugs/parsers/octet_stream.ex new file mode 100644 index 000000000..ead0d4c51 --- /dev/null +++ b/lib/realtime_web/plugs/parsers/octet_stream.ex @@ -0,0 +1,41 @@ +defmodule RealtimeWeb.Plugs.Parsers.OctetStream do + @moduledoc """ + `Plug.Parsers` implementation for `application/octet-stream` request bodies. + + The raw binary body is placed in `conn.body_params` under the `"_binary"` + key, mirroring how `Plug.Parsers.JSON` exposes non-map top-level JSON via + `"_json"`. + + Supports the same options as `Plug.Conn.read_body/2`: `:length`, + `:read_length`, `:read_timeout`, and `:body_reader`. Defaults inherit from + `Plug.Conn.read_body/2` (8 MB max length). + """ + + @behaviour Plug.Parsers + + @impl true + def init(opts) do + Keyword.pop(opts, :body_reader, {Plug.Conn, :read_body, []}) + end + + @impl true + def parse(conn, "application", "octet-stream", _headers, {{mod, fun, args}, opts}) do + case apply(mod, fun, [conn, opts | args]) do + {:ok, body, conn} -> + {:ok, %{"_binary" => body}, conn} + + {:more, _data, conn} -> + {:error, :too_large, conn} + + {:error, :timeout} -> + raise Plug.TimeoutError + + {:error, _} -> + raise Plug.BadRequestError + end + end + + def parse(conn, _type, _subtype, _headers, _opts) do + {:next, conn} + end +end diff --git a/lib/realtime_web/plugs/rate_limiter.ex b/lib/realtime_web/plugs/rate_limiter.ex index ed2f4c47d..4214e40e3 100644 --- a/lib/realtime_web/plugs/rate_limiter.ex +++ b/lib/realtime_web/plugs/rate_limiter.ex @@ -4,8 +4,6 @@ defmodule RealtimeWeb.Plugs.RateLimiter do """ import Plug.Conn import Phoenix.Controller, only: [json: 2] - require Logger - alias Realtime.Api.Tenant def init(opts) do diff --git a/lib/realtime_web/plugs/validate_broadcast_content_type.ex b/lib/realtime_web/plugs/validate_broadcast_content_type.ex new file mode 100644 index 000000000..a6a29932b --- /dev/null +++ b/lib/realtime_web/plugs/validate_broadcast_content_type.ex @@ -0,0 +1,41 @@ +defmodule RealtimeWeb.Plugs.ValidateBroadcastContentType do + @moduledoc """ + Validates the request `Content-Type` for the broadcast-single endpoint. + + Allowed: `application/json` and `application/octet-stream` (optionally with + parameters such as `; charset=utf-8`). A missing header is also allowed for + backward compatibility with callers that historically POSTed JSON without + setting the header. + + Any other media type is rejected with a 415 response carrying a JSON body. + """ + import Plug.Conn + + @allowed ["json", "octet-stream"] + + def init(opts), do: opts + + def call(conn, _opts) do + case get_req_header(conn, "content-type") do + [] -> + conn + + [content_type | _] -> + case Plug.Conn.Utils.content_type(content_type) do + {:ok, "application", subtype, _params} when subtype in @allowed -> + conn + + _ -> + conn + |> put_resp_content_type("application/json") + |> send_resp( + 415, + Jason.encode!(%{ + error: "Unsupported Media Type. Use application/json or application/octet-stream" + }) + ) + |> halt() + end + end + end +end diff --git a/lib/realtime_web/router.ex b/lib/realtime_web/router.ex index 1e368f6d2..980c92b73 100644 --- a/lib/realtime_web/router.ex +++ b/lib/realtime_web/router.ex @@ -2,7 +2,6 @@ defmodule RealtimeWeb.Router do use RealtimeWeb, :router require Logger - require OpenTelemetry.Tracer, as: Tracer import RealtimeWeb.ChannelsAuthorization, only: [authorize: 3] @@ -37,8 +36,16 @@ defmodule RealtimeWeb.Router do plug(:set_span_request_id) end + pipeline :broadcast_single do + plug(:accepts, ["json", "octet-stream"]) + plug(RealtimeWeb.Plugs.ValidateBroadcastContentType) + plug(RealtimeWeb.Plugs.AssignTenant) + plug(RealtimeWeb.Plugs.RateLimiter) + plug(:set_span_request_id) + end + pipeline :dashboard_admin do - plug(:dashboard_basic_auth) + plug(:dashboard_auth) end pipeline :metrics do @@ -70,12 +77,14 @@ defmodule RealtimeWeb.Router do scope "/admin", RealtimeWeb do pipe_through [:browser, :dashboard_admin] live("/tenants", TenantsLive.Index, :index) + live("/feature-flags", FeatureFlagsLive.Index, :index) end scope "/metrics", RealtimeWeb do pipe_through(:metrics) get("/", MetricsController, :index) + get("/:region", MetricsController, :region) end scope "/api" do @@ -89,6 +98,7 @@ defmodule RealtimeWeb.Router do resources("/tenants", TenantController, param: "tenant_id", except: [:edit, :new]) post("/tenants/:tenant_id/reload", TenantController, :reload) + post("/tenants/:tenant_id/shutdown", TenantController, :shutdown) get("/tenants/:tenant_id/health", TenantController, :health) end @@ -104,6 +114,12 @@ defmodule RealtimeWeb.Router do post("/broadcast", BroadcastController, :broadcast) end + scope "/api", RealtimeWeb do + pipe_through([:open_cors, :broadcast_single, :secure_tenant_api]) + + post("/broadcast/:topic/events/:event", BroadcastSingleController, :broadcast) + end + # Enables LiveDashboard only for development # # If you want to use the LiveDashboard in production, you should put @@ -125,19 +141,25 @@ defmodule RealtimeWeb.Router do ecto_psql_extras_options: [long_running_queries: [threshold: "200 milliseconds"]], metrics: RealtimeWeb.Telemetry, additional_pages: [ - route_name: Realtime.Dashboard.ProcessDump + route_name: RealtimeWeb.Dashboard.ProcessDump, + recon_trace: RealtimeWeb.Dashboard.ReconTrace, + node_info: RealtimeWeb.Dashboard.NodeInfo, + tenant_info: RealtimeWeb.Dashboard.TenantInfo, + tenant_migrations: RealtimeWeb.Dashboard.TenantMigrations, + sql_inspector: RealtimeWeb.Dashboard.SqlInspector, + feature_flags: RealtimeWeb.Dashboard.FeatureFlags ] ) end defp check_auth(conn, [secret_key, blocklist_key]) do - secret = Application.fetch_env!(:realtime, secret_key) + secrets = :realtime |> Application.fetch_env!(secret_key) |> List.wrap() blocklist = Application.get_env(:realtime, blocklist_key, []) with ["Bearer " <> token] <- get_req_header(conn, "authorization"), token <- Regex.replace(~r/\s|\n/, URI.decode(token), ""), false <- token in blocklist, - {:ok, _claims} <- authorize(token, secret, nil) do + {:ok, _claims} <- authorize_any(token, secrets) do conn else _ -> @@ -147,17 +169,36 @@ defmodule RealtimeWeb.Router do end end - defp dashboard_basic_auth(conn, _opts) do - user = System.fetch_env!("DASHBOARD_USER") - password = System.fetch_env!("DASHBOARD_PASSWORD") - Plug.BasicAuth.basic_auth(conn, username: user, password: password) + defp authorize_any(token, secrets) do + Enum.find_value(secrets, {:error, :unauthorized}, fn secret -> + case authorize(token, secret, nil) do + {:ok, claims} -> {:ok, claims} + _ -> nil + end + end) + end + + defp dashboard_auth(conn, _opts) do + case Application.fetch_env!(:realtime, :dashboard_auth) do + :zta -> + {conn, user} = NimbleZTA.Cloudflare.authenticate(Realtime.ZTA, conn) + if user, do: conn, else: conn |> send_resp(403, "") |> halt() + + :basic_auth -> + {user, password} = Application.fetch_env!(:realtime, :dashboard_credentials) + Plug.BasicAuth.basic_auth(conn, username: user, password: password) + end + catch + :exit, reason -> + Logger.error("ZTA authentication failed: #{inspect(reason)}") + conn |> send_resp(503, "") |> halt() end defp set_span_request_id(conn, _) do # Must have been set by BaggageRequestId # We can't set the span attribute there because the phoenix span only starts after it reaches the Router if request_id = Logger.metadata()[:request_id] do - Tracer.set_attribute(:request_id, request_id) + OpenTelemetry.Tracer.set_attribute(:request_id, request_id) end conn diff --git a/lib/realtime_web/socket.ex b/lib/realtime_web/socket.ex new file mode 100644 index 000000000..174ca788a --- /dev/null +++ b/lib/realtime_web/socket.ex @@ -0,0 +1,130 @@ +defmodule RealtimeWeb.Socket do + @moduledoc """ + A drop-in replacement for `use Phoenix.Socket` that adds Realtime-specific + transport behaviour: + + * Sets `:max_heap_size` on the transport process during `init/1` + * Schedules periodic traffic measurement via `handle_info/2` + * Wraps `handle_in/2` with error handling for malformed WebSocket messages + """ + + defmacro __using__(opts) do + quote do + import Phoenix.Socket + @behaviour Phoenix.Socket + @before_compile Phoenix.Socket + Module.register_attribute(__MODULE__, :phoenix_channels, accumulate: true) + @phoenix_socket_options unquote(opts) + + @behaviour Phoenix.Socket.Transport + + @doc false + def child_spec(opts) do + Phoenix.Socket.__child_spec__(__MODULE__, opts, @phoenix_socket_options) + end + + @doc false + def drainer_spec(opts) do + Phoenix.Socket.__drainer_spec__(__MODULE__, opts, @phoenix_socket_options) + end + + @doc false + def connect(map), do: Phoenix.Socket.__connect__(__MODULE__, map, @phoenix_socket_options) + + @doc false + def init(state) when is_tuple(state) do + Process.flag(:max_heap_size, :persistent_term.get({__MODULE__, :websocket_max_heap_size})) + + Process.send_after( + self(), + {:measure_traffic, 0, 0}, + :persistent_term.get({__MODULE__, :measure_traffic_interval_in_ms}) + ) + + Phoenix.Socket.__init__(state) + end + + @doc false + def handle_in({payload, opts}, {_state, socket} = full_state) do + Phoenix.Socket.__in__({payload, opts}, full_state) + rescue + e in Phoenix.Socket.InvalidMessageError -> + RealtimeWeb.RealtimeChannel.Logging.log_error(socket, "MalformedWebSocketMessage", e.message) + {:ok, full_state} + + e in Jason.DecodeError -> + RealtimeWeb.RealtimeChannel.Logging.log_error( + socket, + "MalformedWebSocketMessage", + Jason.DecodeError.message(e) + ) + + {:ok, full_state} + + e -> + RealtimeWeb.RealtimeChannel.Logging.log_error(socket, "UnknownErrorOnWebSocketMessage", Exception.message(e)) + {:ok, full_state} + end + + @doc false + def handle_info( + {:measure_traffic, previous_recv, previous_send}, + {_, %{assigns: assigns, transport_pid: transport_pid}} = state + ) do + tenant_external_id = Map.get(assigns, :tenant) + + %{latest_recv: latest_recv, latest_send: latest_send} = + RealtimeWeb.Socket.collect_traffic_telemetry( + transport_pid, + tenant_external_id, + previous_recv, + previous_send + ) + + Process.send_after( + self(), + {:measure_traffic, latest_recv, latest_send}, + :persistent_term.get({__MODULE__, :measure_traffic_interval_in_ms}) + ) + + {:ok, state} + end + + def handle_info(message, state), do: Phoenix.Socket.__info__(message, state) + + @doc false + def terminate(reason, state), do: Phoenix.Socket.__terminate__(reason, state) + end + end + + @doc false + def collect_traffic_telemetry(nil, _tenant_external_id, previous_recv, previous_send), + do: %{latest_recv: previous_recv, latest_send: previous_send} + + def collect_traffic_telemetry(transport_pid, tenant_external_id, previous_recv, previous_send) do + %{send_oct: latest_send, recv_oct: latest_recv} = + transport_pid + |> Process.info(:links) + |> then(fn {:links, links} -> links end) + |> Enum.filter(&is_port/1) + |> Enum.reduce(%{send_oct: 0, recv_oct: 0}, fn link, acc -> + case :inet.getstat(link, [:send_oct, :recv_oct]) do + {:ok, stats} -> + send_oct = Keyword.get(stats, :send_oct, 0) + recv_oct = Keyword.get(stats, :recv_oct, 0) + %{send_oct: acc.send_oct + send_oct, recv_oct: acc.recv_oct + recv_oct} + + {:error, _} -> + acc + end + end) + + send_delta = max(0, latest_send - previous_send) + recv_delta = max(0, latest_recv - previous_recv) + + :telemetry.execute([:realtime, :channel, :output_bytes], %{size: send_delta}, %{tenant: tenant_external_id}) + :telemetry.execute([:realtime, :channel, :input_bytes], %{size: recv_delta}, %{tenant: tenant_external_id}) + + %{latest_recv: latest_recv, latest_send: latest_send} + end +end diff --git a/lib/realtime_web/socket/user_broadcast.ex b/lib/realtime_web/socket/user_broadcast.ex new file mode 100644 index 000000000..7caba33ce --- /dev/null +++ b/lib/realtime_web/socket/user_broadcast.ex @@ -0,0 +1,39 @@ +defmodule RealtimeWeb.Socket.UserBroadcast do + @moduledoc """ + Defines a message sent from pubsub to channels and vice-versa. + + The message format requires the following keys: + + * `:topic` - The string topic or topic:subtopic pair namespace, for example "messages", "messages:123" + * `:user_event`- The string user event name, for example "my-event" + * `:user_payload_encoding`- :json or :binary + * `:user_payload` - The actual message payload + + Optionally metadata which is a map to be JSON encoded + """ + + alias Phoenix.Socket.Broadcast + + @type t :: %__MODULE__{} + defstruct topic: nil, user_event: nil, user_payload: nil, user_payload_encoding: nil, metadata: nil + + @spec convert_to_json_broadcast(t) :: {:ok, Broadcast.t()} | {:error, String.t()} + def convert_to_json_broadcast(%__MODULE__{user_payload_encoding: :json} = user_broadcast) do + payload = %{ + "event" => user_broadcast.user_event, + "payload" => Jason.Fragment.new(user_broadcast.user_payload), + "type" => "broadcast" + } + + payload = + if user_broadcast.metadata do + Map.put(payload, "meta", user_broadcast.metadata) + else + payload + end + + {:ok, %Broadcast{event: "broadcast", payload: payload, topic: user_broadcast.topic}} + end + + def convert_to_json_broadcast(%__MODULE__{}), do: {:error, "User payload encoding is not JSON"} +end diff --git a/lib/realtime_web/socket/v2_serializer.ex b/lib/realtime_web/socket/v2_serializer.ex new file mode 100644 index 000000000..4c4b62170 --- /dev/null +++ b/lib/realtime_web/socket/v2_serializer.ex @@ -0,0 +1,231 @@ +defmodule RealtimeWeb.Socket.V2Serializer do + @moduledoc """ + Custom serializer that is a superset of Phoenix's V2 JSONSerializer + that handles user broadcast and user broadcast push + """ + + @behaviour Phoenix.Socket.Serializer + + @push 0 + @reply 1 + @broadcast 2 + @user_broadcast_push 3 + @user_broadcast 4 + + alias Phoenix.Socket.{Message, Reply, Broadcast} + alias RealtimeWeb.Socket.UserBroadcast + + @impl true + def fastlane!(%UserBroadcast{} = msg) do + metadata = + if msg.metadata do + Phoenix.json_library().encode!(msg.metadata) + else + msg.metadata + end + + topic_size = byte_size!(msg.topic, :topic, 255) + user_event_size = byte_size!(msg.user_event, :user_event, 255) + metadata_size = byte_size!(metadata, :metadata, 255) + user_payload_encoding = if msg.user_payload_encoding == :json, do: 1, else: 0 + + bin = << + @user_broadcast::size(8), + topic_size::size(8), + user_event_size::size(8), + metadata_size::size(8), + user_payload_encoding::size(8), + msg.topic::binary-size(topic_size), + msg.user_event::binary-size(user_event_size), + metadata || <<>>::binary-size(metadata_size), + msg.user_payload::binary + >> + + {:socket_push, :binary, bin} + end + + def fastlane!(%Broadcast{payload: {:binary, data}} = msg) do + topic_size = byte_size!(msg.topic, :topic, 255) + event_size = byte_size!(msg.event, :event, 255) + + bin = << + @broadcast::size(8), + topic_size::size(8), + event_size::size(8), + msg.topic::binary-size(topic_size), + msg.event::binary-size(event_size), + data::binary + >> + + {:socket_push, :binary, bin} + end + + def fastlane!(%Broadcast{payload: %{}} = msg) do + data = Phoenix.json_library().encode_to_iodata!([nil, nil, msg.topic, msg.event, msg.payload]) + {:socket_push, :text, data} + end + + def fastlane!(%Broadcast{payload: invalid}) do + raise ArgumentError, "expected broadcasted payload to be a map, got: #{inspect(invalid)}" + end + + @impl true + def encode!(%Reply{payload: {:binary, data}} = reply) do + status = to_string(reply.status) + join_ref = to_string(reply.join_ref) + ref = to_string(reply.ref) + join_ref_size = byte_size!(join_ref, :join_ref, 255) + ref_size = byte_size!(ref, :ref, 255) + topic_size = byte_size!(reply.topic, :topic, 255) + status_size = byte_size!(status, :status, 255) + + bin = << + @reply::size(8), + join_ref_size::size(8), + ref_size::size(8), + topic_size::size(8), + status_size::size(8), + join_ref::binary-size(join_ref_size), + ref::binary-size(ref_size), + reply.topic::binary-size(topic_size), + status::binary-size(status_size), + data::binary + >> + + {:socket_push, :binary, bin} + end + + def encode!(%Reply{} = reply) do + data = [ + reply.join_ref, + reply.ref, + reply.topic, + "phx_reply", + %{status: reply.status, response: reply.payload} + ] + + {:socket_push, :text, Phoenix.json_library().encode_to_iodata!(data)} + end + + def encode!(%Message{payload: {:binary, data}} = msg) do + join_ref = to_string(msg.join_ref) + join_ref_size = byte_size!(join_ref, :join_ref, 255) + topic_size = byte_size!(msg.topic, :topic, 255) + event_size = byte_size!(msg.event, :event, 255) + + bin = << + @push::size(8), + join_ref_size::size(8), + topic_size::size(8), + event_size::size(8), + join_ref::binary-size(join_ref_size), + msg.topic::binary-size(topic_size), + msg.event::binary-size(event_size), + data::binary + >> + + {:socket_push, :binary, bin} + end + + def encode!(%Message{payload: %{}} = msg) do + data = [msg.join_ref, msg.ref, msg.topic, msg.event, msg.payload] + {:socket_push, :text, Phoenix.json_library().encode_to_iodata!(data)} + end + + def encode!(%Message{payload: invalid}) do + raise ArgumentError, "expected payload to be a map, got: #{inspect(invalid)}" + end + + @impl true + def decode!(raw_message, opts) do + case Keyword.fetch(opts, :opcode) do + {:ok, :text} -> decode_text(raw_message) + {:ok, :binary} -> decode_binary(raw_message) + end + end + + defp decode_text(raw_message) do + case Phoenix.json_library().decode!(raw_message) do + [join_ref, ref, topic, event, payload | _] -> + %Message{topic: topic, event: event, payload: payload, ref: ref, join_ref: join_ref} + + other -> + raise Phoenix.Socket.InvalidMessageError, + "expected V2 array, got: #{inspect(other, limit: 200, printable_limit: 200)}" + end + end + + defp decode_binary(<< + @push::size(8), + join_ref_size::size(8), + ref_size::size(8), + topic_size::size(8), + event_size::size(8), + join_ref::binary-size(join_ref_size), + ref::binary-size(ref_size), + topic::binary-size(topic_size), + event::binary-size(event_size), + data::binary + >>) do + %Message{ + topic: topic, + event: event, + payload: {:binary, data}, + ref: ref, + join_ref: join_ref + } + end + + defp decode_binary(<< + @user_broadcast_push::size(8), + join_ref_size::size(8), + ref_size::size(8), + topic_size::size(8), + user_event_size::size(8), + metadata_size::size(8), + user_payload_encoding::size(8), + join_ref::binary-size(join_ref_size), + ref::binary-size(ref_size), + topic::binary-size(topic_size), + user_event::binary-size(user_event_size), + metadata::binary-size(metadata_size), + user_payload::binary + >>) do + user_payload_encoding = if user_payload_encoding == 0, do: :binary, else: :json + + metadata = + if metadata_size > 0 do + Phoenix.json_library().decode!(metadata) + else + %{} + end + + # Encoding as Message because that's how Phoenix Socket and Channel.Server expects things to show up + # Here we abuse the payload field to carry a tuple of (user_event, user payload encoding, user payload, metadata) + %Message{ + topic: topic, + event: "broadcast", + payload: {user_event, user_payload_encoding, user_payload, metadata}, + ref: ref, + join_ref: join_ref + } + end + + defp byte_size!(nil, _kind, _max), do: 0 + + defp byte_size!(bin, kind, max) do + case byte_size(bin) do + size when size <= max -> + size + + oversized -> + raise ArgumentError, """ + unable to convert #{kind} to binary. + + #{inspect(bin)} + + must be less than or equal to #{max} bytes, but is #{oversized} bytes. + """ + end + end +end diff --git a/lib/realtime_web/tenant_broadcaster.ex b/lib/realtime_web/tenant_broadcaster.ex index ee8646614..b0a95d679 100644 --- a/lib/realtime_web/tenant_broadcaster.ex +++ b/lib/realtime_web/tenant_broadcaster.ex @@ -5,12 +5,40 @@ defmodule RealtimeWeb.TenantBroadcaster do alias Phoenix.PubSub - @spec pubsub_broadcast(tenant_id :: String.t(), PubSub.topic(), PubSub.message(), PubSub.dispatcher()) :: :ok - def pubsub_broadcast(tenant_id, topic, message, dispatcher) do - collect_payload_size(tenant_id, message) + @type message_type :: :broadcast | :presence | :postgres_changes - Realtime.GenRpc.multicast(PubSub, :local_broadcast, [Realtime.PubSub, topic, message, dispatcher], key: topic) + @spec pubsub_direct_broadcast( + node :: node(), + tenant_id :: String.t(), + PubSub.topic(), + PubSub.message(), + PubSub.dispatcher(), + message_type + ) :: + :ok + def pubsub_direct_broadcast(node, tenant_id, topic, message, dispatcher, message_type) do + collect_payload_size(tenant_id, message, message_type) + + do_direct_broadcast(node, topic, message, dispatcher) + + :ok + end + + # Remote + defp do_direct_broadcast(node, topic, message, dispatcher) when node != node() do + PubSub.direct_broadcast(node, Realtime.PubSub, topic, message, dispatcher) + end + + # Local + defp do_direct_broadcast(_node, topic, message, dispatcher) do + PubSub.local_broadcast(Realtime.PubSub, topic, message, dispatcher) + end + @spec pubsub_broadcast(tenant_id :: String.t(), PubSub.topic(), PubSub.message(), PubSub.dispatcher(), message_type) :: + :ok + def pubsub_broadcast(tenant_id, topic, message, dispatcher, message_type) do + collect_payload_size(tenant_id, message, message_type) + PubSub.broadcast(Realtime.PubSub, topic, message, dispatcher) :ok end @@ -19,30 +47,28 @@ defmodule RealtimeWeb.TenantBroadcaster do from :: pid, PubSub.topic(), PubSub.message(), - PubSub.dispatcher() + PubSub.dispatcher(), + message_type ) :: :ok - def pubsub_broadcast_from(tenant_id, from, topic, message, dispatcher) do - collect_payload_size(tenant_id, message) - - Realtime.GenRpc.multicast( - PubSub, - :local_broadcast_from, - [Realtime.PubSub, from, topic, message, dispatcher], - key: topic - ) - + def pubsub_broadcast_from(tenant_id, from, topic, message, dispatcher, message_type) do + collect_payload_size(tenant_id, message, message_type) + PubSub.broadcast_from(Realtime.PubSub, from, topic, message, dispatcher) :ok end @payload_size_event [:realtime, :tenants, :payload, :size] - defp collect_payload_size(tenant_id, payload) when is_struct(payload) do + @spec collect_payload_size(tenant_id :: String.t(), payload :: term, message_type :: message_type) :: :ok + def collect_payload_size(tenant_id, payload, message_type) when is_struct(payload) do # Extracting from struct so the __struct__ bit is not calculated as part of the payload - collect_payload_size(tenant_id, Map.from_struct(payload)) + collect_payload_size(tenant_id, Map.from_struct(payload), message_type) end - defp collect_payload_size(tenant_id, payload) do - :telemetry.execute(@payload_size_event, %{size: :erlang.external_size(payload)}, %{tenant: tenant_id}) + def collect_payload_size(tenant_id, payload, message_type) do + :telemetry.execute(@payload_size_event, %{size: :erlang.external_size(payload)}, %{ + tenant: tenant_id, + message_type: message_type + }) end end diff --git a/lib/realtime_web/views/tenant_view.ex b/lib/realtime_web/views/tenant_view.ex index a74428f7d..7e19c26e6 100644 --- a/lib/realtime_web/views/tenant_view.ex +++ b/lib/realtime_web/views/tenant_view.ex @@ -30,7 +30,10 @@ defmodule RealtimeWeb.TenantView do Map.drop(settings, ["db_password"]) end) end), - private_only: tenant.private_only + private_only: tenant.private_only, + max_client_presence_events_per_window: tenant.max_client_presence_events_per_window, + client_presence_window_ms: tenant.client_presence_window_ms, + presence_enabled: tenant.presence_enabled } end end diff --git a/mise.lock b/mise.lock new file mode 100644 index 000000000..2ac11a4ba --- /dev/null +++ b/mise.lock @@ -0,0 +1,80 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html + +[[tools.elixir]] +version = "1.18.4-otp-27" +backend = "core:elixir" + +[[tools.erlang]] +version = "27.3.4.10" +backend = "core:erlang" + +[tools.erlang."platforms.macos-arm64"] +checksum = "blake3:0d27e4815676201f374d135c1a9a8ee7d41535c3477509be9836232c57afddab" + +[[tools.node]] +version = "24.14.1" +backend = "core:node" + +[tools.node."platforms.linux-arm64"] +checksum = "sha256:734ff04fa7f8ed2e8a78d40cacf5ac3fc4515dac2858757cbab313eb483ba8a2" +url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-linux-arm64.tar.gz" + +[tools.node."platforms.linux-arm64-musl"] +checksum = "sha256:734ff04fa7f8ed2e8a78d40cacf5ac3fc4515dac2858757cbab313eb483ba8a2" +url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-linux-arm64.tar.gz" + +[tools.node."platforms.linux-x64"] +checksum = "sha256:ace9fa104992ed0829642629c46ca7bd7fd6e76278cb96c958c4b387d29658ea" +url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-linux-x64.tar.gz" + +[tools.node."platforms.linux-x64-musl"] +checksum = "sha256:ace9fa104992ed0829642629c46ca7bd7fd6e76278cb96c958c4b387d29658ea" +url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-linux-x64.tar.gz" + +[tools.node."platforms.macos-arm64"] +checksum = "sha256:25495ff85bd89e2d8a24d88566d7e2f827c6b0d3d872b2cebf75371f93fcb1fe" +url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-darwin-arm64.tar.gz" + +[tools.node."platforms.macos-x64"] +checksum = "sha256:2526230ad7d922be82d4fdb1e7ee1e84303e133e3b4b0ec4c2897ab31de0253d" +url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-darwin-x64.tar.gz" + +[tools.node."platforms.windows-x64"] +checksum = "sha256:6e50ce5498c0cebc20fd39ab3ff5df836ed2f8a31aa093cecad8497cff126d70" +url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-win-x64.zip" + +[[tools."npm:@supabase/pg-delta"]] +version = "1.0.0-alpha.30" +backend = "npm:@supabase/pg-delta" + +[[tools.supabase]] +version = "2.105.0" +backend = "aqua:supabase/cli" + +[tools.supabase."platforms.linux-arm64"] +checksum = "sha256:7e090059cdd28e2a6233298f2ff7b7c819e0fac040b975561c80ca5b8a583b56" +url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_linux_arm64.tar.gz" + +[tools.supabase."platforms.linux-arm64-musl"] +checksum = "sha256:7e090059cdd28e2a6233298f2ff7b7c819e0fac040b975561c80ca5b8a583b56" +url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_linux_arm64.tar.gz" + +[tools.supabase."platforms.linux-x64"] +checksum = "sha256:11ac4410c11e8b03f0cc7fd9316d68146695b0e06115a0663364b07e7feb6db8" +url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_linux_amd64.tar.gz" + +[tools.supabase."platforms.linux-x64-musl"] +checksum = "sha256:11ac4410c11e8b03f0cc7fd9316d68146695b0e06115a0663364b07e7feb6db8" +url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_linux_amd64.tar.gz" + +[tools.supabase."platforms.macos-arm64"] +checksum = "sha256:930ffe5ce66c97917c43c6e1c712825b628c9665caaae5a6db109f816d50192d" +url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_darwin_arm64.tar.gz" + +[tools.supabase."platforms.macos-x64"] +checksum = "sha256:c0af429bd5748ef03642e2d755c6a2d07fb52dd733b5ba592aeff598a36e585f" +url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_darwin_amd64.tar.gz" + +[tools.supabase."platforms.windows-x64"] +checksum = "sha256:f4c11bb8b3c34fcf5ab6dd41cb1e9403416c7c319d668f24b3769f004bd24824" +url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_windows_amd64.tar.gz" diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..9c2519a7f --- /dev/null +++ b/mise.toml @@ -0,0 +1,76 @@ +[tools] +elixir = "1.18.4-otp-27" +erlang = "27" +node = "24" +supabase = "2.105.0" +"npm:@supabase/pg-delta" = "1.0.0-alpha.30" + +[env] +API_JWT_SECRET = "dev" +DB_ENC_KEY = "1234567890123456" +METRICS_JWT_SECRET = "dev" +DASHBOARD_USER = "admin" +DASHBOARD_PASSWORD = "admin" + +[tasks.dev] +description = "Start the dev server" +run = "iex --name ${NAME}@127.0.0.1 --cookie cookie -S mix phx.server" +env = { NAME = "pink", PORT = "4000", REGION = "us-east-1", GEN_RPC_TCP_SERVER_PORT = "5369", GEN_RPC_TCP_CLIENT_PORT = "5469" } + +[tasks.dev-orange] +description = "Start another dev server (orange)" +run = "iex --name ${NAME}@127.0.0.1 --cookie cookie -S mix phx.server" +env = { NAME = "orange", PORT = "4001", REGION = "eu-west-1", GEN_RPC_TCP_SERVER_PORT = "5469", GEN_RPC_TCP_CLIENT_PORT = "5369" } + +[tasks.db-start] +description = "Start all dev databases" +run = "docker compose -f compose.dbs.yml up -d --wait" + +[tasks.db-stop] +description = "Stop all dev databases" +run = "docker compose -f compose.dbs.yml stop" + +[tasks.db-rm] +description = "Remove all dev databases" +run = "docker compose -f compose.dbs.yml rm -sf" + +[tasks.realtime-start] +description = "Start realtime server and dev databases" +run = "docker compose up" + +[tasks.realtime-stop] +description = "Stop realtime server and dev databases" +run = "docker compose stop" + +[tasks.realtime-rm] +description = "Remove realtime server and dev databases" +run = "docker compose rm -sf" + +[tasks.realtime-rebuild] +description = "Rebuild realtime server and dev databases" +run = "docker compose down --remove-orphans && docker compose build" + +[tasks.test-start] +description = "Start test services" +run = "docker compose -f compose.tests.yml up" + +[tasks.test-stop] +description = "Stop test services" +run = "docker compose -f compose.tests.yml stop" + +[tasks.test-rm] +description = "Remove test services" +run = "docker compose -f compose.tests.yml rm -sf" + +[tasks.e2e] +description = "Run e2e tests locally. Starts supabase if needed. Pass --url http://127.0.0.1:4000 to test against the local dev server." +dir = "test/e2e" +run = """ + supabase start + eval "$(supabase status --output env)" + bun run realtime-check.ts --env local \ + --publishable-key "$PUBLISHABLE_KEY" \ + --secret-key "$SECRET_KEY" \ + --db-url "postgresql://postgres:postgres@127.0.0.1:54322/postgres" \ + "$@" +""" diff --git a/mix.exs b/mix.exs index d0f8a267b..d3dbf3d5e 100644 --- a/mix.exs +++ b/mix.exs @@ -4,8 +4,8 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.46.2", - elixir: "~> 1.17.3", + version: "2.109.1", + elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, aliases: aliases(), @@ -53,11 +53,12 @@ defmodule Realtime.MixProject do # Type `mix help deps` for examples and options. defp deps do [ - {:phoenix, "~> 1.7.0"}, + phoenix_dep(), {:phoenix_ecto, "~> 4.4.0"}, {:ecto_sql, "~> 3.11"}, {:ecto_psql_extras, "~> 0.8"}, - {:postgrex, "~> 0.20.0"}, + {:postgrex, "~> 0.22"}, + {:db_connection, github: "elixir-ecto/db_connection", branch: "master", override: true}, {:phoenix_html, "~> 3.2"}, {:phoenix_live_view, "~> 0.18"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, @@ -65,21 +66,26 @@ defmodule Realtime.MixProject do {:phoenix_view, "~> 2.0"}, {:esbuild, "~> 0.4", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.1", runtime: Mix.env() == :dev}, - {:telemetry_metrics, "~> 0.6"}, + {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 0.19"}, {:jason, "~> 1.3"}, - {:plug_cowboy, "~> 2.6"}, + {:plug_cowboy, "~> 2.8"}, {:libcluster, "~> 3.3"}, {:libcluster_postgres, "~> 0.2"}, {:uuid, "~> 1.1"}, - {:prom_ex, "~> 1.8"}, + {:prom_ex, "~> 1.10"}, + # prom_ex depends on peep ~> 3.0 but there is no issue using peep ~> 4.0 + # https://github.com/akoutmos/prom_ex/pull/270 + {:peep, "~> 4.3", override: true}, {:joken, "~> 2.5.0"}, - {:ex_json_schema, "~> 0.7"}, + {:nimble_zta, "~> 0.1"}, + {:ex_json_schema, "~> 0.11"}, {:recon, "~> 2.5"}, {:mint, "~> 1.4"}, {:logflare_logger_backend, "~> 0.11"}, {:syn, "~> 3.3"}, + {:forum, path: "./forum"}, {:cachex, "~> 4.0"}, {:open_api_spex, "~> 3.16"}, {:corsica, "~> 2.0"}, @@ -90,7 +96,8 @@ defmodule Realtime.MixProject do {:opentelemetry_phoenix, "~> 2.0"}, {:opentelemetry_cowboy, "~> 1.0"}, {:opentelemetry_ecto, "~> 1.2"}, - {:gen_rpc, git: "https://github.com/supabase/gen_rpc.git", ref: "d161cf263c661a534eaabf80aac7a34484dac772"}, + {:gen_rpc, git: "https://github.com/emqx/gen_rpc.git", tag: "3.6.1"}, + {:req, "~> 0.5"}, {:mimic, "~> 1.0", only: :test}, {:floki, ">= 0.30.0", only: :test}, {:mint_web_socket, "~> 1.0", only: :test}, @@ -102,11 +109,18 @@ defmodule Realtime.MixProject do {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: :dev, runtime: false}, {:poolboy, "~> 1.5", only: :test}, - {:req, "~> 0.5", only: :test}, {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false} ] end + defp phoenix_dep do + if path = System.get_env("PHOENIX_PATH") do + {:phoenix, path: path, override: true} + else + {:phoenix, override: true, github: "supabase/phoenix", branch: "feat/presence-custom-dispatcher-1.7.19"} + end + end + # Aliases are shortcuts or tasks specific to the current project. # For example, to install project dependencies and perform other setup tasks, run: # @@ -116,15 +130,16 @@ defmodule Realtime.MixProject do defp aliases do [ setup: ["deps.get", "ecto.setup", "cmd npm install --prefix assets"], - "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/dev_seeds.exs"], + "ecto.setup": ["ecto.create", "ecto.migrate", "seed"], "ecto.reset": ["ecto.drop", "ecto.setup"], - test: [ + seed: ["run priv/repo/dev_seeds.exs"], + "test.setup": [ "cmd epmd -daemon", "ecto.create --quiet", - "run priv/repo/seeds_before_migration.exs", - "ecto.migrate --migrations-path=priv/repo/migrations", - "test" + "ecto.migrate" ], + test: ["test.setup", "test"], + "test.partitioned": ["test.setup", "test --partitions 4"], "assets.deploy": ["esbuild default --minify", "tailwind default --minify", "phx.digest"] ] end diff --git a/mix.lock b/mix.lock index 76eb0d980..42f41b7c7 100644 --- a/mix.lock +++ b/mix.lock @@ -3,40 +3,40 @@ "benchee": {:hex, :benchee, "1.1.0", "f3a43817209a92a1fade36ef36b86e1052627fd8934a8b937ac9ab3a76c43062", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}], "hexpm", "7da57d545003165a012b587077f6ba90b89210fd88074ce3c60ce239eb5e6d93"}, "bertex": {:hex, :bertex, "1.3.0", "0ad0df9159b5110d9d2b6654f72fbf42a54884ef43b6b651e6224c0af30ba3cb", [:mix], [], "hexpm", "0a5d5e478bb5764b7b7bae37cae1ca491200e58b089df121a2fe1c223d8ee57a"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "cachex": {:hex, :cachex, "4.0.3", "95e88c3ef4d37990948eaecccefe40b4ce4a778e0d7ade29081e6b7a89309ee2", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "d5d632da7f162f8a190f1c39b712c0ebc9cf0007c4e2029d44eddc8041b52d55"}, - "castore": {:hex, :castore, "1.0.11", "4bbd584741601eb658007339ea730b082cc61f3554cf2e8f39bf693a11b49073", [:mix], [], "hexpm", "e03990b4db988df56262852f20de0f659871c35154691427a5047f4967a16a62"}, + "cachex": {:hex, :cachex, "4.1.1", "574c5cd28473db313a0a76aac8c945fe44191659538ca6a1e8946ec300b1a19f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "d6b7449ff98d6bb92dda58bd4fc3189cae9f99e7042054d669596f56dc503cd8"}, + "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"}, "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"}, - "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, + "cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, - "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, + "cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"}, + "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, - "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, - "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "db_connection": {:git, "https://github.com/elixir-ecto/db_connection.git", "7ea461e4d13caa6d2c2483d30c932680f0fdf408", [branch: "master"]}, + "decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, - "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, - "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, + "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, + "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, "ecto_psql_extras": {:hex, :ecto_psql_extras, "0.8.8", "aa02529c97f69aed5722899f5dc6360128735a92dd169f23c5d50b1f7fdede08", [:mix], [{:ecto_sql, "~> 3.7", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "> 0.16.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1 or ~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "04c63d92b141723ad6fed2e60a4b461ca00b3594d16df47bbc48f1f4534f2c49"}, "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, - "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, "ex_hash_ring": {:hex, :ex_hash_ring, "6.0.4", "bef9d2d796afbbe25ab5b5a7ed746e06b99c76604f558113c273466d52fa6d6b", [:mix], [], "hexpm", "89adabf31f7d3dfaa36802ce598ce918e9b5b33bae8909ac1a4d052e1e567d18"}, - "ex_json_schema": {:hex, :ex_json_schema, "0.10.2", "7c4b8c1481fdeb1741e2ce66223976edfb9bccebc8014f6aec35d4efe964fb71", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "37f43be60f8407659d4d0155a7e45e7f406dab1f827051d3d35858a709baf6a6"}, - "excoveralls": {:hex, :excoveralls, "0.18.3", "bca47a24d69a3179951f51f1db6d3ed63bca9017f476fe520eb78602d45f7756", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "746f404fcd09d5029f1b211739afb8fb8575d775b21f6a3908e7ce3e640724c6"}, + "ex_json_schema": {:hex, :ex_json_schema, "0.11.4", "d2f7d31894d048f79ed6c5a76515c266d5bd137438c53fa39c55f6ae98a05f47", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "0bbe87044ef0154be2a91ab6927d69c5fcccdb21908a135653fc10dcbbb79c3b"}, + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, - "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, - "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, - "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, - "gen_rpc": {:git, "https://github.com/supabase/gen_rpc.git", "d161cf263c661a534eaabf80aac7a34484dac772", [ref: "d161cf263c661a534eaabf80aac7a34484dac772"]}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, + "gen_rpc": {:git, "https://github.com/emqx/gen_rpc.git", "891f90d713e83e3fca049345fb641afd9a1def28", [tag: "3.6.1"]}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, - "ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"}, + "ham": {:hex, :ham, "0.3.2", "02ae195f49970ef667faf9d01bc454fb80909a83d6c775bcac724ca567aeb7b3", [:mix], [], "hexpm", "b71cc684c0e5a3d32b5f94b186770551509e93a9ae44ca1c1a313700f2f6a69a"}, "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, - "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "joken": {:hex, :joken, "2.5.0", "09be497d804b8115eb6f07615cef2e60c2a1008fb89dc0aef0d4c4b4609b99aa", [:mix], [{:jose, "~> 1.11.2", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "22b25c89617c5ed8ca7b31026340a25ea0f9ca7160f9706b79be9ed81fdf74e7"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, @@ -45,65 +45,68 @@ "logflare_api_client": {:hex, :logflare_api_client, "0.3.5", "c427ebf65a8402d68b056d4a5ef3e1eb3b90c0ad1d0de97d1fe23807e0c1b113", [:mix], [{:bertex, "~> 1.3", [hex: :bertex, repo: "hexpm", optional: false]}, {:finch, "~> 0.10", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "16d29abcb80c4f72745cdf943379da02a201504813c3aa12b4d4acb0302b7723"}, "logflare_etso": {:hex, :logflare_etso, "1.1.2", "040bd3e482aaf0ed20080743b7562242ec5079fd88a6f9c8ce5d8298818292e9", [:mix], [{:ecto, "~> 3.8", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "ab96be42900730a49b132891f43a9be1d52e4ad3ee9ed9cb92565c5f87345117"}, "logflare_logger_backend": {:hex, :logflare_logger_backend, "0.11.4", "3a5df94e764b7c8ee4bd7b875a480a34a27807128d8459aa59ea63b2b38bddc7", [:mix], [{:bertex, "~> 1.3", [hex: :bertex, repo: "hexpm", optional: false]}, {:logflare_api_client, "~> 0.3.5", [hex: :logflare_api_client, repo: "hexpm", optional: false]}, {:logflare_etso, "~> 1.1.2", [hex: :logflare_etso, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "00998d81b3c481ad93d2bf25e66d1ddb1a01ad77d994e2c1a7638c6da94755c5"}, - "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimic": {:hex, :mimic, "1.12.0", "34c9d1fb8e756df09ca5f96861d273f2bb01063df1a6a51a4c101f9ad7f07a9c", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "eaa43d495d6f3bc8099b28886e05a1b09a2a6be083f6385c3abc17599e5e2c43"}, - "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"}, - "mix_audit": {:hex, :mix_audit, "2.1.4", "0a23d5b07350cdd69001c13882a4f5fb9f90fbd4cbf2ebc190a2ee0d187ea3e9", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "fd807653cc8c1cada2911129c7eb9e985e3cc76ebf26f4dd628bb25bbcaa7099"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "mix_test_watch": {:hex, :mix_test_watch, "1.3.0", "2ffc9f72b0d1f4ecf0ce97b044e0e3c607c3b4dc21d6228365e8bc7c2856dc77", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f9e5edca976857ffac78632e635750d158df14ee2d6185a15013844af7570ffe"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "observer_cli": {:hex, :observer_cli, "1.8.1", "edfe0c0f983631961599326f239f6e99750aba7387515002b1284dcfe7fcd6d2", [:mix, :rebar3], [{:recon, "~> 2.5.6", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "a3cd6300dd8290ade93d688fbd79c872e393b01256309dd7a653feb13c434fb4"}, + "nimble_zta": {:hex, :nimble_zta, "0.1.2", "cb3d9f12963b36004a2cebbfe7b8a16fc186cef87e561485d5c7940ee5c8f093", [:mix], [{:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "a806e7c7a3c2cc09f0f58334f210f59a6ab6facdf64a3d7421a5776b3d00dca4"}, + "observer_cli": {:hex, :observer_cli, "1.8.4", "09030c04d2480499037ba33d801c6e02adba4e7244a05e05b984b5a82843be71", [:mix, :rebar3], [{:recon, "~> 2.5.6", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "0fcd71ac723bcd2d91266d99b3c3ccd9465c71c9f392d900cea8effdc1a1485c"}, "octo_fetch": {:hex, :octo_fetch, "0.4.0", "074b5ecbc08be10b05b27e9db08bc20a3060142769436242702931c418695b19", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "cf8be6f40cd519d7000bb4e84adcf661c32e59369ca2827c4e20042eda7a7fc6"}, - "open_api_spex": {:hex, :open_api_spex, "3.21.2", "6a704f3777761feeb5657340250d6d7332c545755116ca98f33d4b875777e1e5", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "f42ae6ed668b895ebba3e02773cfb4b41050df26f803f2ef634c72a7687dc387"}, - "opentelemetry": {:hex, :opentelemetry, "1.5.0", "7dda6551edfc3050ea4b0b40c0d2570423d6372b97e9c60793263ef62c53c3c2", [:rebar3], [{:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "cdf4f51d17b592fc592b9a75f86a6f808c23044ba7cf7b9534debbcc5c23b0ee"}, - "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.0", "63ca1742f92f00059298f478048dfb826f4b20d49534493d6919a0db39b6db04", [:mix, :rebar3], [], "hexpm", "3dfbbfaa2c2ed3121c5c483162836c4f9027def469c41578af5ef32589fcfc58"}, + "open_api_spex": {:hex, :open_api_spex, "3.22.3", "0e383bf23cc3a060bffaebbcd09fc06bfc908d948c00e518aed36bbf8a2fe473", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "5f74f1878fdc38f8e961b0b943ac7af88dcf3a82a0c0ef6680ddfd3d161aecbd"}, + "opentelemetry": {:hex, :opentelemetry, "1.6.0", "0954dbe12f490ee7b126c9e924cf60141b1238a02dfc700907eadde4dcc20460", [:rebar3], [{:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "5fd0123d65d2649f10e478e7444927cd9fbdffcaeb8c1c2fcae3d486d18c5e62"}, + "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.1", "e071429a37441a0fe9097eeea0ff921ebadce8eba8e1ce297b05a43c7a0d121f", [:mix, :rebar3], [], "hexpm", "39bdb6ad740bc13b16215cb9f233d66796bbae897f3bf6eb77abb712e87c3c26"}, "opentelemetry_cowboy": {:hex, :opentelemetry_cowboy, "1.0.0", "786c7cde66a2493323c79d2c94e679ff501d459a9b403d8b60b9bef116333117", [:rebar3], [{:cowboy_telemetry, "~> 0.4", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7575716eaccacd0eddc3e7e61403aecb5d0a6397183987d6049094aeb0b87a7c"}, "opentelemetry_ecto": {:hex, :opentelemetry_ecto, "1.2.0", "2382cb47ddc231f953d3b8263ed029d87fbf217915a1da82f49159d122b64865", [:mix], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "70dfa2e79932e86f209df00e36c980b17a32f82d175f0068bf7ef9a96cf080cf"}, - "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.8.0", "5d546123230771ef4174e37bedfd77e3374913304cd6ea3ca82a2add49cd5d56", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.5.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "a1f9f271f8d3b02b81462a6bfef7075fd8457fdb06adff5d2537df5e2264d9af"}, + "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.9.0", "e344bf5e3dab2815fe381b0cac172c06cfc29ecf792c5d74cbbd2b3184af359c", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.6.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "2030a59e33afff6aaeba847d865c8db5dc3873db87a9257df2ca03cafd9e0478"}, "opentelemetry_phoenix": {:hex, :opentelemetry_phoenix, "2.0.1", "c664cdef205738cffcd409b33599439a4ffb2035ef6e21a77927ac1da90463cb", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:plug, ">= 1.11.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a24fdccdfa6b890c8892c6366beab4a15a27ec0c692b0f77ec2a862e7b235f6e"}, "opentelemetry_process_propagator": {:hex, :opentelemetry_process_propagator, "0.3.0", "ef5b2059403a1e2b2d2c65914e6962e56371570b8c3ab5323d7a8d3444fb7f84", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "7243cb6de1523c473cba5b1aefa3f85e1ff8cc75d08f367104c1e11919c8c029"}, "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"}, "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.2", "410ab4d76b0921f42dbccbe5a7c831b8125282850be649ee1f70050d3961118a", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "641ab469deb181957ac6d59bce6e1321d5fe2a56df444fc9c19afcad623ab253"}, "otel_http": {:hex, :otel_http, "0.2.0", "b17385986c7f1b862f5d577f72614ecaa29de40392b7618869999326b9a61d8a", [:rebar3], [], "hexpm", "f2beadf922c8cfeb0965488dd736c95cc6ea8b9efce89466b3904d317d7cc717"}, - "phoenix": {:hex, :phoenix, "1.7.19", "36617efe5afbd821099a8b994ff4618a340a5bfb25531a1802c4d4c634017a57", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "ba4dc14458278773f905f8ae6c2ec743d52c3a35b6b353733f64f02dfe096cd6"}, + "peep": {:hex, :peep, "4.3.1", "5157b7ed02d1fa90af2f67768230084c8bc82ec1513e6982e46d6fb1ec5f957f", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e96cca0c194a1ed8b0a8b109fa2244a3bfb23acf2b45c01434007ffb67859fe"}, + "phoenix": {:git, "https://github.com/supabase/phoenix.git", "7b884cc0cc1a49ad2bc272acda2e622b3e11c139", [branch: "feat/presence-custom-dispatcher-1.7.19"]}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.6", "7b1f0327f54c9eb69845fd09a77accf922f488c549a7e7b8618775eb603a62c7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1681ab813ec26ca6915beb3414aa138f298e17721dc6a2bde9e6eb8a62360ff6"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.17", "f396bbdaf4ba227b82251eb75ac0afa6b3da5e509bc0d030206374237dfc9450", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61d741ffb78c85fdbca0de084da6a48f8ceb5261a79165b5a0b59e5f65ce98b"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, - "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.7.2", "fdadb973799ae691bf9ecad99125b16625b1c6039999da5fe544d99218e662e4", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "245d8a11ee2306094840c000e8816f0cbed69a23fc0ac2bcf8d7835ae019bb2f"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, + "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, - "postgres_replication": {:git, "https://github.com/filipecabaco/postgres_replication.git", "69129221f0263aa13faa5fbb8af97c28aeb4f71c", []}, - "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, - "prom_ex": {:hex, :prom_ex, "1.9.0", "63e6dda6c05cdeec1f26c48443dcc38ffd2118b3665ae8d2bd0e5b79f2aea03e", [:mix], [{:absinthe, ">= 1.6.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.0.2", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.5.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.4.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.3", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.5.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.14.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.12.1", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.5", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.0", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "01f3d4f69ec93068219e686cc65e58a29c42bea5429a8ff4e2121f19db178ee6"}, - "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "postgres_replication": {:git, "https://github.com/filipecabaco/postgres_replication.git", "3b0700ee38a1dddaf7936c5793d6f35431fee2cd", []}, + "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"}, + "prom_ex": {:hex, :prom_ex, "1.11.0", "1f6d67f2dead92224cb4f59beb3e4d319257c5728d9638b4a5e8ceb51a4f9c7e", [:mix], [{:absinthe, ">= 1.7.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.11.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "76b074bc3730f0802978a7eb5c7091a65473eaaf07e99ec9e933138dcc327805"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, - "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, + "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, + "rustler": {:hex, :rustler, "0.37.3", "5f4e6634d43b26f0a69834dd1d3ed4e1710b022a053bf4a670220c9540c92602", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a6872c6f53dcf00486d1e7f9e046e20e01bf1654bdacc4193016c2e8002b32a2"}, "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, "snabbkaffe": {:git, "https://github.com/kafka4beam/snabbkaffe", "b59298334ed349556f63405d1353184c63c66534", [tag: "1.0.10"]}, - "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, + "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, + "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, "syn": {:hex, :syn, "3.3.0", "4684a909efdfea35ce75a9662fc523e4a8a4e8169a3df275e4de4fa63f99c486", [:rebar3], [], "hexpm", "e58ee447bc1094bdd21bf0acc102b1fbf99541a508cd48060bf783c245eaf7d6"}, "table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"}, - "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, - "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, - "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"}, + "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"}, - "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, - "tesla": {:hex, :tesla, "1.13.2", "85afa342eb2ac0fee830cf649dbd19179b6b359bec4710d02a3d5d587f016910", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "960609848f1ef654c3cdfad68453cd84a5febecb6ed9fed9416e36cd9cd724f9"}, - "tls_certificate_check": {:hex, :tls_certificate_check, "1.28.0", "c39bf21f67c2d124ae905454fad00f27e625917e8ab1009146e916e1df6ab275", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3ab058c3f9457fffca916729587415f0ddc822048a0e5b5e2694918556d92df1"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "tesla": {:hex, :tesla, "1.15.3", "3a2b5c37f09629b8dcf5d028fbafc9143c0099753559d7fe567eaabfbd9b8663", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "98bb3d4558abc67b92fb7be4cd31bb57ca8d80792de26870d362974b58caeda7"}, + "tls_certificate_check": {:hex, :tls_certificate_check, "1.29.0", "4473005eb0bbdad215d7083a230e2e076f538d9ea472c8009fd22006a4cfc5f6", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "5b0d0e5cb0f928bc4f210df667304ed91c5bff2a391ce6bdedfbfe70a8f096c5"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, - "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, } diff --git a/priv/repo/dev_seeds.exs b/priv/repo/dev_seeds.exs index 7dec7895a..7b69a6ea2 100644 --- a/priv/repo/dev_seeds.exs +++ b/priv/repo/dev_seeds.exs @@ -1,5 +1,3 @@ -import Ecto.Adapters.SQL, only: [query!: 3] - alias Realtime.Api.Tenant alias Realtime.Database alias Realtime.Repo @@ -7,6 +5,7 @@ alias Realtime.Tenants tenant_name = "realtime-dev" default_db_host = "127.0.0.1" +publication = "supabase_realtime" {:ok, tenant} = Repo.transaction(fn -> @@ -30,6 +29,8 @@ default_db_host = "127.0.0.1" "db_host" => System.get_env("DB_HOST", default_db_host), "db_user" => System.get_env("DB_USER", "supabase_admin"), "db_password" => System.get_env("DB_PASSWORD", "postgres"), + "db_user_realtime" => System.get_env("DB_USER_REALTIME", "supabase_realtime_admin"), + "db_pass_realtime" => System.get_env("DB_PASS_REALTIME", "postgres"), "db_port" => System.get_env("DB_PORT", "5433"), "region" => "us-east-1", "poll_interval_ms" => 100, @@ -41,36 +42,31 @@ default_db_host = "127.0.0.1" }) |> Repo.insert!() - publication = "supabase_realtime" - - [ - "drop publication if exists #{publication}", - "drop table if exists public.test_tenant;", - "create table public.test_tenant ( id SERIAL PRIMARY KEY, details text );", - "grant all on table public.test_tenant to anon;", - "grant all on table public.test_tenant to postgres;", - "grant all on table public.test_tenant to authenticated;", - "create publication #{publication} for table public.test_tenant" - ] - |> Enum.each(&query!(Repo, &1, [])) - tenant end) # Reset Tenant DB -settings = Database.from_tenant(tenant, "realtime_migrations", :stop) -settings = %{settings | max_restarts: 0, ssl: false} -{:ok, tenant_conn} = Database.connect_db(settings) +{:ok, settings} = Database.from_tenant(tenant, "realtime_seeds", :stop) +{:ok, admin_conn} = Database.connect_db(%{settings | username: "supabase_admin", max_restarts: 0, ssl: false}) -Postgrex.transaction(tenant_conn, fn db_conn -> - Postgrex.query!(db_conn, "DROP SCHEMA IF EXISTS realtime CASCADE", []) - Postgrex.query!(db_conn, "CREATE SCHEMA IF NOT EXISTS realtime", []) +Postgrex.transaction(admin_conn, fn db_conn -> + [ + "grant usage on schema realtime to postgres, anon, authenticated, service_role", + "grant all on schema realtime to supabase_realtime_admin with grant option", + "drop publication if exists #{publication}", + "drop table if exists public.test_tenant", + "create table public.test_tenant ( id SERIAL PRIMARY KEY, details text )", + "grant all on table public.test_tenant to anon, authenticated, supabase_realtime_admin", + "create publication #{publication} for table public.test_tenant" + ] + |> Enum.each(&Postgrex.query!(db_conn, &1)) end) +# Enable supabase_realtime_admin to include SetupSupabaseRealtimeAdmin in tenant catalog +{:ok, _} = Realtime.Api.upsert_feature_flag(%{name: "use_supabase_realtime_admin", enabled: true}) + case Tenants.Migrations.run_migrations(tenant) do :ok -> :ok :noop -> :ok _ -> raise "Running Migrations failed" end - -Tenants.Migrations.run_migrations(tenant) diff --git a/priv/repo/migrations/20250926223044_set_default_presence_value.exs b/priv/repo/migrations/20250926223044_set_default_presence_value.exs new file mode 100644 index 000000000..5f1833a34 --- /dev/null +++ b/priv/repo/migrations/20250926223044_set_default_presence_value.exs @@ -0,0 +1,10 @@ +defmodule Realtime.Repo.Migrations.SetDefaultPresenceValue do + use Ecto.Migration + @disable_ddl_transaction true + @disable_migration_lock true + def change do + alter table(:tenants) do + modify :max_presence_events_per_second, :integer, default: 1000 + end + end +end diff --git a/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs b/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs new file mode 100644 index 000000000..342a80ad9 --- /dev/null +++ b/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs @@ -0,0 +1,13 @@ +defmodule Realtime.Repo.Migrations.NullableJwtSecrets do + use Ecto.Migration + + def change do + alter table(:tenants) do + modify :jwt_secret, :text, null: true + end + + create constraint(:tenants, :jwt_secret_or_jwt_jwks_required, + check: "jwt_secret IS NOT NULL OR jwt_jwks IS NOT NULL" + ) + end +end diff --git a/priv/repo/migrations/20251218000543_ensure_jwt_secret_is_text.exs b/priv/repo/migrations/20251218000543_ensure_jwt_secret_is_text.exs new file mode 100644 index 000000000..008c9d7db --- /dev/null +++ b/priv/repo/migrations/20251218000543_ensure_jwt_secret_is_text.exs @@ -0,0 +1,9 @@ +defmodule Realtime.Repo.Migrations.EnsureJwtSecretIsText do + use Ecto.Migration + + def change do + alter table(:tenants) do + modify :jwt_secret, :text, null: true + end + end +end diff --git a/priv/repo/migrations/20260209232800_add_max_client_presence_events_per_second.exs b/priv/repo/migrations/20260209232800_add_max_client_presence_events_per_second.exs new file mode 100644 index 000000000..403ad77c5 --- /dev/null +++ b/priv/repo/migrations/20260209232800_add_max_client_presence_events_per_second.exs @@ -0,0 +1,10 @@ +defmodule Realtime.Repo.Migrations.AddMaxClientPresenceEventsPerSecond do + use Ecto.Migration + + def change do + alter table(:tenants) do + add :max_client_presence_events_per_window, :integer, null: true + add :client_presence_window_ms, :integer, null: true + end + end +end diff --git a/priv/repo/migrations/20260304000000_add_presence_enabled_to_tenants.exs b/priv/repo/migrations/20260304000000_add_presence_enabled_to_tenants.exs new file mode 100644 index 000000000..0e032afb4 --- /dev/null +++ b/priv/repo/migrations/20260304000000_add_presence_enabled_to_tenants.exs @@ -0,0 +1,9 @@ +defmodule Realtime.Repo.Migrations.AddPresenceEnabledToTenants do + use Ecto.Migration + + def change do + alter table(:tenants) do + add :presence_enabled, :boolean, default: false, null: false + end + end +end diff --git a/priv/repo/migrations/20260422000000_create_feature_flags.exs b/priv/repo/migrations/20260422000000_create_feature_flags.exs new file mode 100644 index 000000000..3792025b4 --- /dev/null +++ b/priv/repo/migrations/20260422000000_create_feature_flags.exs @@ -0,0 +1,18 @@ +defmodule Realtime.Repo.Migrations.CreateFeatureFlags do + use Ecto.Migration + + def change do + create table(:feature_flags, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :string, null: false + add :enabled, :boolean, null: false, default: false + timestamps() + end + + create unique_index(:feature_flags, [:name]) + + alter table(:tenants) do + add :feature_flags, :map, null: false, default: %{} + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 95715f77b..d5caf2b61 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -30,6 +30,8 @@ default_db_host = "host.docker.internal" "db_host" => System.get_env("DB_HOST", default_db_host), "db_user" => System.get_env("DB_USER", "supabase_admin"), "db_password" => System.get_env("DB_PASSWORD", "postgres"), + "db_user_realtime" => System.get_env("DB_USER_REALTIME", "supabase_realtime_admin"), + "db_pass_realtime" => System.get_env("DB_PASS_REALTIME", "postgres"), "db_port" => System.get_env("DB_PORT", "5433"), "region" => "us-east-1", "poll_interval_ms" => 100, diff --git a/priv/repo/tenant_db_catalog_17.json b/priv/repo/tenant_db_catalog_17.json new file mode 100644 index 000000000..c67fb82c9 --- /dev/null +++ b/priv/repo/tenant_db_catalog_17.json @@ -0,0 +1,3256 @@ +{ + "version": 170006, + "currentUser": "supabase_admin", + "aggregates": {}, + "collations": {}, + "compositeTypes": { + "type:realtime.user_defined_filter": { + "schema": "realtime", + "name": "user_defined_filter", + "row_security": false, + "force_row_security": false, + "has_indexes": false, + "has_rules": false, + "has_triggers": false, + "has_subclasses": false, + "is_populated": true, + "replica_identity": "n", + "is_partition": false, + "options": null, + "partition_bound": null, + "owner": "supabase_realtime_admin", + "comment": null, + "columns": [ + { + "name": "column_name", + "position": 1, + "data_type": "text", + "data_type_str": "text", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null + }, + { + "name": "op", + "position": 2, + "data_type": "realtime.equality_op", + "data_type_str": "realtime.equality_op", + "is_custom_type": true, + "custom_type_type": "e", + "custom_type_category": "E", + "custom_type_schema": "realtime", + "custom_type_name": "equality_op", + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null + }, + { + "name": "value", + "position": 3, + "data_type": "text", + "data_type_str": "text", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null + } + ], + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "USAGE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "USAGE", + "grantable": false + } + ], + "security_labels": [] + }, + "type:realtime.wal_column": { + "schema": "realtime", + "name": "wal_column", + "row_security": false, + "force_row_security": false, + "has_indexes": false, + "has_rules": false, + "has_triggers": false, + "has_subclasses": false, + "is_populated": true, + "replica_identity": "n", + "is_partition": false, + "options": null, + "partition_bound": null, + "owner": "supabase_realtime_admin", + "comment": null, + "columns": [ + { + "name": "name", + "position": 1, + "data_type": "text", + "data_type_str": "text", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null + }, + { + "name": "type_name", + "position": 2, + "data_type": "text", + "data_type_str": "text", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null + }, + { + "name": "type_oid", + "position": 3, + "data_type": "oid", + "data_type_str": "oid", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null + }, + { + "name": "value", + "position": 4, + "data_type": "jsonb", + "data_type_str": "jsonb", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null + }, + { + "name": "is_pkey", + "position": 5, + "data_type": "boolean", + "data_type_str": "boolean", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null + }, + { + "name": "is_selectable", + "position": 6, + "data_type": "boolean", + "data_type_str": "boolean", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null + } + ], + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "USAGE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "USAGE", + "grantable": false + } + ], + "security_labels": [] + }, + "type:realtime.wal_rls": { + "schema": "realtime", + "name": "wal_rls", + "row_security": false, + "force_row_security": false, + "has_indexes": false, + "has_rules": false, + "has_triggers": false, + "has_subclasses": false, + "is_populated": true, + "replica_identity": "n", + "is_partition": false, + "options": null, + "partition_bound": null, + "owner": "supabase_realtime_admin", + "comment": null, + "columns": [ + { + "name": "wal", + "position": 1, + "data_type": "jsonb", + "data_type_str": "jsonb", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null + }, + { + "name": "is_rls_enabled", + "position": 2, + "data_type": "boolean", + "data_type_str": "boolean", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null + }, + { + "name": "subscription_ids", + "position": 3, + "data_type": "uuid[]", + "data_type_str": "uuid[]", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null + }, + { + "name": "errors", + "position": 4, + "data_type": "text[]", + "data_type_str": "text[]", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null + } + ], + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "USAGE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "USAGE", + "grantable": false + } + ], + "security_labels": [] + } + }, + "domains": {}, + "enums": { + "type:realtime.action": { + "schema": "realtime", + "name": "action", + "owner": "supabase_realtime_admin", + "labels": [ + { + "sort_order": 1, + "label": "INSERT" + }, + { + "sort_order": 2, + "label": "UPDATE" + }, + { + "sort_order": 3, + "label": "DELETE" + }, + { + "sort_order": 4, + "label": "TRUNCATE" + }, + { + "sort_order": 5, + "label": "ERROR" + } + ], + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "USAGE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "USAGE", + "grantable": false + } + ], + "security_labels": [] + }, + "type:realtime.equality_op": { + "schema": "realtime", + "name": "equality_op", + "owner": "supabase_realtime_admin", + "labels": [ + { + "sort_order": 1, + "label": "eq" + }, + { + "sort_order": 2, + "label": "neq" + }, + { + "sort_order": 3, + "label": "lt" + }, + { + "sort_order": 4, + "label": "lte" + }, + { + "sort_order": 5, + "label": "gt" + }, + { + "sort_order": 6, + "label": "gte" + }, + { + "sort_order": 7, + "label": "in" + } + ], + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "USAGE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "USAGE", + "grantable": false + } + ], + "security_labels": [] + } + }, + "extensions": {}, + "procedures": { + "procedure:realtime.\"cast\"(text,regtype)": { + "schema": "realtime", + "name": "\"cast\"", + "kind": "f", + "return_type": "jsonb", + "return_type_schema": "pg_catalog", + "language": "plpgsql", + "security_definer": false, + "volatility": "i", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 0, + "is_strict": false, + "leakproof": false, + "returns_set": false, + "argument_count": 2, + "argument_default_count": 0, + "argument_names": [ + "val", + "type_" + ], + "argument_types": [ + "text", + "regtype" + ], + "all_argument_types": [], + "argument_modes": null, + "argument_defaults": null, + "source_code": "\ndeclare\n res jsonb;\nbegin\n if type_::text = 'bytea' then\n return to_jsonb(val);\n end if;\n execute format('select to_jsonb(%L::'|| type_::text || ')', val) into res;\n return res;\nend\n", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.\"cast\"(val text, type_ regtype)\n RETURNS jsonb\n LANGUAGE plpgsql\n IMMUTABLE\nAS $function$\ndeclare\n res jsonb;\nbegin\n if type_::text = 'bytea' then\n return to_jsonb(val);\n end if;\n execute format('select to_jsonb(%L::'|| type_::text || ')', val) into res;\n return res;\nend\n$function$\n", + "config": null, + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "anon", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "authenticated", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "service_role", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + }, + "procedure:realtime.apply_rls(jsonb,integer)": { + "schema": "realtime", + "name": "apply_rls", + "kind": "f", + "return_type": "realtime.wal_rls", + "return_type_schema": "realtime", + "language": "plpgsql", + "security_definer": false, + "volatility": "v", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 1000, + "is_strict": false, + "leakproof": false, + "returns_set": true, + "argument_count": 2, + "argument_default_count": 1, + "argument_names": [ + "wal", + "max_record_bytes" + ], + "argument_types": [ + "jsonb", + "integer" + ], + "all_argument_types": [], + "argument_modes": null, + "argument_defaults": "(1024 * 1024)", + "source_code": "\ndeclare\n -- Regclass of the table e.g. public.notes\n entity_ regclass = (quote_ident(wal ->> 'schema') || '.' || quote_ident(wal ->> 'table'))::regclass;\n\n -- I, U, D, T: insert, update ...\n action realtime.action = (\n case wal ->> 'action'\n when 'I' then 'INSERT'\n when 'U' then 'UPDATE'\n when 'D' then 'DELETE'\n else 'ERROR'\n end\n );\n\n -- Is row level security enabled for the table\n is_rls_enabled bool = relrowsecurity from pg_class where oid = entity_;\n\n subscriptions realtime.subscription[] = array_agg(subs)\n from\n realtime.subscription subs\n where\n subs.entity = entity_\n -- Filter by action early - only get subscriptions interested in this action\n -- action_filter column can be: '*' (all), 'INSERT', 'UPDATE', or 'DELETE'\n and (subs.action_filter = '*' or subs.action_filter = action::text);\n\n -- Subscription vars\n working_role regrole;\n working_selected_columns text[];\n claimed_role regrole;\n claims jsonb;\n\n subscription_id uuid;\n subscription_has_access bool;\n visible_to_subscription_ids uuid[] = '{}';\n\n -- structured info for wal's columns\n columns realtime.wal_column[];\n -- previous identity values for update/delete\n old_columns realtime.wal_column[];\n\n error_record_exceeds_max_size boolean = octet_length(wal::text) > max_record_bytes;\n\n -- Primary jsonb output for record\n output jsonb;\n\n -- Loop record for iterating unique roles (outer loop)\n role_record record;\n -- Loop record for iterating unique selected_columns within a role (inner loop)\n cols_record record;\n -- Subscription ids visible at the role level (before fanning out by selected_columns)\n visible_role_sub_ids uuid[] = '{}';\n\nbegin\n perform set_config('role', null, true);\n\n columns =\n array_agg(\n (\n x->>'name',\n x->>'type',\n x->>'typeoid',\n realtime.cast(\n (x->'value') #>> '{}',\n coalesce(\n (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4\n (x->>'type')::regtype\n )\n ),\n (pks ->> 'name') is not null,\n true\n )::realtime.wal_column\n )\n from\n jsonb_array_elements(wal -> 'columns') x\n left join jsonb_array_elements(wal -> 'pk') pks\n on (x ->> 'name') = (pks ->> 'name');\n\n old_columns =\n array_agg(\n (\n x->>'name',\n x->>'type',\n x->>'typeoid',\n realtime.cast(\n (x->'value') #>> '{}',\n coalesce(\n (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4\n (x->>'type')::regtype\n )\n ),\n (pks ->> 'name') is not null,\n true\n )::realtime.wal_column\n )\n from\n jsonb_array_elements(wal -> 'identity') x\n left join jsonb_array_elements(wal -> 'pk') pks\n on (x ->> 'name') = (pks ->> 'name');\n\n for role_record in\n select claims_role\n from (select distinct claims_role from unnest(subscriptions)) t\n order by claims_role::text\n loop\n working_role := role_record.claims_role;\n\n -- Update `is_selectable` for columns and old_columns (once per role)\n columns =\n array_agg(\n (\n c.name,\n c.type_name,\n c.type_oid,\n c.value,\n c.is_pkey,\n pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT')\n )::realtime.wal_column\n )\n from\n unnest(columns) c;\n\n old_columns =\n array_agg(\n (\n c.name,\n c.type_name,\n c.type_oid,\n c.value,\n c.is_pkey,\n pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT')\n )::realtime.wal_column\n )\n from\n unnest(old_columns) c;\n\n if action <> 'DELETE' and count(1) = 0 from unnest(columns) c where c.is_pkey then\n -- Fan out 400 error per distinct selected_columns for this role\n for cols_record in\n select selected_columns\n from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t\n order by coalesce(array_to_string(selected_columns, ','), '')\n loop\n working_selected_columns := cols_record.selected_columns;\n return next (\n jsonb_build_object(\n 'schema', wal ->> 'schema',\n 'table', wal ->> 'table',\n 'type', action\n ),\n is_rls_enabled,\n (select array_agg(s.subscription_id) from unnest(subscriptions) as s where s.claims_role = working_role and (s.selected_columns is not distinct from working_selected_columns)),\n array['Error 400: Bad Request, no primary key']\n )::realtime.wal_rls;\n end loop;\n\n -- The claims role does not have SELECT permission to the primary key of entity\n elsif action <> 'DELETE' and sum(c.is_selectable::int) <> count(1) from unnest(columns) c where c.is_pkey then\n -- Fan out 401 error per distinct selected_columns for this role\n for cols_record in\n select selected_columns\n from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t\n order by coalesce(array_to_string(selected_columns, ','), '')\n loop\n working_selected_columns := cols_record.selected_columns;\n return next (\n jsonb_build_object(\n 'schema', wal ->> 'schema',\n 'table', wal ->> 'table',\n 'type', action\n ),\n is_rls_enabled,\n (select array_agg(s.subscription_id) from unnest(subscriptions) as s where s.claims_role = working_role and (s.selected_columns is not distinct from working_selected_columns)),\n array['Error 401: Unauthorized']\n )::realtime.wal_rls;\n end loop;\n\n else\n -- Create the prepared statement (once per role)\n if is_rls_enabled and action <> 'DELETE' then\n if (select 1 from pg_prepared_statements where name = 'walrus_rls_stmt' limit 1) > 0 then\n deallocate walrus_rls_stmt;\n end if;\n execute realtime.build_prepared_statement_sql('walrus_rls_stmt', entity_, columns);\n end if;\n\n -- Collect all visible subscription IDs for this role (filter check + RLS check)\n visible_role_sub_ids = '{}';\n\n for subscription_id, claims in (\n select\n subs.subscription_id,\n subs.claims\n from\n unnest(subscriptions) subs\n where\n subs.entity = entity_\n and subs.claims_role = working_role\n and (\n realtime.is_visible_through_filters(columns, subs.filters)\n or (\n action = 'DELETE'\n and realtime.is_visible_through_filters(old_columns, subs.filters)\n )\n )\n ) loop\n\n if not is_rls_enabled or action = 'DELETE' then\n visible_role_sub_ids = visible_role_sub_ids || subscription_id;\n else\n -- Check if RLS allows the role to see the record\n perform\n -- Trim leading and trailing quotes from working_role because set_config\n -- doesn't recognize the role as valid if they are included\n set_config('role', trim(both '\"' from working_role::text), true),\n set_config('request.jwt.claims', claims::text, true);\n\n execute 'execute walrus_rls_stmt' into subscription_has_access;\n\n if subscription_has_access then\n visible_role_sub_ids = visible_role_sub_ids || subscription_id;\n end if;\n end if;\n end loop;\n\n perform set_config('role', null, true);\n\n -- Inner loop: per distinct selected_columns for this role\n for cols_record in\n select selected_columns\n from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t\n order by coalesce(array_to_string(selected_columns, ','), '')\n loop\n working_selected_columns := cols_record.selected_columns;\n\n output = jsonb_build_object(\n 'schema', wal ->> 'schema',\n 'table', wal ->> 'table',\n 'type', action,\n 'commit_timestamp', to_char(\n ((wal ->> 'timestamp')::timestamptz at time zone 'utc'),\n 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'\n ),\n 'columns', (\n select\n jsonb_agg(\n jsonb_build_object(\n 'name', pa.attname,\n 'type', pt.typname\n )\n order by pa.attnum asc\n )\n from\n pg_attribute pa\n join pg_type pt\n on pa.atttypid = pt.oid\n left join (\n select unnest(conkey) as pkey_attnum\n from pg_constraint\n where conrelid = entity_ and contype = 'p'\n ) pk on pk.pkey_attnum = pa.attnum\n where\n attrelid = entity_\n and attnum > 0\n and pg_catalog.has_column_privilege(working_role, entity_, pa.attname, 'SELECT')\n and (working_selected_columns is null or pa.attname = any(working_selected_columns) or pk.pkey_attnum is not null)\n )\n )\n -- Add \"record\" key for insert and update\n || case\n when action in ('INSERT', 'UPDATE') then\n jsonb_build_object(\n 'record',\n (\n select\n jsonb_object_agg(\n -- if unchanged toast, get column name and value from old record\n coalesce((c).name, (oc).name),\n case\n when (c).name is null then (oc).value\n else (c).value\n end\n )\n from\n unnest(columns) c\n full outer join unnest(old_columns) oc\n on (c).name = (oc).name\n where\n coalesce((c).is_selectable, (oc).is_selectable)\n and (working_selected_columns is null or coalesce((c).name, (oc).name) = any(working_selected_columns) or coalesce((c).is_pkey, (oc).is_pkey))\n and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))\n )\n )\n else '{}'::jsonb\n end\n -- Add \"old_record\" key for update and delete\n || case\n when action = 'UPDATE' then\n jsonb_build_object(\n 'old_record',\n (\n select jsonb_object_agg((c).name, (c).value)\n from unnest(old_columns) c\n where\n (c).is_selectable\n and (working_selected_columns is null or (c).name = any(working_selected_columns) or (c).is_pkey)\n and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))\n )\n )\n when action = 'DELETE' then\n jsonb_build_object(\n 'old_record',\n (\n select jsonb_object_agg((c).name, (c).value)\n from unnest(old_columns) c\n where\n (c).is_selectable\n and (working_selected_columns is null or (c).name = any(working_selected_columns) or (c).is_pkey)\n and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))\n and ( not is_rls_enabled or (c).is_pkey ) -- if RLS enabled, we can't secure deletes so filter to pkey\n )\n )\n else '{}'::jsonb\n end;\n\n -- Filter visible_role_sub_ids to those matching the current selected_columns group\n visible_to_subscription_ids = coalesce(\n (\n select array_agg(s.subscription_id)\n from unnest(subscriptions) s\n where s.claims_role = working_role\n and (s.selected_columns is not distinct from working_selected_columns)\n and s.subscription_id = any(visible_role_sub_ids)\n ),\n '{}'::uuid[]\n );\n\n return next (\n output,\n is_rls_enabled,\n visible_to_subscription_ids,\n case\n when error_record_exceeds_max_size then array['Error 413: Payload Too Large']\n else '{}'\n end\n )::realtime.wal_rls;\n end loop;\n\n end if;\n end loop;\n\n perform set_config('role', null, true);\nend;\n", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer DEFAULT (1024 * 1024))\n RETURNS SETOF realtime.wal_rls\n LANGUAGE plpgsql\nAS $function$\ndeclare\n -- Regclass of the table e.g. public.notes\n entity_ regclass = (quote_ident(wal ->> 'schema') || '.' || quote_ident(wal ->> 'table'))::regclass;\n\n -- I, U, D, T: insert, update ...\n action realtime.action = (\n case wal ->> 'action'\n when 'I' then 'INSERT'\n when 'U' then 'UPDATE'\n when 'D' then 'DELETE'\n else 'ERROR'\n end\n );\n\n -- Is row level security enabled for the table\n is_rls_enabled bool = relrowsecurity from pg_class where oid = entity_;\n\n subscriptions realtime.subscription[] = array_agg(subs)\n from\n realtime.subscription subs\n where\n subs.entity = entity_\n -- Filter by action early - only get subscriptions interested in this action\n -- action_filter column can be: '*' (all), 'INSERT', 'UPDATE', or 'DELETE'\n and (subs.action_filter = '*' or subs.action_filter = action::text);\n\n -- Subscription vars\n working_role regrole;\n working_selected_columns text[];\n claimed_role regrole;\n claims jsonb;\n\n subscription_id uuid;\n subscription_has_access bool;\n visible_to_subscription_ids uuid[] = '{}';\n\n -- structured info for wal's columns\n columns realtime.wal_column[];\n -- previous identity values for update/delete\n old_columns realtime.wal_column[];\n\n error_record_exceeds_max_size boolean = octet_length(wal::text) > max_record_bytes;\n\n -- Primary jsonb output for record\n output jsonb;\n\n -- Loop record for iterating unique roles (outer loop)\n role_record record;\n -- Loop record for iterating unique selected_columns within a role (inner loop)\n cols_record record;\n -- Subscription ids visible at the role level (before fanning out by selected_columns)\n visible_role_sub_ids uuid[] = '{}';\n\nbegin\n perform set_config('role', null, true);\n\n columns =\n array_agg(\n (\n x->>'name',\n x->>'type',\n x->>'typeoid',\n realtime.cast(\n (x->'value') #>> '{}',\n coalesce(\n (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4\n (x->>'type')::regtype\n )\n ),\n (pks ->> 'name') is not null,\n true\n )::realtime.wal_column\n )\n from\n jsonb_array_elements(wal -> 'columns') x\n left join jsonb_array_elements(wal -> 'pk') pks\n on (x ->> 'name') = (pks ->> 'name');\n\n old_columns =\n array_agg(\n (\n x->>'name',\n x->>'type',\n x->>'typeoid',\n realtime.cast(\n (x->'value') #>> '{}',\n coalesce(\n (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4\n (x->>'type')::regtype\n )\n ),\n (pks ->> 'name') is not null,\n true\n )::realtime.wal_column\n )\n from\n jsonb_array_elements(wal -> 'identity') x\n left join jsonb_array_elements(wal -> 'pk') pks\n on (x ->> 'name') = (pks ->> 'name');\n\n for role_record in\n select claims_role\n from (select distinct claims_role from unnest(subscriptions)) t\n order by claims_role::text\n loop\n working_role := role_record.claims_role;\n\n -- Update `is_selectable` for columns and old_columns (once per role)\n columns =\n array_agg(\n (\n c.name,\n c.type_name,\n c.type_oid,\n c.value,\n c.is_pkey,\n pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT')\n )::realtime.wal_column\n )\n from\n unnest(columns) c;\n\n old_columns =\n array_agg(\n (\n c.name,\n c.type_name,\n c.type_oid,\n c.value,\n c.is_pkey,\n pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT')\n )::realtime.wal_column\n )\n from\n unnest(old_columns) c;\n\n if action <> 'DELETE' and count(1) = 0 from unnest(columns) c where c.is_pkey then\n -- Fan out 400 error per distinct selected_columns for this role\n for cols_record in\n select selected_columns\n from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t\n order by coalesce(array_to_string(selected_columns, ','), '')\n loop\n working_selected_columns := cols_record.selected_columns;\n return next (\n jsonb_build_object(\n 'schema', wal ->> 'schema',\n 'table', wal ->> 'table',\n 'type', action\n ),\n is_rls_enabled,\n (select array_agg(s.subscription_id) from unnest(subscriptions) as s where s.claims_role = working_role and (s.selected_columns is not distinct from working_selected_columns)),\n array['Error 400: Bad Request, no primary key']\n )::realtime.wal_rls;\n end loop;\n\n -- The claims role does not have SELECT permission to the primary key of entity\n elsif action <> 'DELETE' and sum(c.is_selectable::int) <> count(1) from unnest(columns) c where c.is_pkey then\n -- Fan out 401 error per distinct selected_columns for this role\n for cols_record in\n select selected_columns\n from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t\n order by coalesce(array_to_string(selected_columns, ','), '')\n loop\n working_selected_columns := cols_record.selected_columns;\n return next (\n jsonb_build_object(\n 'schema', wal ->> 'schema',\n 'table', wal ->> 'table',\n 'type', action\n ),\n is_rls_enabled,\n (select array_agg(s.subscription_id) from unnest(subscriptions) as s where s.claims_role = working_role and (s.selected_columns is not distinct from working_selected_columns)),\n array['Error 401: Unauthorized']\n )::realtime.wal_rls;\n end loop;\n\n else\n -- Create the prepared statement (once per role)\n if is_rls_enabled and action <> 'DELETE' then\n if (select 1 from pg_prepared_statements where name = 'walrus_rls_stmt' limit 1) > 0 then\n deallocate walrus_rls_stmt;\n end if;\n execute realtime.build_prepared_statement_sql('walrus_rls_stmt', entity_, columns);\n end if;\n\n -- Collect all visible subscription IDs for this role (filter check + RLS check)\n visible_role_sub_ids = '{}';\n\n for subscription_id, claims in (\n select\n subs.subscription_id,\n subs.claims\n from\n unnest(subscriptions) subs\n where\n subs.entity = entity_\n and subs.claims_role = working_role\n and (\n realtime.is_visible_through_filters(columns, subs.filters)\n or (\n action = 'DELETE'\n and realtime.is_visible_through_filters(old_columns, subs.filters)\n )\n )\n ) loop\n\n if not is_rls_enabled or action = 'DELETE' then\n visible_role_sub_ids = visible_role_sub_ids || subscription_id;\n else\n -- Check if RLS allows the role to see the record\n perform\n -- Trim leading and trailing quotes from working_role because set_config\n -- doesn't recognize the role as valid if they are included\n set_config('role', trim(both '\"' from working_role::text), true),\n set_config('request.jwt.claims', claims::text, true);\n\n execute 'execute walrus_rls_stmt' into subscription_has_access;\n\n if subscription_has_access then\n visible_role_sub_ids = visible_role_sub_ids || subscription_id;\n end if;\n end if;\n end loop;\n\n perform set_config('role', null, true);\n\n -- Inner loop: per distinct selected_columns for this role\n for cols_record in\n select selected_columns\n from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t\n order by coalesce(array_to_string(selected_columns, ','), '')\n loop\n working_selected_columns := cols_record.selected_columns;\n\n output = jsonb_build_object(\n 'schema', wal ->> 'schema',\n 'table', wal ->> 'table',\n 'type', action,\n 'commit_timestamp', to_char(\n ((wal ->> 'timestamp')::timestamptz at time zone 'utc'),\n 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'\n ),\n 'columns', (\n select\n jsonb_agg(\n jsonb_build_object(\n 'name', pa.attname,\n 'type', pt.typname\n )\n order by pa.attnum asc\n )\n from\n pg_attribute pa\n join pg_type pt\n on pa.atttypid = pt.oid\n left join (\n select unnest(conkey) as pkey_attnum\n from pg_constraint\n where conrelid = entity_ and contype = 'p'\n ) pk on pk.pkey_attnum = pa.attnum\n where\n attrelid = entity_\n and attnum > 0\n and pg_catalog.has_column_privilege(working_role, entity_, pa.attname, 'SELECT')\n and (working_selected_columns is null or pa.attname = any(working_selected_columns) or pk.pkey_attnum is not null)\n )\n )\n -- Add \"record\" key for insert and update\n || case\n when action in ('INSERT', 'UPDATE') then\n jsonb_build_object(\n 'record',\n (\n select\n jsonb_object_agg(\n -- if unchanged toast, get column name and value from old record\n coalesce((c).name, (oc).name),\n case\n when (c).name is null then (oc).value\n else (c).value\n end\n )\n from\n unnest(columns) c\n full outer join unnest(old_columns) oc\n on (c).name = (oc).name\n where\n coalesce((c).is_selectable, (oc).is_selectable)\n and (working_selected_columns is null or coalesce((c).name, (oc).name) = any(working_selected_columns) or coalesce((c).is_pkey, (oc).is_pkey))\n and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))\n )\n )\n else '{}'::jsonb\n end\n -- Add \"old_record\" key for update and delete\n || case\n when action = 'UPDATE' then\n jsonb_build_object(\n 'old_record',\n (\n select jsonb_object_agg((c).name, (c).value)\n from unnest(old_columns) c\n where\n (c).is_selectable\n and (working_selected_columns is null or (c).name = any(working_selected_columns) or (c).is_pkey)\n and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))\n )\n )\n when action = 'DELETE' then\n jsonb_build_object(\n 'old_record',\n (\n select jsonb_object_agg((c).name, (c).value)\n from unnest(old_columns) c\n where\n (c).is_selectable\n and (working_selected_columns is null or (c).name = any(working_selected_columns) or (c).is_pkey)\n and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))\n and ( not is_rls_enabled or (c).is_pkey ) -- if RLS enabled, we can't secure deletes so filter to pkey\n )\n )\n else '{}'::jsonb\n end;\n\n -- Filter visible_role_sub_ids to those matching the current selected_columns group\n visible_to_subscription_ids = coalesce(\n (\n select array_agg(s.subscription_id)\n from unnest(subscriptions) s\n where s.claims_role = working_role\n and (s.selected_columns is not distinct from working_selected_columns)\n and s.subscription_id = any(visible_role_sub_ids)\n ),\n '{}'::uuid[]\n );\n\n return next (\n output,\n is_rls_enabled,\n visible_to_subscription_ids,\n case\n when error_record_exceeds_max_size then array['Error 413: Payload Too Large']\n else '{}'\n end\n )::realtime.wal_rls;\n end loop;\n\n end if;\n end loop;\n\n perform set_config('role', null, true);\nend;\n$function$\n", + "config": null, + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "anon", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "authenticated", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "service_role", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + }, + "procedure:realtime.broadcast_changes(text,text,text,text,text,record,record,text)": { + "schema": "realtime", + "name": "broadcast_changes", + "kind": "f", + "return_type": "void", + "return_type_schema": "pg_catalog", + "language": "plpgsql", + "security_definer": false, + "volatility": "v", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 0, + "is_strict": false, + "leakproof": false, + "returns_set": false, + "argument_count": 8, + "argument_default_count": 1, + "argument_names": [ + "topic_name", + "event_name", + "operation", + "table_name", + "table_schema", + "new", + "old", + "level" + ], + "argument_types": [ + "text", + "text", + "text", + "text", + "text", + "record", + "record", + "text" + ], + "all_argument_types": [], + "argument_modes": null, + "argument_defaults": "'ROW'::text", + "source_code": "\nDECLARE\n -- Declare a variable to hold the JSONB representation of the row\n row_data jsonb := '{}'::jsonb;\nBEGIN\n IF level = 'STATEMENT' THEN\n RAISE EXCEPTION 'function can only be triggered for each row, not for each statement';\n END IF;\n -- Check the operation type and handle accordingly\n IF operation = 'INSERT' OR operation = 'UPDATE' OR operation = 'DELETE' THEN\n row_data := jsonb_build_object('old_record', OLD, 'record', NEW, 'operation', operation, 'table', table_name, 'schema', table_schema);\n PERFORM realtime.send (row_data, event_name, topic_name);\n ELSE\n RAISE EXCEPTION 'Unexpected operation type: %', operation;\n END IF;\nEXCEPTION\n WHEN OTHERS THEN\n RAISE EXCEPTION 'Failed to process the row: %', SQLERRM;\nEND;\n\n", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.broadcast_changes(topic_name text, event_name text, operation text, table_name text, table_schema text, new record, old record, level text DEFAULT 'ROW'::text)\n RETURNS void\n LANGUAGE plpgsql\nAS $function$\nDECLARE\n -- Declare a variable to hold the JSONB representation of the row\n row_data jsonb := '{}'::jsonb;\nBEGIN\n IF level = 'STATEMENT' THEN\n RAISE EXCEPTION 'function can only be triggered for each row, not for each statement';\n END IF;\n -- Check the operation type and handle accordingly\n IF operation = 'INSERT' OR operation = 'UPDATE' OR operation = 'DELETE' THEN\n row_data := jsonb_build_object('old_record', OLD, 'record', NEW, 'operation', operation, 'table', table_name, 'schema', table_schema);\n PERFORM realtime.send (row_data, event_name, topic_name);\n ELSE\n RAISE EXCEPTION 'Unexpected operation type: %', operation;\n END IF;\nEXCEPTION\n WHEN OTHERS THEN\n RAISE EXCEPTION 'Failed to process the row: %', SQLERRM;\nEND;\n\n$function$\n", + "config": null, + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + }, + "procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])": { + "schema": "realtime", + "name": "build_prepared_statement_sql", + "kind": "f", + "return_type": "text", + "return_type_schema": "pg_catalog", + "language": "sql", + "security_definer": false, + "volatility": "v", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 0, + "is_strict": false, + "leakproof": false, + "returns_set": false, + "argument_count": 3, + "argument_default_count": 0, + "argument_names": [ + "prepared_statement_name", + "entity", + "columns" + ], + "argument_types": [ + "text", + "regclass", + "realtime.wal_column[]" + ], + "all_argument_types": [], + "argument_modes": null, + "argument_defaults": null, + "source_code": "\n /*\n Builds a sql string that, if executed, creates a prepared statement to\n tests retrive a row from *entity* by its primary key columns.\n Example\n select realtime.build_prepared_statement_sql('public.notes', '{\"id\"}'::text[], '{\"bigint\"}'::text[])\n */\n select\n 'prepare ' || prepared_statement_name || ' as\n select\n exists(\n select\n 1\n from\n ' || entity || '\n where\n ' || string_agg(quote_ident(pkc.name) || '=' || quote_nullable(pkc.value #>> '{}') , ' and ') || '\n )'\n from\n unnest(columns) pkc\n where\n pkc.is_pkey\n group by\n entity\n ", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.build_prepared_statement_sql(prepared_statement_name text, entity regclass, columns realtime.wal_column[])\n RETURNS text\n LANGUAGE sql\nAS $function$\n /*\n Builds a sql string that, if executed, creates a prepared statement to\n tests retrive a row from *entity* by its primary key columns.\n Example\n select realtime.build_prepared_statement_sql('public.notes', '{\"id\"}'::text[], '{\"bigint\"}'::text[])\n */\n select\n 'prepare ' || prepared_statement_name || ' as\n select\n exists(\n select\n 1\n from\n ' || entity || '\n where\n ' || string_agg(quote_ident(pkc.name) || '=' || quote_nullable(pkc.value #>> '{}') , ' and ') || '\n )'\n from\n unnest(columns) pkc\n where\n pkc.is_pkey\n group by\n entity\n $function$\n", + "config": null, + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "anon", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "authenticated", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "service_role", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + }, + "procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)": { + "schema": "realtime", + "name": "check_equality_op", + "kind": "f", + "return_type": "boolean", + "return_type_schema": "pg_catalog", + "language": "plpgsql", + "security_definer": false, + "volatility": "i", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 0, + "is_strict": false, + "leakproof": false, + "returns_set": false, + "argument_count": 4, + "argument_default_count": 0, + "argument_names": [ + "op", + "type_", + "val_1", + "val_2" + ], + "argument_types": [ + "realtime.equality_op", + "regtype", + "text", + "text" + ], + "all_argument_types": [], + "argument_modes": null, + "argument_defaults": null, + "source_code": "\n /*\n Casts *val_1* and *val_2* as type *type_* and check the *op* condition for truthiness\n */\n declare\n op_symbol text = (\n case\n when op = 'eq' then '='\n when op = 'neq' then '!='\n when op = 'lt' then '<'\n when op = 'lte' then '<='\n when op = 'gt' then '>'\n when op = 'gte' then '>='\n when op = 'in' then '= any'\n else 'UNKNOWN OP'\n end\n );\n res boolean;\n begin\n execute format(\n 'select %L::'|| type_::text || ' ' || op_symbol\n || ' ( %L::'\n || (\n case\n when op = 'in' then type_::text || '[]'\n else type_::text end\n )\n || ')', val_1, val_2) into res;\n return res;\n end;\n ", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.check_equality_op(op realtime.equality_op, type_ regtype, val_1 text, val_2 text)\n RETURNS boolean\n LANGUAGE plpgsql\n IMMUTABLE\nAS $function$\n /*\n Casts *val_1* and *val_2* as type *type_* and check the *op* condition for truthiness\n */\n declare\n op_symbol text = (\n case\n when op = 'eq' then '='\n when op = 'neq' then '!='\n when op = 'lt' then '<'\n when op = 'lte' then '<='\n when op = 'gt' then '>'\n when op = 'gte' then '>='\n when op = 'in' then '= any'\n else 'UNKNOWN OP'\n end\n );\n res boolean;\n begin\n execute format(\n 'select %L::'|| type_::text || ' ' || op_symbol\n || ' ( %L::'\n || (\n case\n when op = 'in' then type_::text || '[]'\n else type_::text end\n )\n || ')', val_1, val_2) into res;\n return res;\n end;\n $function$\n", + "config": null, + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "anon", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "authenticated", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "service_role", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + }, + "procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])": { + "schema": "realtime", + "name": "is_visible_through_filters", + "kind": "f", + "return_type": "boolean", + "return_type_schema": "pg_catalog", + "language": "sql", + "security_definer": false, + "volatility": "i", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 0, + "is_strict": false, + "leakproof": false, + "returns_set": false, + "argument_count": 2, + "argument_default_count": 0, + "argument_names": [ + "columns", + "filters" + ], + "argument_types": [ + "realtime.wal_column[]", + "realtime.user_defined_filter[]" + ], + "all_argument_types": [], + "argument_modes": null, + "argument_defaults": null, + "source_code": "\n /*\n Should the record be visible (true) or filtered out (false) after *filters* are applied\n */\n select\n -- Default to allowed when no filters present\n $2 is null -- no filters. this should not happen because subscriptions has a default\n or array_length($2, 1) is null -- array length of an empty array is null\n or bool_and(\n coalesce(\n realtime.check_equality_op(\n op:=f.op,\n type_:=coalesce(\n col.type_oid::regtype, -- null when wal2json version <= 2.4\n col.type_name::regtype\n ),\n -- cast jsonb to text\n val_1:=col.value #>> '{}',\n val_2:=f.value\n ),\n false -- if null, filter does not match\n )\n )\n from\n unnest(filters) f\n join unnest(columns) col\n on f.column_name = col.name;\n ", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[])\n RETURNS boolean\n LANGUAGE sql\n IMMUTABLE\nAS $function$\n /*\n Should the record be visible (true) or filtered out (false) after *filters* are applied\n */\n select\n -- Default to allowed when no filters present\n $2 is null -- no filters. this should not happen because subscriptions has a default\n or array_length($2, 1) is null -- array length of an empty array is null\n or bool_and(\n coalesce(\n realtime.check_equality_op(\n op:=f.op,\n type_:=coalesce(\n col.type_oid::regtype, -- null when wal2json version <= 2.4\n col.type_name::regtype\n ),\n -- cast jsonb to text\n val_1:=col.value #>> '{}',\n val_2:=f.value\n ),\n false -- if null, filter does not match\n )\n )\n from\n unnest(filters) f\n join unnest(columns) col\n on f.column_name = col.name;\n $function$\n", + "config": null, + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "anon", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "authenticated", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "service_role", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + }, + "procedure:realtime.list_changes(name,name,integer,integer)": { + "schema": "realtime", + "name": "list_changes", + "kind": "f", + "return_type": "record", + "return_type_schema": "pg_catalog", + "language": "sql", + "security_definer": false, + "volatility": "v", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 1000, + "is_strict": false, + "leakproof": false, + "returns_set": true, + "argument_count": 4, + "argument_default_count": 0, + "argument_names": [ + "publication", + "slot_name", + "max_changes", + "max_record_bytes", + "wal", + "is_rls_enabled", + "subscription_ids", + "errors", + "slot_changes_count" + ], + "argument_types": [ + "name", + "name", + "integer", + "integer" + ], + "all_argument_types": [ + "name", + "name", + "integer", + "integer", + "jsonb", + "boolean", + "uuid[]", + "text[]", + "bigint" + ], + "argument_modes": [ + "i", + "i", + "i", + "i", + "t", + "t", + "t", + "t", + "t" + ], + "argument_defaults": null, + "source_code": "\n WITH pub AS (\n SELECT\n concat_ws(\n ',',\n CASE WHEN bool_or(pubinsert) THEN 'insert' ELSE NULL END,\n CASE WHEN bool_or(pubupdate) THEN 'update' ELSE NULL END,\n CASE WHEN bool_or(pubdelete) THEN 'delete' ELSE NULL END\n ) AS w2j_actions,\n coalesce(\n string_agg(\n realtime.quote_wal2json(format('%I.%I', schemaname, tablename)::regclass),\n ','\n ) filter (WHERE ppt.tablename IS NOT NULL),\n ''\n ) AS w2j_add_tables\n FROM pg_publication pp\n LEFT JOIN pg_publication_tables ppt ON pp.pubname = ppt.pubname\n WHERE pp.pubname = publication\n GROUP BY pp.pubname\n LIMIT 1\n ),\n -- MATERIALIZED ensures pg_logical_slot_get_changes is called exactly once\n w2j AS MATERIALIZED (\n SELECT x.*, pub.w2j_add_tables\n FROM pub,\n pg_logical_slot_get_changes(\n slot_name, null, max_changes,\n 'include-pk', 'true',\n 'include-transaction', 'false',\n 'include-timestamp', 'true',\n 'include-type-oids', 'true',\n 'format-version', '2',\n 'actions', pub.w2j_actions,\n 'add-tables', pub.w2j_add_tables\n ) x\n ),\n slot_count AS (\n SELECT count(*)::bigint AS cnt\n FROM w2j\n WHERE w2j.w2j_add_tables <> ''\n ),\n rls_filtered AS (\n SELECT xyz.wal, xyz.is_rls_enabled, xyz.subscription_ids, xyz.errors\n FROM w2j,\n realtime.apply_rls(\n wal := w2j.data::jsonb,\n max_record_bytes := max_record_bytes\n ) xyz(wal, is_rls_enabled, subscription_ids, errors)\n WHERE w2j.w2j_add_tables <> ''\n AND xyz.subscription_ids[1] IS NOT NULL\n )\n SELECT rf.wal, rf.is_rls_enabled, rf.subscription_ids, rf.errors, sc.cnt\n FROM rls_filtered rf, slot_count sc\n\n UNION ALL\n\n SELECT null, null, null, null, sc.cnt\n FROM slot_count sc\n WHERE NOT EXISTS (SELECT 1 FROM rls_filtered)\n", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.list_changes(publication name, slot_name name, max_changes integer, max_record_bytes integer)\n RETURNS TABLE(wal jsonb, is_rls_enabled boolean, subscription_ids uuid[], errors text[], slot_changes_count bigint)\n LANGUAGE sql\n SET log_min_messages TO 'fatal'\nAS $function$\n WITH pub AS (\n SELECT\n concat_ws(\n ',',\n CASE WHEN bool_or(pubinsert) THEN 'insert' ELSE NULL END,\n CASE WHEN bool_or(pubupdate) THEN 'update' ELSE NULL END,\n CASE WHEN bool_or(pubdelete) THEN 'delete' ELSE NULL END\n ) AS w2j_actions,\n coalesce(\n string_agg(\n realtime.quote_wal2json(format('%I.%I', schemaname, tablename)::regclass),\n ','\n ) filter (WHERE ppt.tablename IS NOT NULL),\n ''\n ) AS w2j_add_tables\n FROM pg_publication pp\n LEFT JOIN pg_publication_tables ppt ON pp.pubname = ppt.pubname\n WHERE pp.pubname = publication\n GROUP BY pp.pubname\n LIMIT 1\n ),\n -- MATERIALIZED ensures pg_logical_slot_get_changes is called exactly once\n w2j AS MATERIALIZED (\n SELECT x.*, pub.w2j_add_tables\n FROM pub,\n pg_logical_slot_get_changes(\n slot_name, null, max_changes,\n 'include-pk', 'true',\n 'include-transaction', 'false',\n 'include-timestamp', 'true',\n 'include-type-oids', 'true',\n 'format-version', '2',\n 'actions', pub.w2j_actions,\n 'add-tables', pub.w2j_add_tables\n ) x\n ),\n slot_count AS (\n SELECT count(*)::bigint AS cnt\n FROM w2j\n WHERE w2j.w2j_add_tables <> ''\n ),\n rls_filtered AS (\n SELECT xyz.wal, xyz.is_rls_enabled, xyz.subscription_ids, xyz.errors\n FROM w2j,\n realtime.apply_rls(\n wal := w2j.data::jsonb,\n max_record_bytes := max_record_bytes\n ) xyz(wal, is_rls_enabled, subscription_ids, errors)\n WHERE w2j.w2j_add_tables <> ''\n AND xyz.subscription_ids[1] IS NOT NULL\n )\n SELECT rf.wal, rf.is_rls_enabled, rf.subscription_ids, rf.errors, sc.cnt\n FROM rls_filtered rf, slot_count sc\n\n UNION ALL\n\n SELECT null, null, null, null, sc.cnt\n FROM slot_count sc\n WHERE NOT EXISTS (SELECT 1 FROM rls_filtered)\n$function$\n", + "config": [ + "log_min_messages=fatal" + ], + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + }, + "procedure:realtime.quote_wal2json(regclass)": { + "schema": "realtime", + "name": "quote_wal2json", + "kind": "f", + "return_type": "text", + "return_type_schema": "pg_catalog", + "language": "sql", + "security_definer": false, + "volatility": "i", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 0, + "is_strict": true, + "leakproof": false, + "returns_set": false, + "argument_count": 1, + "argument_default_count": 0, + "argument_names": [ + "entity" + ], + "argument_types": [ + "regclass" + ], + "all_argument_types": [], + "argument_modes": null, + "argument_defaults": null, + "source_code": "\n SELECT\n realtime.wal2json_escape_identifier(nsp.nspname::text)\n || '.'\n || realtime.wal2json_escape_identifier(pc.relname::text)\n FROM pg_class pc\n JOIN pg_namespace nsp ON pc.relnamespace = nsp.oid\n WHERE pc.oid = entity\n", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.quote_wal2json(entity regclass)\n RETURNS text\n LANGUAGE sql\n IMMUTABLE STRICT\nAS $function$\n SELECT\n realtime.wal2json_escape_identifier(nsp.nspname::text)\n || '.'\n || realtime.wal2json_escape_identifier(pc.relname::text)\n FROM pg_class pc\n JOIN pg_namespace nsp ON pc.relnamespace = nsp.oid\n WHERE pc.oid = entity\n$function$\n", + "config": null, + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "anon", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "authenticated", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "service_role", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + }, + "procedure:realtime.send(jsonb,text,text,boolean)": { + "schema": "realtime", + "name": "send", + "kind": "f", + "return_type": "void", + "return_type_schema": "pg_catalog", + "language": "plpgsql", + "security_definer": false, + "volatility": "v", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 0, + "is_strict": false, + "leakproof": false, + "returns_set": false, + "argument_count": 4, + "argument_default_count": 1, + "argument_names": [ + "payload", + "event", + "topic", + "private" + ], + "argument_types": [ + "jsonb", + "text", + "text", + "boolean" + ], + "all_argument_types": [], + "argument_modes": null, + "argument_defaults": "true", + "source_code": "\nDECLARE\n generated_id uuid;\n final_payload jsonb;\nBEGIN\n BEGIN\n generated_id := gen_random_uuid();\n\n -- Check if payload has an 'id' key, if not, add the generated UUID\n IF payload ? 'id' THEN\n final_payload := payload;\n ELSE\n final_payload := jsonb_set(payload, '{id}', to_jsonb(generated_id));\n END IF;\n\n -- Set the topic configuration\n EXECUTE format('SET LOCAL realtime.topic TO %L', topic);\n\n INSERT INTO realtime.messages (id, payload, event, topic, private, extension)\n VALUES (generated_id, final_payload, event, topic, private, 'broadcast');\n EXCEPTION\n WHEN OTHERS THEN\n RAISE WARNING 'WarnSendingBroadcastMessage: %', SQLERRM;\n END;\nEND;\n", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true)\n RETURNS void\n LANGUAGE plpgsql\nAS $function$\nDECLARE\n generated_id uuid;\n final_payload jsonb;\nBEGIN\n BEGIN\n generated_id := gen_random_uuid();\n\n -- Check if payload has an 'id' key, if not, add the generated UUID\n IF payload ? 'id' THEN\n final_payload := payload;\n ELSE\n final_payload := jsonb_set(payload, '{id}', to_jsonb(generated_id));\n END IF;\n\n -- Set the topic configuration\n EXECUTE format('SET LOCAL realtime.topic TO %L', topic);\n\n INSERT INTO realtime.messages (id, payload, event, topic, private, extension)\n VALUES (generated_id, final_payload, event, topic, private, 'broadcast');\n EXCEPTION\n WHEN OTHERS THEN\n RAISE WARNING 'WarnSendingBroadcastMessage: %', SQLERRM;\n END;\nEND;\n$function$\n", + "config": null, + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + }, + "procedure:realtime.send_binary(bytea,text,text,boolean)": { + "schema": "realtime", + "name": "send_binary", + "kind": "f", + "return_type": "void", + "return_type_schema": "pg_catalog", + "language": "plpgsql", + "security_definer": false, + "volatility": "v", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 0, + "is_strict": false, + "leakproof": false, + "returns_set": false, + "argument_count": 4, + "argument_default_count": 1, + "argument_names": [ + "payload", + "event", + "topic", + "private" + ], + "argument_types": [ + "bytea", + "text", + "text", + "boolean" + ], + "all_argument_types": [], + "argument_modes": null, + "argument_defaults": "true", + "source_code": "\nDECLARE\n generated_id uuid;\nBEGIN\n BEGIN\n generated_id := gen_random_uuid();\n\n EXECUTE format('SET LOCAL realtime.topic TO %L', topic);\n\n INSERT INTO realtime.messages (id, binary_payload, event, topic, private, extension)\n VALUES (generated_id, payload, event, topic, private, 'broadcast');\n EXCEPTION\n WHEN OTHERS THEN\n RAISE WARNING 'WarnSendingBroadcastMessage: %', SQLERRM;\n END;\nEND;\n", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.send_binary(payload bytea, event text, topic text, private boolean DEFAULT true)\n RETURNS void\n LANGUAGE plpgsql\nAS $function$\nDECLARE\n generated_id uuid;\nBEGIN\n BEGIN\n generated_id := gen_random_uuid();\n\n EXECUTE format('SET LOCAL realtime.topic TO %L', topic);\n\n INSERT INTO realtime.messages (id, binary_payload, event, topic, private, extension)\n VALUES (generated_id, payload, event, topic, private, 'broadcast');\n EXCEPTION\n WHEN OTHERS THEN\n RAISE WARNING 'WarnSendingBroadcastMessage: %', SQLERRM;\n END;\nEND;\n$function$\n", + "config": null, + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + }, + "procedure:realtime.subscription_check_filters()": { + "schema": "realtime", + "name": "subscription_check_filters", + "kind": "f", + "return_type": "trigger", + "return_type_schema": "pg_catalog", + "language": "plpgsql", + "security_definer": false, + "volatility": "v", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 0, + "is_strict": false, + "leakproof": false, + "returns_set": false, + "argument_count": 0, + "argument_default_count": 0, + "argument_names": null, + "argument_types": [], + "all_argument_types": [], + "argument_modes": null, + "argument_defaults": null, + "source_code": "\ndeclare\n col_names text[] = coalesce(\n array_agg(a.attname order by a.attnum),\n '{}'::text[]\n )\n from\n pg_catalog.pg_attribute a\n where\n a.attrelid = new.entity\n and a.attnum > 0\n and not a.attisdropped\n and pg_catalog.has_column_privilege(\n (new.claims ->> 'role'),\n a.attrelid,\n a.attnum,\n 'SELECT'\n );\n filter realtime.user_defined_filter;\n col_type regtype;\n in_val jsonb;\n selected_col text;\nbegin\n for filter in select * from unnest(new.filters) loop\n if not filter.column_name = any(col_names) then\n raise exception 'invalid column for filter %', filter.column_name;\n end if;\n\n col_type = (\n select atttypid::regtype\n from pg_catalog.pg_attribute\n where attrelid = new.entity\n and attname = filter.column_name\n );\n if col_type is null then\n raise exception 'failed to lookup type for column %', filter.column_name;\n end if;\n\n if filter.op = 'in'::realtime.equality_op then\n in_val = realtime.cast(filter.value, (col_type::text || '[]')::regtype);\n if coalesce(jsonb_array_length(in_val), 0) > 100 then\n raise exception 'too many values for `in` filter. Maximum 100';\n end if;\n else\n perform realtime.cast(filter.value, col_type);\n end if;\n end loop;\n\n if new.selected_columns is not null then\n for selected_col in select * from unnest(new.selected_columns) loop\n if not selected_col = any(col_names) then\n raise exception 'invalid column for select %', selected_col;\n end if;\n end loop;\n end if;\n\n new.filters = coalesce(\n array_agg(f order by f.column_name, f.op, f.value),\n '{}'\n ) from unnest(new.filters) f;\n\n new.selected_columns = (\n select array_agg(c order by c)\n from unnest(new.selected_columns) c\n );\n\n return new;\nend;\n", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.subscription_check_filters()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\ndeclare\n col_names text[] = coalesce(\n array_agg(a.attname order by a.attnum),\n '{}'::text[]\n )\n from\n pg_catalog.pg_attribute a\n where\n a.attrelid = new.entity\n and a.attnum > 0\n and not a.attisdropped\n and pg_catalog.has_column_privilege(\n (new.claims ->> 'role'),\n a.attrelid,\n a.attnum,\n 'SELECT'\n );\n filter realtime.user_defined_filter;\n col_type regtype;\n in_val jsonb;\n selected_col text;\nbegin\n for filter in select * from unnest(new.filters) loop\n if not filter.column_name = any(col_names) then\n raise exception 'invalid column for filter %', filter.column_name;\n end if;\n\n col_type = (\n select atttypid::regtype\n from pg_catalog.pg_attribute\n where attrelid = new.entity\n and attname = filter.column_name\n );\n if col_type is null then\n raise exception 'failed to lookup type for column %', filter.column_name;\n end if;\n\n if filter.op = 'in'::realtime.equality_op then\n in_val = realtime.cast(filter.value, (col_type::text || '[]')::regtype);\n if coalesce(jsonb_array_length(in_val), 0) > 100 then\n raise exception 'too many values for `in` filter. Maximum 100';\n end if;\n else\n perform realtime.cast(filter.value, col_type);\n end if;\n end loop;\n\n if new.selected_columns is not null then\n for selected_col in select * from unnest(new.selected_columns) loop\n if not selected_col = any(col_names) then\n raise exception 'invalid column for select %', selected_col;\n end if;\n end loop;\n end if;\n\n new.filters = coalesce(\n array_agg(f order by f.column_name, f.op, f.value),\n '{}'\n ) from unnest(new.filters) f;\n\n new.selected_columns = (\n select array_agg(c order by c)\n from unnest(new.selected_columns) c\n );\n\n return new;\nend;\n$function$\n", + "config": null, + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "anon", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "authenticated", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "service_role", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + }, + "procedure:realtime.to_regrole(text)": { + "schema": "realtime", + "name": "to_regrole", + "kind": "f", + "return_type": "regrole", + "return_type_schema": "pg_catalog", + "language": "sql", + "security_definer": false, + "volatility": "i", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 0, + "is_strict": false, + "leakproof": false, + "returns_set": false, + "argument_count": 1, + "argument_default_count": 0, + "argument_names": [ + "role_name" + ], + "argument_types": [ + "text" + ], + "all_argument_types": [], + "argument_modes": null, + "argument_defaults": null, + "source_code": " select role_name::regrole ", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.to_regrole(role_name text)\n RETURNS regrole\n LANGUAGE sql\n IMMUTABLE\nAS $function$ select role_name::regrole $function$\n", + "config": null, + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "anon", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "authenticated", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "service_role", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + }, + "procedure:realtime.topic()": { + "schema": "realtime", + "name": "topic", + "kind": "f", + "return_type": "text", + "return_type_schema": "pg_catalog", + "language": "sql", + "security_definer": false, + "volatility": "s", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 0, + "is_strict": false, + "leakproof": false, + "returns_set": false, + "argument_count": 0, + "argument_default_count": 0, + "argument_names": null, + "argument_types": [], + "all_argument_types": [], + "argument_modes": null, + "argument_defaults": null, + "source_code": "\nselect nullif(current_setting('realtime.topic', true), '')::text;\n", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.topic()\n RETURNS text\n LANGUAGE sql\n STABLE\nAS $function$\nselect nullif(current_setting('realtime.topic', true), '')::text;\n$function$\n", + "config": null, + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + }, + "procedure:realtime.wal2json_escape_identifier(text)": { + "schema": "realtime", + "name": "wal2json_escape_identifier", + "kind": "f", + "return_type": "text", + "return_type_schema": "pg_catalog", + "language": "sql", + "security_definer": false, + "volatility": "i", + "parallel_safety": "u", + "execution_cost": 100, + "result_rows": 0, + "is_strict": true, + "leakproof": false, + "returns_set": false, + "argument_count": 1, + "argument_default_count": 0, + "argument_names": [ + "name" + ], + "argument_types": [ + "text" + ], + "all_argument_types": [], + "argument_modes": null, + "argument_defaults": null, + "source_code": "\n -- Prefix `\\`, `,`, `.`, and any whitespace with `\\`\n SELECT regexp_replace(name, '([\\\\,.[:space:]])', '\\\\\\1', 'g')\n", + "binary_path": null, + "sql_body": null, + "definition": "CREATE OR REPLACE FUNCTION realtime.wal2json_escape_identifier(name text)\n RETURNS text\n LANGUAGE sql\n IMMUTABLE STRICT\nAS $function$\n -- Prefix `\\`, `,`, `.`, and any whitespace with `\\`\n SELECT regexp_replace(name, '([\\\\,.[:space:]])', '\\\\\\1', 'g')\n$function$\n", + "config": null, + "owner": "supabase_realtime_admin", + "comment": null, + "privileges": [ + { + "grantee": "PUBLIC", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "dashboard_user", + "privilege": "EXECUTE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "EXECUTE", + "grantable": false + } + ], + "security_labels": [] + } + }, + "indexes": { + "index:realtime.messages.messages_inserted_at_topic_index": { + "schema": "realtime", + "table_name": "messages", + "name": "messages_inserted_at_topic_index", + "storage_params": [], + "statistics_target": [ + -1, + -1 + ], + "index_type": "btree", + "tablespace": null, + "is_unique": false, + "is_primary": false, + "is_exclusion": false, + "nulls_not_distinct": false, + "immediate": true, + "is_clustered": false, + "is_replica_identity": false, + "key_columns": [ + 9, + 3 + ], + "column_collations": [ + null, + null + ], + "operator_classes": [ + "default", + "default" + ], + "column_options": [ + 3, + 0 + ], + "index_expressions": null, + "partial_predicate": "((extension = 'broadcast'::text) AND (private IS TRUE))", + "table_relkind": "p", + "is_owned_by_constraint": false, + "is_partitioned_index": true, + "is_index_partition": false, + "parent_index_name": null, + "definition": "CREATE INDEX messages_inserted_at_topic_index ON ONLY realtime.messages USING btree (inserted_at DESC, topic) WHERE extension = 'broadcast'::text AND private IS TRUE", + "comment": null, + "owner": "supabase_realtime_admin" + }, + "index:realtime.subscription.ix_realtime_subscription_entity": { + "schema": "realtime", + "table_name": "subscription", + "name": "ix_realtime_subscription_entity", + "storage_params": [], + "statistics_target": [ + -1 + ], + "index_type": "btree", + "tablespace": null, + "is_unique": false, + "is_primary": false, + "is_exclusion": false, + "nulls_not_distinct": false, + "immediate": true, + "is_clustered": false, + "is_replica_identity": false, + "key_columns": [ + 4 + ], + "column_collations": [ + null + ], + "operator_classes": [ + "default" + ], + "column_options": [ + 0 + ], + "index_expressions": null, + "partial_predicate": null, + "table_relkind": "r", + "is_owned_by_constraint": false, + "is_partitioned_index": false, + "is_index_partition": false, + "parent_index_name": null, + "definition": "CREATE INDEX ix_realtime_subscription_entity ON realtime.subscription USING btree (entity)", + "comment": null, + "owner": "supabase_realtime_admin" + }, + "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec": { + "schema": "realtime", + "table_name": "subscription", + "name": "subscription_subscription_id_entity_filters_action_filter_selec", + "storage_params": [], + "statistics_target": [ + -1, + -1, + -1, + -1, + -1 + ], + "index_type": "btree", + "tablespace": null, + "is_unique": true, + "is_primary": false, + "is_exclusion": false, + "nulls_not_distinct": false, + "immediate": true, + "is_clustered": false, + "is_replica_identity": false, + "key_columns": [ + 2, + 4, + 5, + 10, + 0 + ], + "column_collations": [ + null, + null, + null, + null, + null + ], + "operator_classes": [ + "default", + "default", + "pg_catalog.array_ops", + "default", + "pg_catalog.array_ops" + ], + "column_options": [ + 0, + 0, + 0, + 0, + 0 + ], + "index_expressions": "COALESCE(selected_columns, '{}'::text[])", + "partial_predicate": null, + "table_relkind": "r", + "is_owned_by_constraint": false, + "is_partitioned_index": false, + "is_index_partition": false, + "parent_index_name": null, + "definition": "CREATE UNIQUE INDEX subscription_subscription_id_entity_filters_action_filter_selec ON realtime.subscription USING btree (subscription_id, entity, filters, action_filter, COALESCE(selected_columns, '{}'::text[]))", + "comment": null, + "owner": "supabase_realtime_admin" + } + }, + "materializedViews": {}, + "subscriptions": {}, + "publications": {}, + "rlsPolicies": {}, + "roles": {}, + "schemas": { + "schema:realtime": { + "name": "realtime", + "owner": "supabase_admin", + "comment": null, + "privileges": [ + { + "grantee": "supabase_admin", + "privilege": "CREATE", + "grantable": false + }, + { + "grantee": "supabase_admin", + "privilege": "USAGE", + "grantable": false + }, + { + "grantee": "postgres", + "privilege": "USAGE", + "grantable": true + }, + { + "grantee": "anon", + "privilege": "USAGE", + "grantable": false + }, + { + "grantee": "authenticated", + "privilege": "USAGE", + "grantable": false + }, + { + "grantee": "service_role", + "privilege": "USAGE", + "grantable": false + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "CREATE", + "grantable": true + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "USAGE", + "grantable": true + } + ], + "security_labels": [] + } + }, + "sequences": {}, + "tables": { + "table:realtime.messages": { + "schema": "realtime", + "name": "messages", + "persistence": "p", + "row_security": true, + "force_row_security": false, + "has_indexes": true, + "has_rules": false, + "has_triggers": false, + "has_subclasses": false, + "is_populated": true, + "replica_identity": "d", + "replica_identity_index": null, + "is_partition": false, + "options": null, + "partition_bound": null, + "partition_by": "RANGE (inserted_at)", + "owner": "supabase_realtime_admin", + "comment": null, + "parent_schema": null, + "parent_name": null, + "columns": [ + { + "name": "topic", + "position": 3, + "data_type": "text", + "data_type_str": "text", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": true, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null, + "security_labels": [] + }, + { + "name": "extension", + "position": 4, + "data_type": "text", + "data_type_str": "text", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": true, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null, + "security_labels": [] + }, + { + "name": "payload", + "position": 5, + "data_type": "jsonb", + "data_type_str": "jsonb", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null, + "security_labels": [] + }, + { + "name": "event", + "position": 6, + "data_type": "text", + "data_type_str": "text", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null, + "security_labels": [] + }, + { + "name": "private", + "position": 7, + "data_type": "boolean", + "data_type_str": "boolean", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": "false", + "comment": null, + "security_labels": [] + }, + { + "name": "updated_at", + "position": 8, + "data_type": "timestamp without time zone", + "data_type_str": "timestamp without time zone", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": true, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": "now()", + "comment": null, + "security_labels": [] + }, + { + "name": "inserted_at", + "position": 9, + "data_type": "timestamp without time zone", + "data_type_str": "timestamp without time zone", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": true, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": "now()", + "comment": null, + "security_labels": [] + }, + { + "name": "id", + "position": 10, + "data_type": "uuid", + "data_type_str": "uuid", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": true, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": "gen_random_uuid()", + "comment": null, + "security_labels": [] + }, + { + "name": "binary_payload", + "position": 11, + "data_type": "bytea", + "data_type_str": "bytea", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null, + "security_labels": [] + } + ], + "constraints": [ + { + "name": "messages_payload_exclusive", + "constraint_type": "c", + "deferrable": false, + "initially_deferred": false, + "validated": false, + "is_local": true, + "no_inherit": false, + "is_temporal": false, + "is_partition_clone": false, + "parent_constraint_schema": null, + "parent_constraint_name": null, + "parent_table_schema": null, + "parent_table_name": null, + "key_columns": [ + "payload", + "binary_payload" + ], + "foreign_key_columns": null, + "foreign_key_table": null, + "foreign_key_schema": null, + "foreign_key_table_is_partition": null, + "foreign_key_parent_schema": null, + "foreign_key_parent_table": null, + "foreign_key_effective_schema": null, + "foreign_key_effective_table": null, + "on_update": null, + "on_delete": null, + "match_type": null, + "check_expression": "((payload IS NULL) OR (binary_payload IS NULL))", + "owner": "supabase_realtime_admin", + "definition": "CHECK (payload IS NULL OR binary_payload IS NULL) NOT VALID", + "comment": null + }, + { + "name": "messages_pkey", + "constraint_type": "p", + "deferrable": false, + "initially_deferred": false, + "validated": true, + "is_local": true, + "no_inherit": true, + "is_temporal": false, + "is_partition_clone": false, + "parent_constraint_schema": null, + "parent_constraint_name": null, + "parent_table_schema": null, + "parent_table_name": null, + "key_columns": [ + "id", + "inserted_at" + ], + "foreign_key_columns": null, + "foreign_key_table": null, + "foreign_key_schema": null, + "foreign_key_table_is_partition": null, + "foreign_key_parent_schema": null, + "foreign_key_parent_table": null, + "foreign_key_effective_schema": null, + "foreign_key_effective_table": null, + "on_update": null, + "on_delete": null, + "match_type": null, + "check_expression": null, + "owner": "supabase_realtime_admin", + "definition": "PRIMARY KEY (id, inserted_at)", + "comment": null + } + ], + "privileges": [ + { + "grantee": "postgres", + "privilege": "DELETE", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "INSERT", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "MAINTAIN", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "REFERENCES", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "TRIGGER", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "TRUNCATE", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "UPDATE", + "grantable": false, + "columns": null + }, + { + "grantee": "anon", + "privilege": "INSERT", + "grantable": false, + "columns": null + }, + { + "grantee": "anon", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "anon", + "privilege": "UPDATE", + "grantable": false, + "columns": null + }, + { + "grantee": "authenticated", + "privilege": "INSERT", + "grantable": false, + "columns": null + }, + { + "grantee": "authenticated", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "authenticated", + "privilege": "UPDATE", + "grantable": false, + "columns": null + }, + { + "grantee": "service_role", + "privilege": "INSERT", + "grantable": false, + "columns": null + }, + { + "grantee": "service_role", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "service_role", + "privilege": "UPDATE", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "DELETE", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "INSERT", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "MAINTAIN", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "REFERENCES", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "TRIGGER", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "TRUNCATE", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "UPDATE", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "DELETE", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "INSERT", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "MAINTAIN", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "REFERENCES", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "TRIGGER", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "TRUNCATE", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "UPDATE", + "grantable": false, + "columns": null + } + ], + "security_labels": [] + }, + "table:realtime.schema_migrations": { + "schema": "realtime", + "name": "schema_migrations", + "persistence": "p", + "row_security": false, + "force_row_security": false, + "has_indexes": true, + "has_rules": false, + "has_triggers": false, + "has_subclasses": false, + "is_populated": true, + "replica_identity": "d", + "replica_identity_index": null, + "is_partition": false, + "options": null, + "partition_bound": null, + "partition_by": null, + "owner": "supabase_admin", + "comment": null, + "parent_schema": null, + "parent_name": null, + "columns": [ + { + "name": "version", + "position": 1, + "data_type": "bigint", + "data_type_str": "bigint", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": true, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null, + "security_labels": [] + }, + { + "name": "inserted_at", + "position": 2, + "data_type": "timestamp without time zone", + "data_type_str": "timestamp(0) without time zone", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null, + "security_labels": [] + } + ], + "constraints": [ + { + "name": "schema_migrations_pkey", + "constraint_type": "p", + "deferrable": false, + "initially_deferred": false, + "validated": true, + "is_local": true, + "no_inherit": true, + "is_temporal": false, + "is_partition_clone": false, + "parent_constraint_schema": null, + "parent_constraint_name": null, + "parent_table_schema": null, + "parent_table_name": null, + "key_columns": [ + "version" + ], + "foreign_key_columns": null, + "foreign_key_table": null, + "foreign_key_schema": null, + "foreign_key_table_is_partition": null, + "foreign_key_parent_schema": null, + "foreign_key_parent_table": null, + "foreign_key_effective_schema": null, + "foreign_key_effective_table": null, + "on_update": null, + "on_delete": null, + "match_type": null, + "check_expression": null, + "owner": "supabase_admin", + "definition": "PRIMARY KEY (version)", + "comment": null + } + ], + "privileges": [ + { + "grantee": "supabase_admin", + "privilege": "DELETE", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_admin", + "privilege": "INSERT", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_admin", + "privilege": "MAINTAIN", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_admin", + "privilege": "REFERENCES", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_admin", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_admin", + "privilege": "TRIGGER", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_admin", + "privilege": "TRUNCATE", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_admin", + "privilege": "UPDATE", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "DELETE", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "INSERT", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "MAINTAIN", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "REFERENCES", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "TRIGGER", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "TRUNCATE", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "UPDATE", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "DELETE", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "INSERT", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "MAINTAIN", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "REFERENCES", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "TRIGGER", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "TRUNCATE", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "UPDATE", + "grantable": false, + "columns": null + } + ], + "security_labels": [] + }, + "table:realtime.subscription": { + "schema": "realtime", + "name": "subscription", + "persistence": "p", + "row_security": false, + "force_row_security": false, + "has_indexes": true, + "has_rules": false, + "has_triggers": true, + "has_subclasses": false, + "is_populated": true, + "replica_identity": "d", + "replica_identity_index": null, + "is_partition": false, + "options": null, + "partition_bound": null, + "partition_by": null, + "owner": "supabase_realtime_admin", + "comment": null, + "parent_schema": null, + "parent_name": null, + "columns": [ + { + "name": "id", + "position": 1, + "data_type": "bigint", + "data_type_str": "bigint", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": true, + "is_identity": true, + "is_identity_always": true, + "is_generated": false, + "collation": null, + "default": null, + "comment": null, + "security_labels": [] + }, + { + "name": "subscription_id", + "position": 2, + "data_type": "uuid", + "data_type_str": "uuid", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": true, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null, + "security_labels": [] + }, + { + "name": "entity", + "position": 4, + "data_type": "regclass", + "data_type_str": "regclass", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": true, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null, + "security_labels": [] + }, + { + "name": "filters", + "position": 5, + "data_type": "realtime.user_defined_filter[]", + "data_type_str": "realtime.user_defined_filter[]", + "is_custom_type": true, + "custom_type_type": "b", + "custom_type_category": "A", + "custom_type_schema": "realtime", + "custom_type_name": "_user_defined_filter", + "not_null": true, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": "'{}'::realtime.user_defined_filter[]", + "comment": null, + "security_labels": [] + }, + { + "name": "claims", + "position": 7, + "data_type": "jsonb", + "data_type_str": "jsonb", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": true, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null, + "security_labels": [] + }, + { + "name": "claims_role", + "position": 8, + "data_type": "regrole", + "data_type_str": "regrole", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": true, + "is_identity": false, + "is_identity_always": false, + "is_generated": true, + "collation": null, + "default": "realtime.to_regrole((claims ->> 'role'::text))", + "comment": null, + "security_labels": [] + }, + { + "name": "created_at", + "position": 9, + "data_type": "timestamp without time zone", + "data_type_str": "timestamp without time zone", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": true, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": "timezone('utc'::text, now())", + "comment": null, + "security_labels": [] + }, + { + "name": "action_filter", + "position": 10, + "data_type": "text", + "data_type_str": "text", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": "'*'::text", + "comment": null, + "security_labels": [] + }, + { + "name": "selected_columns", + "position": 11, + "data_type": "text[]", + "data_type_str": "text[]", + "is_custom_type": false, + "custom_type_type": null, + "custom_type_category": null, + "custom_type_schema": null, + "custom_type_name": null, + "not_null": false, + "is_identity": false, + "is_identity_always": false, + "is_generated": false, + "collation": null, + "default": null, + "comment": null, + "security_labels": [] + } + ], + "constraints": [ + { + "name": "pk_subscription", + "constraint_type": "p", + "deferrable": false, + "initially_deferred": false, + "validated": true, + "is_local": true, + "no_inherit": true, + "is_temporal": false, + "is_partition_clone": false, + "parent_constraint_schema": null, + "parent_constraint_name": null, + "parent_table_schema": null, + "parent_table_name": null, + "key_columns": [ + "id" + ], + "foreign_key_columns": null, + "foreign_key_table": null, + "foreign_key_schema": null, + "foreign_key_table_is_partition": null, + "foreign_key_parent_schema": null, + "foreign_key_parent_table": null, + "foreign_key_effective_schema": null, + "foreign_key_effective_table": null, + "on_update": null, + "on_delete": null, + "match_type": null, + "check_expression": null, + "owner": "supabase_realtime_admin", + "definition": "PRIMARY KEY (id)", + "comment": null + }, + { + "name": "subscription_action_filter_check", + "constraint_type": "c", + "deferrable": false, + "initially_deferred": false, + "validated": true, + "is_local": true, + "no_inherit": false, + "is_temporal": false, + "is_partition_clone": false, + "parent_constraint_schema": null, + "parent_constraint_name": null, + "parent_table_schema": null, + "parent_table_name": null, + "key_columns": [ + "action_filter" + ], + "foreign_key_columns": null, + "foreign_key_table": null, + "foreign_key_schema": null, + "foreign_key_table_is_partition": null, + "foreign_key_parent_schema": null, + "foreign_key_parent_table": null, + "foreign_key_effective_schema": null, + "foreign_key_effective_table": null, + "on_update": null, + "on_delete": null, + "match_type": null, + "check_expression": "(action_filter = ANY (ARRAY['*'::text, 'INSERT'::text, 'UPDATE'::text, 'DELETE'::text]))", + "owner": "supabase_realtime_admin", + "definition": "CHECK (action_filter = ANY (ARRAY['*'::text, 'INSERT'::text, 'UPDATE'::text, 'DELETE'::text]))", + "comment": null + } + ], + "privileges": [ + { + "grantee": "postgres", + "privilege": "DELETE", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "INSERT", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "MAINTAIN", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "REFERENCES", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "TRIGGER", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "TRUNCATE", + "grantable": false, + "columns": null + }, + { + "grantee": "postgres", + "privilege": "UPDATE", + "grantable": false, + "columns": null + }, + { + "grantee": "anon", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "authenticated", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "service_role", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "DELETE", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "INSERT", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "MAINTAIN", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "REFERENCES", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "TRIGGER", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "TRUNCATE", + "grantable": false, + "columns": null + }, + { + "grantee": "dashboard_user", + "privilege": "UPDATE", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "DELETE", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "INSERT", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "MAINTAIN", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "REFERENCES", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "SELECT", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "TRIGGER", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "TRUNCATE", + "grantable": false, + "columns": null + }, + { + "grantee": "supabase_realtime_admin", + "privilege": "UPDATE", + "grantable": false, + "columns": null + } + ], + "security_labels": [] + } + }, + "triggers": { + "trigger:realtime.subscription.tr_check_filters": { + "schema": "realtime", + "name": "tr_check_filters", + "table_name": "subscription", + "table_relkind": "r", + "function_schema": "realtime", + "function_name": "subscription_check_filters", + "trigger_type": 23, + "enabled": "O", + "is_internal": false, + "deferrable": false, + "initially_deferred": false, + "argument_count": 0, + "column_numbers": [], + "arguments": [], + "when_condition": null, + "old_table": null, + "new_table": null, + "is_partition_clone": false, + "parent_trigger_name": null, + "parent_table_schema": null, + "parent_table_name": null, + "is_on_partitioned_table": false, + "owner": "supabase_realtime_admin", + "definition": "CREATE TRIGGER tr_check_filters BEFORE INSERT OR UPDATE ON realtime.subscription FOR EACH ROW EXECUTE FUNCTION realtime.subscription_check_filters()", + "comment": null + } + }, + "eventTriggers": {}, + "rules": {}, + "ranges": {}, + "views": {}, + "foreignDataWrappers": {}, + "servers": {}, + "userMappings": {}, + "foreignTables": {}, + "depends": [ + { + "dependent_stable_id": "acl:procedure:realtime.\"cast\"(text,regtype)::grantee:anon", + "referenced_stable_id": "procedure:realtime.\"cast\"(text,regtype)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.\"cast\"(text,regtype)::grantee:authenticated", + "referenced_stable_id": "procedure:realtime.\"cast\"(text,regtype)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.\"cast\"(text,regtype)::grantee:service_role", + "referenced_stable_id": "procedure:realtime.\"cast\"(text,regtype)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.apply_rls(jsonb,integer)::grantee:anon", + "referenced_stable_id": "procedure:realtime.apply_rls(jsonb,integer)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.apply_rls(jsonb,integer)::grantee:authenticated", + "referenced_stable_id": "procedure:realtime.apply_rls(jsonb,integer)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.apply_rls(jsonb,integer)::grantee:service_role", + "referenced_stable_id": "procedure:realtime.apply_rls(jsonb,integer)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])::grantee:anon", + "referenced_stable_id": "procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])::grantee:authenticated", + "referenced_stable_id": "procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])::grantee:service_role", + "referenced_stable_id": "procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)::grantee:anon", + "referenced_stable_id": "procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)::grantee:authenticated", + "referenced_stable_id": "procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)::grantee:service_role", + "referenced_stable_id": "procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])::grantee:anon", + "referenced_stable_id": "procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])::grantee:authenticated", + "referenced_stable_id": "procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])::grantee:service_role", + "referenced_stable_id": "procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.quote_wal2json(regclass)::grantee:anon", + "referenced_stable_id": "procedure:realtime.quote_wal2json(regclass)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.quote_wal2json(regclass)::grantee:authenticated", + "referenced_stable_id": "procedure:realtime.quote_wal2json(regclass)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.quote_wal2json(regclass)::grantee:service_role", + "referenced_stable_id": "procedure:realtime.quote_wal2json(regclass)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.subscription_check_filters()::grantee:anon", + "referenced_stable_id": "procedure:realtime.subscription_check_filters()", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.subscription_check_filters()::grantee:authenticated", + "referenced_stable_id": "procedure:realtime.subscription_check_filters()", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.subscription_check_filters()::grantee:service_role", + "referenced_stable_id": "procedure:realtime.subscription_check_filters()", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.to_regrole(text)::grantee:anon", + "referenced_stable_id": "procedure:realtime.to_regrole(text)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.to_regrole(text)::grantee:authenticated", + "referenced_stable_id": "procedure:realtime.to_regrole(text)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:procedure:realtime.to_regrole(text)::grantee:service_role", + "referenced_stable_id": "procedure:realtime.to_regrole(text)", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:schema:realtime::grantee:anon", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:schema:realtime::grantee:authenticated", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:schema:realtime::grantee:postgres", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:schema:realtime::grantee:service_role", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:schema:realtime::grantee:supabase_realtime_admin", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:table:realtime.messages::grantee:anon", + "referenced_stable_id": "table:realtime.messages", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:table:realtime.messages::grantee:authenticated", + "referenced_stable_id": "table:realtime.messages", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:table:realtime.messages::grantee:service_role", + "referenced_stable_id": "table:realtime.messages", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:table:realtime.schema_migrations::grantee:supabase_realtime_admin", + "referenced_stable_id": "table:realtime.schema_migrations", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:table:realtime.subscription::grantee:anon", + "referenced_stable_id": "table:realtime.subscription", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:table:realtime.subscription::grantee:authenticated", + "referenced_stable_id": "table:realtime.subscription", + "deptype": "n" + }, + { + "dependent_stable_id": "acl:table:realtime.subscription::grantee:service_role", + "referenced_stable_id": "table:realtime.subscription", + "deptype": "n" + }, + { + "dependent_stable_id": "column:realtime.subscription.claims_role", + "referenced_stable_id": "column:realtime.subscription.claims", + "deptype": "n" + }, + { + "dependent_stable_id": "column:realtime.subscription.claims_role", + "referenced_stable_id": "procedure:realtime.to_regrole(text)", + "deptype": "n" + }, + { + "dependent_stable_id": "constraint:realtime.messages.messages_payload_exclusive", + "referenced_stable_id": "column:realtime.messages.binary_payload", + "deptype": "n" + }, + { + "dependent_stable_id": "constraint:realtime.messages.messages_payload_exclusive", + "referenced_stable_id": "column:realtime.messages.binary_payload", + "deptype": "a" + }, + { + "dependent_stable_id": "constraint:realtime.messages.messages_payload_exclusive", + "referenced_stable_id": "column:realtime.messages.payload", + "deptype": "n" + }, + { + "dependent_stable_id": "constraint:realtime.messages.messages_payload_exclusive", + "referenced_stable_id": "column:realtime.messages.payload", + "deptype": "a" + }, + { + "dependent_stable_id": "constraint:realtime.messages.messages_pkey", + "referenced_stable_id": "column:realtime.messages.id", + "deptype": "a" + }, + { + "dependent_stable_id": "constraint:realtime.messages.messages_pkey", + "referenced_stable_id": "column:realtime.messages.inserted_at", + "deptype": "a" + }, + { + "dependent_stable_id": "constraint:realtime.schema_migrations.schema_migrations_pkey", + "referenced_stable_id": "column:realtime.schema_migrations.version", + "deptype": "a" + }, + { + "dependent_stable_id": "constraint:realtime.subscription.pk_subscription", + "referenced_stable_id": "column:realtime.subscription.id", + "deptype": "a" + }, + { + "dependent_stable_id": "constraint:realtime.subscription.subscription_action_filter_check", + "referenced_stable_id": "column:realtime.subscription.action_filter", + "deptype": "n" + }, + { + "dependent_stable_id": "constraint:realtime.subscription.subscription_action_filter_check", + "referenced_stable_id": "column:realtime.subscription.action_filter", + "deptype": "a" + }, + { + "dependent_stable_id": "defacl:supabase_admin:f:schema:realtime:grantee:dashboard_user", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "defacl:supabase_admin:f:schema:realtime:grantee:postgres", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "defacl:supabase_admin:r:schema:realtime:grantee:dashboard_user", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "defacl:supabase_admin:r:schema:realtime:grantee:postgres", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "defacl:supabase_admin:S:schema:realtime:grantee:dashboard_user", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "defacl:supabase_admin:S:schema:realtime:grantee:postgres", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "index:realtime.subscription.ix_realtime_subscription_entity", + "referenced_stable_id": "column:realtime.subscription.entity", + "deptype": "a" + }, + { + "dependent_stable_id": "index:realtime.subscription.ix_realtime_subscription_entity", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "index:realtime.subscription.ix_realtime_subscription_entity", + "referenced_stable_id": "table:realtime.subscription", + "deptype": "n" + }, + { + "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec", + "referenced_stable_id": "column:realtime.subscription.action_filter", + "deptype": "a" + }, + { + "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec", + "referenced_stable_id": "column:realtime.subscription.entity", + "deptype": "a" + }, + { + "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec", + "referenced_stable_id": "column:realtime.subscription.filters", + "deptype": "a" + }, + { + "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec", + "referenced_stable_id": "column:realtime.subscription.selected_columns", + "deptype": "a" + }, + { + "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec", + "referenced_stable_id": "column:realtime.subscription.subscription_id", + "deptype": "a" + }, + { + "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec", + "referenced_stable_id": "table:realtime.subscription", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.\"cast\"(text,regtype)", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.apply_rls(jsonb,integer)", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.apply_rls(jsonb,integer)", + "referenced_stable_id": "type:realtime.wal_rls", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.broadcast_changes(text,text,text,text,text,record,record,text)", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)", + "referenced_stable_id": "type:realtime.equality_op", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.list_changes(name,name,integer,integer)", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.quote_wal2json(regclass)", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.send_binary(bytea,text,text,boolean)", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.send(jsonb,text,text,boolean)", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.subscription_check_filters()", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.to_regrole(text)", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.topic()", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "procedure:realtime.wal2json_escape_identifier(text)", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "table:realtime.messages", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "table:realtime.schema_migrations", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "table:realtime.subscription", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "trigger:realtime.subscription.tr_check_filters", + "referenced_stable_id": "procedure:realtime.subscription_check_filters()", + "deptype": "n" + }, + { + "dependent_stable_id": "trigger:realtime.subscription.tr_check_filters", + "referenced_stable_id": "table:realtime.subscription", + "deptype": "a" + }, + { + "dependent_stable_id": "type:realtime.action", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "type:realtime.equality_op", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "type:realtime.user_defined_filter", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "type:realtime.user_defined_filter", + "referenced_stable_id": "type:realtime.equality_op", + "deptype": "n" + }, + { + "dependent_stable_id": "type:realtime.wal_column", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + }, + { + "dependent_stable_id": "type:realtime.wal_rls", + "referenced_stable_id": "schema:realtime", + "deptype": "n" + } + ] +} \ No newline at end of file diff --git a/rel/env.sh.eex b/rel/env.sh.eex index 402da0791..c7c7764ff 100644 --- a/rel/env.sh.eex +++ b/rel/env.sh.eex @@ -9,7 +9,10 @@ ip=$(grep fly-local-6pn /etc/hosts | cut -f 1) if [ "$AWS_EXECUTION_ENV" = "AWS_ECS_FARGATE" ]; then # for AWS ECS Fargate - ip=$(hostname -I | awk '{print $3}') + ip=$(curl -sf "${ECS_CONTAINER_METADATA_URI_V4}" \ + | jq -r '.Networks[].IPv6Addresses[]' \ + | grep -Ev '^f[cd]' \ + | head -1) elif [ -n "${POD_IP}" ]; then # for kubernetes ip=${POD_IP} diff --git a/rel/vm.args.eex b/rel/vm.args.eex index 278da5524..f5e1845c8 100644 --- a/rel/vm.args.eex +++ b/rel/vm.args.eex @@ -10,8 +10,8 @@ ## Tweak GC to run more often ##-env ERL_FULLSWEEP_AFTER 10 -## Limit process heap for all procs to 1000 MB -+hmax 1000000000 +## Limit process heap for all procs to 1000 MB. The number here is the number of words ++hmax <%= div(1_000_000_000, :erlang.system_info(:wordsize)) %> ## Set distribution buffer busy limit (default is 1024) +zdbbl 100000 @@ -19,4 +19,4 @@ ## Disable Busy Wait +sbwt none +sbwtdio none -+sbwtdcpu none \ No newline at end of file ++sbwtdcpu none diff --git a/run.sh b/run.sh index 2dddbc1b8..22cd50ea7 100755 --- a/run.sh +++ b/run.sh @@ -3,7 +3,7 @@ set -euo pipefail set -x ulimit -n -if [ ! -z "$RLIMIT_NOFILE" ]; then +if [ -n "${RLIMIT_NOFILE:-}" ]; then echo "Setting RLIMIT_NOFILE to ${RLIMIT_NOFILE}" ulimit -Sn "$RLIMIT_NOFILE" fi @@ -17,7 +17,7 @@ upload_crash_dump_to_s3() { s3Port=$ERL_CRASH_DUMP_S3_PORT if [ "${AWS_CONTAINER_CREDENTIALS_RELATIVE_URI-}" ]; then - response=$(curl -s http://169.254.170.2$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) + response=$(curl -s "http://169.254.170.2${AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}") s3Key=$(echo "$response" | grep -o '"AccessKeyId": *"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"') s3Secret=$(echo "$response" | grep -o '"SecretAccessKey": *"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"') else @@ -28,7 +28,7 @@ upload_crash_dump_to_s3() { filePath=${ERL_CRASH_DUMP_FOLDER:-tmp}/$(date +%s)_${ERL_CRASH_DUMP_FILE_NAME:-erl_crash.dump} if [ -f "${ERL_CRASH_DUMP_FOLDER:-tmp}/${ERL_CRASH_DUMP_FILE_NAME:-erl_crash.dump}" ]; then - mv ${ERL_CRASH_DUMP_FOLDER:-tmp}/${ERL_CRASH_DUMP_FILE_NAME:-erl_crash.dump} $filePath + mv "${ERL_CRASH_DUMP_FOLDER:-tmp}/${ERL_CRASH_DUMP_FILE_NAME:-erl_crash.dump}" "$filePath" resource="/${bucket}/realtime/crash_dumps${filePath}" @@ -36,7 +36,7 @@ upload_crash_dump_to_s3() { dateValue=$(date -R) stringToSign="PUT\n\n${contentType}\n${dateValue}\n${resource}" - signature=$(echo -en ${stringToSign} | openssl sha1 -hmac ${s3Secret} -binary | base64) + signature=$(echo -en "${stringToSign}" | openssl sha1 -hmac "${s3Secret}" -binary | base64) if [ "${ERL_CRASH_DUMP_S3_SSL:-}" = true ]; then protocol="https" @@ -49,7 +49,7 @@ upload_crash_dump_to_s3() { -H "Date: ${dateValue}" \ -H "Content-Type: ${contentType}" \ -H "Authorization: AWS ${s3Key}:${signature}" \ - ${protocol}://${s3Host}:${s3Port}${resource} + "${protocol}://${s3Host}:${s3Port}${resource}" fi exit "$EXIT_CODE" @@ -62,7 +62,7 @@ generate_certs() { openssl req -new -nodes -out server.csr -keyout server.key -subj "/C=US/ST=Delaware/L=New Castle/O=Supabase Inc/CN=$(hostname -f)" openssl x509 -req -in server.csr -days 90 -CA ca.cert -CAkey ca.key -out server.cert rm -f ca.key - CWD=`pwd` + CWD=$(pwd) export GEN_RPC_CACERTFILE="$CWD/ca.cert" export GEN_RPC_KEYFILE="$CWD/server.key" export GEN_RPC_CERTFILE="$CWD/server.cert" @@ -87,10 +87,10 @@ EOF } if [ "${ENABLE_ERL_CRASH_DUMP:-false}" = true ]; then - trap upload_crash_dump_to_s3 INT TERM KILL EXIT + trap upload_crash_dump_to_s3 INT TERM EXIT fi -if [[ -n "${GENERATE_CLUSTER_CERTS}" ]] ; then +if [[ -n "${GENERATE_CLUSTER_CERTS:-}" ]] ; then generate_certs fi diff --git a/test/api_jwt_secret_test.exs b/test/api_jwt_secret_test.exs index 4bf08c7ba..5c8b9bab3 100644 --- a/test/api_jwt_secret_test.exs +++ b/test/api_jwt_secret_test.exs @@ -17,4 +17,42 @@ defmodule RealtimeWeb.ApiJwtSecretTest do conn = get(conn, Routes.tenant_path(conn, :index)) assert conn.status == 200 end + + describe "secret rotation" do + setup do + previous = Application.get_env(:realtime, :api_jwt_secret) + Application.put_env(:realtime, :api_jwt_secret, ["current_secret", "next_secret"]) + on_exit(fn -> Application.put_env(:realtime, :api_jwt_secret, previous) end) + :ok + end + + test "api key signed with current secret", %{conn: conn} do + jwt = generate_jwt_token("current_secret") + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> jwt) + conn = get(conn, Routes.tenant_path(conn, :index)) + assert conn.status == 200 + end + + test "api key signed with next secret", %{conn: conn} do + jwt = generate_jwt_token("next_secret") + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> jwt) + conn = get(conn, Routes.tenant_path(conn, :index)) + assert conn.status == 200 + end + + test "api key signed with unknown secret", %{conn: conn} do + jwt = generate_jwt_token("unknown_secret") + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> jwt) + conn = get(conn, Routes.tenant_path(conn, :index)) + assert conn.status == 403 + end + + test "no secrets configured", %{conn: conn} do + Application.put_env(:realtime, :api_jwt_secret, []) + jwt = generate_jwt_token("current_secret") + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> jwt) + conn = get(conn, Routes.tenant_path(conn, :index)) + assert conn.status == 403 + end + end end diff --git a/test/e2e/.gitignore b/test/e2e/.gitignore index 4c49bd78f..82b274ebd 100644 --- a/test/e2e/.gitignore +++ b/test/e2e/.gitignore @@ -1 +1,4 @@ .env +.env.local +realtime-check +result diff --git a/test/e2e/.tool-versions b/test/e2e/.tool-versions index ae794d2c3..f1196e82c 100644 --- a/test/e2e/.tool-versions +++ b/test/e2e/.tool-versions @@ -1 +1 @@ -deno latest +bun latest diff --git a/test/e2e/README.md b/test/e2e/README.md index 03d7c32a1..0c0e6808f 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -1,107 +1,136 @@ # Realtime E2E tests -Our E2E tests intend to test the usage of Realtime with Supabase and ensure we have no breaking changes. They require you to setup your project with some configurations to ensure they work as expected. +| Option | Description | +|---|---| +| `--project` | Supabase project ref (not needed for `--env local`) | +| `--publishable-key` | Project anon/public key | +| `--secret-key` | Project service role key | +| `--db-password` | Database password (required for staging/prod) | +| `--env` | `local` \| `staging` \| `prod` (default: `prod`) | +| `--domain` | Email domain for the test user (default: `example.com`) | +| `--port` | Override URL port (useful for local) | +| `--test` | Comma-separated list of test categories to run (runs all if omitted) | +| `--json` | Output results as JSON to stdout (all other output goes to stderr) | +| `--url` | Override project URL (e.g. `http://127.0.0.1:54321`) | +| `--db-url` | Override database URL (e.g. `postgresql://postgres:postgres@127.0.0.1:54322/postgres`) | +| `--otel` | OTLP HTTP endpoint for tracing (e.g. `http://localhost:4318`) | +| `--otel-token` | Bearer token for authenticated OTLP endpoints | + +A random test user is created at the start of each run and deleted automatically when it finishes. + +## Test categories + +Pass any combination to `--test` as a comma-separated list. Use `functional` to run all non-load suites, or `load` to run all load suites. + +| Category | Suites | Tests | +|---|---|---| +| `connection` | connection | First connect latency; broadcast message throughput | +| `load` | load-postgres-changes | Postgres system message latency; INSERT / UPDATE / DELETE throughput via postgres changes | +| | load-presence | Presence join throughput | +| | load-broadcast-from-db | Broadcast-from-database throughput | +| | load-broadcast` | Self-broadcast throughput; REST broadcast API throughput | +| | load-broadcast-replay | Broadcast replay throughput on channel join | +| `broadcast` | broadcast extension | Self-broadcast receive; REST broadcast API send-and-receive | +| `presence` | presence extension | Presence join on public channels; presence join on private channels | +| `authorization` | authorization check | Private channel denied without permissions; private channel allowed with permissions | +| `postgres-changes` | postgres changes extension | Filtered INSERT, UPDATE, DELETE events; concurrent INSERT + UPDATE + DELETE | +| `broadcast-changes` | broadcast changes | DB-triggered broadcast for INSERT, UPDATE, DELETE | +| `broadcast-replay` | broadcast replay | Replayed messages delivered on join; `meta.replayed` flag set; messages before `since` not replayed | + +```bash +# Run only connection and broadcast tests +./realtime-check --env local --publishable-key --secret-key --test connection,broadcast + +# Run all load tests +./realtime-check --env local --publishable-key --secret-key --test load + +# Run all functional (non-load) tests +./realtime-check --env local --publishable-key --secret-key --test functional +``` + +## JSON output + +When `--json` is used, only the JSON is written to stdout — all progress and diagnostic output goes to stderr — making it safe to pipe directly to `jq`: -## Setup tests +```bash +./realtime-check --json ... | jq '.slis' +./realtime-check --json ... | jq '.suites["broadcast extension"].tests' +./realtime-check --json ... | jq 'select(.passed == false)' +``` -### Project environment +## Using the binary -- Run the following SQL +The pre-built binary requires no runtime — just run it directly. -```sql -CREATE TABLE public.pg_changes ( - id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - value text NOT NULL DEFAULT gen_random_uuid () -); +### Local project -CREATE TABLE public.dummy ( - id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - value text NOT NULL DEFAULT gen_random_uuid () -); +A `supabase/config.toml` is included, so `supabase start` works out of the box. -CREATE TABLE public.authorization ( - id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - value text NOT NULL DEFAULT gen_random_uuid () -); +```bash +supabase start +./realtime-check --env local --publishable-key --secret-key +``` -CREATE TABLE public.broadcast_changes ( - id text PRIMARY KEY, - value text NOT NULL -); +### Local project with tracing -CREATE TABLE public.wallet ( - id text PRIMARY KEY, - wallet_id text NOT NULL -); -INSERT INTO public.wallet (id, wallet_id) VALUES (1, 'wallet_1'); +```bash +supabase start +docker compose up -d # starts Jaeger at http://localhost:16686 +./realtime-check --env local --publishable-key --secret-key --otel http://localhost:4318 +``` -ALTER TABLE public.pg_changes ENABLE ROW LEVEL SECURITY; +For authenticated OTLP endpoints: -ALTER TABLE public.authorization ENABLE ROW LEVEL SECURITY; +```bash +./realtime-check --env local --publishable-key --secret-key \ + --otel https://otlp.example.com --otel-token +``` -ALTER TABLE public.broadcast_changes ENABLE ROW LEVEL SECURITY; +### Remote project -ALTER TABLE public.wallet ENABLE ROW LEVEL SECURITY; +```bash +./realtime-check --project --publishable-key \ + --secret-key --db-password +``` -ALTER PUBLICATION supabase_realtime - ADD TABLE public.pg_changes; +## Using Bun -ALTER PUBLICATION supabase_realtime - ADD TABLE public.dummy; +Requires [Bun](https://bun.sh). -CREATE POLICY "authenticated receive on topic" ON "realtime"."messages" AS PERMISSIVE - FOR SELECT TO authenticated - USING ( realtime.topic() like 'topic:%'); +### Run without building -CREATE POLICY "authenticated broadcast on topic" ON "realtime"."messages" AS PERMISSIVE - FOR INSERT TO authenticated - WITH CHECK ( realtime.topic() like 'topic:%'); +```bash +bun install +bun run check -- --project --publishable-key --secret-key --db-password +``` -CREATE POLICY "authenticated jwt topic in wallet can receive" ON "realtime"."messages" AS PERMISSIVE - FOR SELECT TO authenticated - USING ( realtime.topic() like 'jwt_topic:%' AND exists (select wallet_id from public.wallet where wallet_id = (auth.jwt() -> 'sub')::text)); +### Build the binary -CREATE POLICY "authenticated jwt topic in wallet can broadcast" ON "realtime"."messages" AS PERMISSIVE - FOR INSERT TO authenticated - WITH CHECK ( realtime.topic() like 'jwt_topic:%' AND exists (select wallet_id from public.wallet where wallet_id = (auth.jwt() -> 'sub')::text)); +```bash +bun install +bun run build +./realtime-check --project --publishable-key --secret-key --db-password +``` -CREATE POLICY "allow authenticated users all access" ON "public"."pg_changes" AS PERMISSIVE - FOR ALL TO authenticated - USING (TRUE); +## Using Nix -CREATE POLICY "authenticated have full access to read on broadcast_changes" ON "public"."broadcast_changes" AS PERMISSIVE - FOR ALL TO authenticated - USING (TRUE); +Requires flakes support. Add this once to `/etc/nix/nix.conf`: -CREATE OR REPLACE FUNCTION broadcast_changes_for_table_trigger () - RETURNS TRIGGER - AS $$ -DECLARE - topic text; -BEGIN - topic = 'topic:test'; - PERFORM - realtime.broadcast_changes (topic, TG_OP, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA, NEW, OLD, TG_LEVEL); - RETURN NULL; -END; -$$ -LANGUAGE plpgsql; +``` +experimental-features = nix-command flakes +``` -CREATE TRIGGER broadcast_changes_for_table_public_broadcast_changes_trigger - AFTER INSERT OR UPDATE OR DELETE ON broadcast_changes - FOR EACH ROW - EXECUTE FUNCTION broadcast_changes_for_table_trigger (); +### Build and run -INSERT INTO "auth"."users" ("instance_id", "id", "aud", "role", "email", "encrypted_password", "email_confirmed_at", "invited_at", "confirmation_token", "confirmation_sent_at", "recovery_token", "recovery_sent_at", "email_change_token_new", "email_change", "email_change_sent_at", "last_sign_in_at", "raw_app_meta_data", "raw_user_meta_data", "is_super_admin", "created_at", "updated_at", "phone", "phone_confirmed_at", "phone_change", "phone_change_token", "phone_change_sent_at", "email_change_token_current", "email_change_confirm_status", "banned_until", "reauthentication_token", "reauthentication_sent_at", "is_sso_user", "deleted_at", "is_anonymous") VALUES ('00000000-0000-0000-0000-000000000000', '93c8bc43-c330-4702-aef2-4ba2c298950a', 'authenticated', 'authenticated', 'filipe@supabase.io', '$2a$10$WQ4tbkMVuS2OUmkX.LRC0uRwH6bU39CbI5bdHuLi82UXhUsjhrLP.', '2025-04-03 03:51:28.207805+00', null, '', '2025-04-03 03:50:59.085609+00', '', null, '', '', null, '2025-04-03 08:01:19.813327+00', '{"provider": "email", "providers": ["email"]}', '{"sub": "92c8bc43-c330-4702-aef2-4ba2c298950a", "email": "filipe@supabase.io", "email_verified": true, "phone_verified": false}', null, '2025-04-03 03:50:59.038087+00', '2025-04-03 22:09:10.979685+00', null, null, '', '', null, '', '0', null, '', null, 'false', null, 'false'); +```bash +bun run nix +./result/bin/realtime-check --project --publishable-key --secret-key --db-password ``` -### Test enviroment +`bun run nix` calls `nix-build.sh`, which automatically updates the `outputHash` in `flake.nix` when `package.json` or `bun.lock` change — no manual hash update needed. -- Create .env based on .env.template with: - - PROJECT_URL - URL for the project - - PROJECT_ANON_TOKEN - Anon authentication token for the project +--- -## Run tests +## Deno tests (legacy) -Run the following command -`deno test tests.ts --allow-read --allow-net --trace-leaks --allow-env=WS_NO_BUFFER_UTIL` +See [legacy/README.md](./legacy/README.md). diff --git a/test/e2e/bun.lock b/test/e2e/bun.lock new file mode 100644 index 000000000..465b34d79 --- /dev/null +++ b/test/e2e/bun.lock @@ -0,0 +1,67 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "realtime-check", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.7.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", + "@opentelemetry/resources": "^2.7.1", + "@opentelemetry/sdk-trace-base": "^2.7.1", + "@opentelemetry/semantic-conventions": "^1.41.1", + "@supabase/supabase-js": "2.108.2", + "commander": "^14.0.3", + "kleur": "^4.1.5", + }, + }, + }, + "packages": { + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.7.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], + + "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], + + "@supabase/auth-js": ["@supabase/auth-js@2.108.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag=="], + + "@supabase/functions-js": ["@supabase/functions-js@2.108.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw=="], + + "@supabase/phoenix": ["@supabase/phoenix@0.4.2", "", {}, "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A=="], + + "@supabase/postgrest-js": ["@supabase/postgrest-js@2.108.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw=="], + + "@supabase/realtime-js": ["@supabase/realtime-js@2.108.2", "", { "dependencies": { "@supabase/phoenix": "^0.4.2", "tslib": "2.8.1" } }, "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g=="], + + "@supabase/storage-js": ["@supabase/storage-js@2.108.2", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g=="], + + "@supabase/supabase-js": ["@supabase/supabase-js@2.108.2", "", { "dependencies": { "@supabase/auth-js": "2.108.2", "@supabase/functions-js": "2.108.2", "@supabase/postgrest-js": "2.108.2", "@supabase/realtime-js": "2.108.2", "@supabase/storage-js": "2.108.2" } }, "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + } +} diff --git a/test/e2e/docker-compose.yml b/test/e2e/docker-compose.yml new file mode 100644 index 000000000..8f33b4e4b --- /dev/null +++ b/test/e2e/docker-compose.yml @@ -0,0 +1,11 @@ +# E2e testing infrastructure. Requires `supabase start` to be running first. +# Run from test/e2e: +# docker compose up -d +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 bun run realtime-check.ts --env local +services: + jaeger: + image: jaegertracing/jaeger:2.5.0 + container_name: e2e-jaeger + ports: + - "16686:16686" + - "4318:4318" diff --git a/test/e2e/flake.lock b/test/e2e/flake.lock new file mode 100644 index 000000000..8d169a6c4 --- /dev/null +++ b/test/e2e/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1772624091, + "narHash": "sha256-QKyJ0QGWBn6r0invrMAK8dmJoBYWoOWy7lN+UHzW1jc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/test/e2e/flake.nix b/test/e2e/flake.nix new file mode 100644 index 000000000..fabdacb53 --- /dev/null +++ b/test/e2e/flake.nix @@ -0,0 +1,69 @@ +{ + description = "realtime-check — Supabase Realtime end-to-end test CLI"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + let + src = pkgs.lib.cleanSourceWith { + src = ./.; + filter = path: type: + let baseName = baseNameOf path; + in baseName != "node_modules" && baseName != "result" && baseName != "realtime-check" && baseName != ".env"; + }; + + node_modules = pkgs.stdenv.mkDerivation { + name = "realtime-check-node-modules"; + inherit src; + nativeBuildInputs = [ pkgs.bun ]; + buildPhase = '' + export HOME=$TMPDIR + bun install --frozen-lockfile + ''; + installPhase = "cp -r node_modules $out"; + outputHashMode = "recursive"; + outputHashAlgo = "sha256"; + outputHash = "sha256-9BL1urw6rGJ7qhd72NIU6TBBVrYuKfcl6z2J/WeXZ0E="; + }; + in { + packages.default = pkgs.stdenv.mkDerivation { + pname = "realtime-check"; + version = "0.0.1"; + inherit src; + nativeBuildInputs = [ pkgs.bun ]; + + # Bun's `--compile` output is a self-contained binary that appends a + # JavaScript blob to the end of the ELF image. Nix's default fixupPhase + # runs patchelf (to rewrite the interpreter / RPATH) and strip, both of + # which rewrite ELF section layout and corrupt the trailing blob — + # causing the binary to fall back to the bare Bun CLI at runtime. + # The Bun runtime is statically linked and needs no patching, so we + # disable the entire fixup phase. + dontFixup = true; + dontPatchELF = true; + dontStrip = true; + + buildPhase = '' + export HOME=$TMPDIR + cp -r ${node_modules} node_modules + chmod -R u+w node_modules + bun build --compile --minify-syntax --minify-whitespace --minify-identifiers realtime-check.ts --outfile realtime-check + ''; + installPhase = '' + install -Dm755 realtime-check $out/bin/realtime-check + ''; + }; + + devShells.default = pkgs.mkShell { + buildInputs = [ pkgs.bun ]; + }; + } + ); +} diff --git a/test/e2e/legacy/.tool-versions b/test/e2e/legacy/.tool-versions new file mode 100644 index 000000000..ae794d2c3 --- /dev/null +++ b/test/e2e/legacy/.tool-versions @@ -0,0 +1 @@ +deno latest diff --git a/test/e2e/legacy/README.md b/test/e2e/legacy/README.md new file mode 100644 index 000000000..1b6cd1cad --- /dev/null +++ b/test/e2e/legacy/README.md @@ -0,0 +1,106 @@ +# Deno tests (legacy) + +The original Deno-based tests require manual database setup before running. + +## Project environment + +Run the following SQL against your project before running the tests: + +```sql +CREATE TABLE public.pg_changes ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + value text NOT NULL DEFAULT gen_random_uuid () +); + +CREATE TABLE public.dummy ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + value text NOT NULL DEFAULT gen_random_uuid () +); + +CREATE TABLE public.authorization ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + value text NOT NULL DEFAULT gen_random_uuid () +); + +CREATE TABLE public.broadcast_changes ( + id text PRIMARY KEY, + value text NOT NULL +); + +CREATE TABLE public.wallet ( + id text PRIMARY KEY, + wallet_id text NOT NULL +); +INSERT INTO public.wallet (id, wallet_id) VALUES (1, 'wallet_1'); + +ALTER TABLE public.pg_changes ENABLE ROW LEVEL SECURITY; + +ALTER TABLE public.authorization ENABLE ROW LEVEL SECURITY; + +ALTER TABLE public.broadcast_changes ENABLE ROW LEVEL SECURITY; + +ALTER TABLE public.wallet ENABLE ROW LEVEL SECURITY; + +ALTER PUBLICATION supabase_realtime + ADD TABLE public.pg_changes; + +ALTER PUBLICATION supabase_realtime + ADD TABLE public.dummy; + +CREATE POLICY "authenticated receive on topic" ON "realtime"."messages" AS PERMISSIVE + FOR SELECT TO authenticated + USING ( realtime.topic() like 'topic:%'); + +CREATE POLICY "authenticated broadcast on topic" ON "realtime"."messages" AS PERMISSIVE + FOR INSERT TO authenticated + WITH CHECK ( realtime.topic() like 'topic:%'); + +CREATE POLICY "authenticated jwt topic in wallet can receive" ON "realtime"."messages" AS PERMISSIVE + FOR SELECT TO authenticated + USING ( realtime.topic() like 'jwt_topic:%' AND exists (select wallet_id from public.wallet where wallet_id = (auth.jwt() -> 'sub')::text)); + +CREATE POLICY "authenticated jwt topic in wallet can broadcast" ON "realtime"."messages" AS PERMISSIVE + FOR INSERT TO authenticated + WITH CHECK ( realtime.topic() like 'jwt_topic:%' AND exists (select wallet_id from public.wallet where wallet_id = (auth.jwt() -> 'sub')::text)); + +CREATE POLICY "allow authenticated users all access" ON "public"."pg_changes" AS PERMISSIVE + FOR ALL TO authenticated + USING (TRUE); + +CREATE POLICY "authenticated have full access to read on broadcast_changes" ON "public"."broadcast_changes" AS PERMISSIVE + FOR ALL TO authenticated + USING (TRUE); + +CREATE OR REPLACE FUNCTION broadcast_changes_for_table_trigger () + RETURNS TRIGGER + AS $$ +DECLARE + topic text; +BEGIN + topic = 'topic:test'; + PERFORM + realtime.broadcast_changes (topic, TG_OP, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA, NEW, OLD, TG_LEVEL); + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; + +CREATE TRIGGER broadcast_changes_for_table_public_broadcast_changes_trigger + AFTER INSERT OR UPDATE OR DELETE ON broadcast_changes + FOR EACH ROW + EXECUTE FUNCTION broadcast_changes_for_table_trigger (); + +INSERT INTO "auth"."users" ("instance_id", "id", "aud", "role", "email", "encrypted_password", "email_confirmed_at", "invited_at", "confirmation_token", "confirmation_sent_at", "recovery_token", "recovery_sent_at", "email_change_token_new", "email_change", "email_change_sent_at", "last_sign_in_at", "raw_app_meta_data", "raw_user_meta_data", "is_super_admin", "created_at", "updated_at", "phone", "phone_confirmed_at", "phone_change", "phone_change_token", "phone_change_sent_at", "email_change_token_current", "email_change_confirm_status", "banned_until", "reauthentication_token", "reauthentication_sent_at", "is_sso_user", "deleted_at", "is_anonymous") VALUES ('00000000-0000-0000-0000-000000000000', '93c8bc43-c330-4702-aef2-4ba2c298950a', 'authenticated', 'authenticated', 'filipe@supabase.io', '$2a$10$WQ4tbkMVuS2OUmkX.LRC0uRwH6bU39CbI5bdHuLi82UXhUsjhrLP.', '2025-04-03 03:51:28.207805+00', null, '', '2025-04-03 03:50:59.085609+00', '', null, '', '', null, '2025-04-03 08:01:19.813327+00', '{"provider": "email", "providers": ["email"]}', '{"sub": "92c8bc43-c330-4702-aef2-4ba2c298950a", "email": "filipe@supabase.io", "email_verified": true, "phone_verified": false}', null, '2025-04-03 03:50:59.038087+00', '2025-04-03 22:09:10.979685+00', null, null, '', '', null, '', '0', null, '', null, 'false', null, 'false'); +``` + +## Test environment + +Create `.env` based on `.env.template` with: +- `PROJECT_URL` — URL for the project +- `PROJECT_ANON_TOKEN` — Anon authentication token for the project + +## Run + +```bash +deno test tests.ts --allow-read --allow-net --trace-leaks --allow-env=WS_NO_BUFFER_UTIL +``` diff --git a/test/e2e/tests.ts b/test/e2e/legacy/tests.ts similarity index 98% rename from test/e2e/tests.ts rename to test/e2e/legacy/tests.ts index 2711a959e..4193b06c2 100644 --- a/test/e2e/tests.ts +++ b/test/e2e/legacy/tests.ts @@ -1,8 +1,5 @@ import { load } from "https://deno.land/std@0.224.0/dotenv/mod.ts"; -import { - createClient, - SupabaseClient, -} from "npm:@supabase/supabase-js@2.49.5-next.5"; +import { createClient, SupabaseClient } from "npm:@supabase/supabase-js@latest"; import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { describe, @@ -69,11 +66,7 @@ describe("broadcast extension", () => { while (activeChannel.state == "joining") await sleep(0.2); // Send from unsubscribed channel - supabase.channel(topic, config).send({ - type: "broadcast", - event, - payload: expectedPayload, - }); + supabase.channel(topic, config).httpSend(event, expectedPayload); while (result == null) await sleep(0.2); diff --git a/test/e2e/nix-build.sh b/test/e2e/nix-build.sh new file mode 100755 index 000000000..2371f642c --- /dev/null +++ b/test/e2e/nix-build.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +FLAKE="flake.nix" +FAKE_HASH="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + +hash_count=$(grep -c 'outputHash = "sha256-' "$FLAKE") +[[ "$hash_count" -eq 1 ]] || { echo "Expected exactly one outputHash line, found $hash_count"; exit 1; } + +trap 'git checkout -- "$FLAKE" 2>/dev/null || true' ERR + +update_flake_hash() { + local pattern="$1" replacement="$2" + sed -i.bak "s|outputHash = \"${pattern}\";|outputHash = \"${replacement}\";|" "$FLAKE" + rm -f "${FLAKE}.bak" +} + +update_flake_hash "sha256-.*" "$FAKE_HASH" + +echo "Probing for correct node_modules hash..." +NIX_OUT=$(nix build 2>&1 || true) + +REAL_HASH=$(echo "$NIX_OUT" | grep -Eo 'got:[[:space:]]+(sha256|sha512|sha1)-[A-Za-z0-9+/=]{20,}' | awk '{print $2}' | head -n1) + +if [[ -z "$REAL_HASH" ]]; then + if echo "$NIX_OUT" | grep -q "error:"; then + echo "Build failed:" + echo "$NIX_OUT" + exit 1 + fi + echo "Hash was already correct. Build succeeded." + echo "Done. Binary available at ./result/bin/realtime-check" + exit 0 +fi + +echo "Updating hash to: $REAL_HASH" +update_flake_hash "$FAKE_HASH" "$REAL_HASH" + +echo "Building with correct hash..." +trap - ERR +nix build +echo "Done. Binary available at ./result/bin/realtime-check" diff --git a/test/e2e/package.json b/test/e2e/package.json new file mode 100644 index 000000000..9b04ddb6f --- /dev/null +++ b/test/e2e/package.json @@ -0,0 +1,20 @@ +{ + "name": "realtime-check", + "version": "0.0.1", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.7.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", + "@opentelemetry/resources": "^2.7.1", + "@opentelemetry/sdk-trace-base": "^2.7.1", + "@opentelemetry/semantic-conventions": "^1.41.1", + "@supabase/supabase-js": "2.108.2", + "commander": "^14.0.3", + "kleur": "^4.1.5" + }, + "scripts": { + "check": "bun run realtime-check.ts --", + "build": "bun build --compile --minify-syntax --minify-whitespace --minify-identifiers realtime-check.ts --outfile realtime-check", + "nix": "bash nix-build.sh" + } +} diff --git a/test/e2e/realtime-check.ts b/test/e2e/realtime-check.ts new file mode 100644 index 000000000..0414abacd --- /dev/null +++ b/test/e2e/realtime-check.ts @@ -0,0 +1,1541 @@ +#!/usr/bin/env bun +import assert from "assert"; +import { createClient, SupabaseClient } from "@supabase/supabase-js"; +import { Command } from "commander"; +import kleur from "kleur"; +import { SQL } from "bun"; +import { trace, context, SpanStatusCode, SpanKind, ROOT_CONTEXT } from "@opentelemetry/api"; +import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; + +const program = new Command() + .name("realtime-check") + .description("End-to-end Realtime test suite against any Supabase project") + .option("--project ", "Supabase project ref (required for staging/prod)") + .option("--publishable-key ", "Project publishable (anon) key") + .option("--secret-key ", "Project secret (service role) key") + .option("--db-password ", "Database password (required for staging/prod)") + .option("--env ", "Environment: local | staging | development | prod | production (default: prod)", "prod") + .option("--domain ", "Email domain for the test user", "example.com") + .option("--port ", "Override URL port (useful for local)") + .option("--url ", "Override project URL (e.g. http://127.0.0.1:54321)") + .option("--db-url ", "Override database URL (e.g. postgresql://postgres:postgres@127.0.0.1:54322/postgres)") + .option("--json", "Output results as JSON to stdout") + .option("--otel ", "OTLP HTTP endpoint for tracing (e.g. http://localhost:4318)") + .option("--otel-token ", "Bearer token for authenticated OTLP endpoints") + .option("--test ", "Comma-separated list of test categories to run: functional,load,connection,load-postgres-changes,load-presence,load-broadcast,load-broadcast-from-db,load-broadcast-replay,broadcast,broadcast-replay,presence,authorization,postgres-changes,postgres-changes-filters,broadcast-changes,broadcast-binary") + .option("--debug", "Enable Realtime client debug mode (sets log level to info and enables console logging)") + .parse(); + +const opts = program.opts(); +const ANON_KEY: string = opts.publishableKey; +const SERVICE_KEY: string = opts.secretKey; +const dbPassword: string = opts.dbPassword ?? ""; +const { project, domain: EMAIL_DOMAIN, port, json: JSON_OUTPUT, test: TEST_FILTER, otel: OTEL_ARG, otelToken: OTEL_API_TOKEN, url: URL_ARG, dbUrl: DB_URL_ARG, debug: DEBUG } = opts; +const env: string = opts.env === "production" ? "prod" : opts.env === "development" ? "staging" : opts.env; + +const TEST_CATEGORIES = TEST_FILTER + ? TEST_FILTER.split(",").map((s: string) => s.trim().toLowerCase()) + : null; + +if (env !== "local" && !project && !(URL_ARG && DB_URL_ARG)) { + console.error("--project is required (or provide both --url and --db-url)"); + process.exit(1); +} +if (!ANON_KEY) { + console.error("--publishable-key is required"); + process.exit(1); +} + +const PROJECT_URL = URL_ARG ?? (() => { + if (env === "local") return `http://localhost:${port ?? 54321}`; + if (env === "staging") return `https://${project}.supabase.red`; + return `https://${project}.supabase.co`; +})(); + +const DB_URL = DB_URL_ARG ?? (() => { + const pw = encodeURIComponent(dbPassword ?? "postgres"); + if (env === "local") return `postgresql://postgres:${pw}@localhost:${port ?? 54322}/postgres`; + if (env === "staging") return `postgresql://postgres:${pw}@db.${project}.supabase.red:5432/postgres`; + return `postgresql://postgres:${pw}@db.${project}.supabase.co:5432/postgres`; +})(); + +const DB_SSL = env !== "local" ? { rejectUnauthorized: false } : false; + +const realtimeLogger = DEBUG + ? (kind: string, msg: string, data?: any) => { + if (data !== undefined) console.error(`[realtime] ${kind}: ${msg}`, data); + else console.error(`[realtime] ${kind}: ${msg}`); + } + : undefined; + +const REALTIME_OPTS = { heartbeatIntervalMs: 5000, timeout: 5000, ...(DEBUG ? { logger: realtimeLogger, logLevel: "info" } : {}) }; +const REALTIME_OPTS_REPLAY = { heartbeatIntervalMs: 5000, timeout: 10000, ...(DEBUG ? { logger: realtimeLogger, logLevel: "info" } : {}) }; +const BROADCAST_CONFIG = { config: { broadcast: { self: true } } }; +const EVENT_TIMEOUT_MS = 8000; +const RATE_LIMIT_PAUSE_MS = 2000; +const BROADCAST_API_HEADERS = { + "Content-Type": "application/json", + "Authorization": `Bearer ${ANON_KEY}`, + "apikey": ANON_KEY, +}; +const LOAD_MESSAGES = 20; +const LOAD_SETTLE_MS = 5000; +const LOAD_DELIVERY_SLO = 99; + +const OTEL_ENDPOINT = OTEL_ARG; + +let tracer = trace.getTracer("realtime-check"); +let otelProvider: BasicTracerProvider | null = null; + +function initOtel() { + if (!OTEL_ENDPOINT) return; + const contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + context.setGlobalContextManager(contextManager); + const provider = new BasicTracerProvider({ + resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "realtime-check" }), + spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ + url: `${OTEL_ENDPOINT}/v1/traces`, + ...(OTEL_API_TOKEN ? { headers: { Authorization: `Bearer ${OTEL_API_TOKEN}` } } : {}), + }))], + }); + trace.setGlobalTracerProvider(provider); + tracer = trace.getTracer("realtime-check", "0.0.1"); + otelProvider = provider; +} + +async function flushOtel() { + if (otelProvider) await otelProvider.forceFlush(); +} + +function patchFetch() { + if (!OTEL_ENDPOINT) return; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async function tracedFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/rest/v1") || url.includes("/auth/v1/logout") || url.includes("/auth/v1/admin")) return originalFetch(input, init); + const method = (init?.method ?? (typeof input === "object" && "method" in input ? input.method : undefined) ?? "GET").toUpperCase(); + const span = tracer.startSpan(`HTTP ${method}`, { + kind: SpanKind.CLIENT, + attributes: { "http.method": method, "http.url": url }, + }, context.active()); + return context.with(trace.setSpan(context.active(), span), async () => { + try { + const res = await originalFetch(input, init); + span.setAttribute("http.status_code", res.status); + if (res.status >= 400) span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${res.status}` }); + return res; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + span.setStatus({ code: SpanStatusCode.ERROR, message: msg }); + if (e instanceof Error) span.recordException(e); + throw e; + } finally { + span.end(); + } + }); + }) as typeof fetch; +} + + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const randomTopic = () => "topic:" + crypto.randomUUID(); +const fmtSqlResult = (result: any[]) => { + const count = (result as any).count ?? result.length; + return result.length > 0 ? `count=${count} rows=${JSON.stringify(result)}` : `count=${count}`; +}; +const runSql = (label: string, query: Promise): Promise => + query + .then((r) => { log(kleur.dim(`setup: ${label} ok (${fmtSqlResult(r)})`)); return r; }) + .catch((e: unknown) => { log(kleur.red(`setup: ${label} FAILED: ${e instanceof Error ? e.message : String(e)}`)); throw e; }); +const settle = async (getCount: () => number, expected: number, timeoutMs: number) => { + const deadline = performance.now() + timeoutMs; + while (getCount() < expected && performance.now() < deadline) await sleep(50); +}; +const log = (...args: unknown[]) => JSON_OUTPUT ? process.stderr.write(args.map(String).join(" ") + "\n") : console.log(...args); + +function measureThroughput(latencies: number[], total: number, label: string, slo: number): Metric[] { + const delivered = latencies.length; + const deliveryRate = (delivered / total) * 100; + const sorted = latencies.slice().sort((a, b) => a - b); + if (delivered < total) log(` ${kleur.yellow(`lost ${total - delivered}/${total} ${label}`)}`); + assert(deliveryRate >= slo, `Delivery rate ${deliveryRate.toFixed(1)}% below ${slo}% SLO`); + return [ + { label: "delivered", value: deliveryRate, unit: "%" }, + { label: "p50", value: sorted[Math.ceil(sorted.length * 0.5) - 1] ?? 0, unit: "ms" }, + { label: "p95", value: sorted[Math.ceil(sorted.length * 0.95) - 1] ?? 0, unit: "ms" }, + { label: "p99", value: sorted[Math.ceil(sorted.length * 0.99) - 1] ?? 0, unit: "ms" }, + ]; +} + +type Metric = { label: string; value: number; unit: string }; +type TestResult = { suite: string; name: string; passed: boolean; durationMs: number; metrics: Metric[]; error?: string }; + +let currentSuite = ""; +const results: TestResult[] = []; + +async function test(name: string, fn: () => Promise) { + const start = performance.now(); + const span = tracer.startSpan(name, { + kind: SpanKind.INTERNAL, + attributes: { "suite": currentSuite, "env": env, "project.url": PROJECT_URL }, + }); + const testContext = trace.setSpan(ROOT_CONTEXT, span); + try { + const metrics = await context.with(testContext, fn); + const durationMs = performance.now() - start; + for (const m of metrics) span.setAttribute(`metric.${m.label}`, `${m.value.toFixed(2)}${m.unit}`); + span.setStatus({ code: SpanStatusCode.OK }); + results.push({ suite: currentSuite, name, passed: true, durationMs, metrics }); + const summary = metrics.map((m) => `${kleur.dim(m.label + ":")} ${kleur.cyan(`${m.value.toFixed(m.unit === "%" ? 1 : 0)}${m.unit}`)}`).join(" "); + log(`${kleur.green("PASS")} ${kleur.dim(currentSuite)} / ${name} ${kleur.dim(durationMs.toFixed(0) + "ms")}${summary ? " " + summary : ""}`); + } catch (e: any) { + const durationMs = performance.now() - start; + span.setStatus({ code: SpanStatusCode.ERROR, message: e?.message ?? String(e) }); + span.recordException(e); + results.push({ suite: currentSuite, name, passed: false, durationMs, metrics: [], error: e?.message ?? String(e) }); + log(`${kleur.red("FAIL")} ${kleur.dim(currentSuite)} / ${name} ${kleur.dim(durationMs.toFixed(0) + "ms")} ${kleur.red(e?.message ?? e)}`); + if (e?.stack) log(kleur.dim(e.stack)); + } finally { + span.end(); + } +} + +function suite(name: string) { + currentSuite = name; +} + +async function waitFor(getter: () => T | null, label: string): Promise<{ value: T; latencyMs: number }> { + const span = tracer.startSpan(`wait: ${label}`, { kind: SpanKind.INTERNAL }); + const start = performance.now(); + const deadline = start + EVENT_TIMEOUT_MS; + let value: T | null; + return context.with(trace.setSpan(context.active(), span), async () => { + while ((value = getter()) === null && performance.now() < deadline) await sleep(50); + const latencyMs = performance.now() - start; + if (value === null) { + const msg = `Timed out waiting for ${label} (${latencyMs.toFixed(0)}ms)`; + span.setStatus({ code: SpanStatusCode.ERROR, message: msg }); + span.end(); + throw new Error(msg); + } + span.setAttribute("latency_ms", latencyMs); + span.setStatus({ code: SpanStatusCode.OK }); + span.end(); + return { value, latencyMs }; + }); +} + +async function stopClient(supabase: SupabaseClient) { + await Promise.all([supabase.removeAllChannels(), supabase.auth.stopAutoRefresh()]); + const { error } = await supabase.auth.signOut(); + if (error) log(kleur.dim(`stopClient signOut: ${error.message}`)); +} + +async function signInUser(supabase: SupabaseClient, email: string, password: string) { + const span = tracer.startSpan("sign in", { kind: SpanKind.INTERNAL }); + return context.with(trace.setSpan(context.active(), span), async () => { + const { data, error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) { + span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }); + span.end(); + throw new Error(`Error signing in: ${error.message}`); + } + span.setStatus({ code: SpanStatusCode.OK }); + span.end(); + return data!.session!.access_token; + }); +} + +async function waitForSubscribed(channel: ReturnType): Promise { + const span = tracer.startSpan("wait: subscribe", { kind: SpanKind.INTERNAL }); + const start = performance.now(); + const deadline = start + EVENT_TIMEOUT_MS; + return context.with(trace.setSpan(context.active(), span), async () => { + while (channel.state === "joining" && performance.now() < deadline) await sleep(50); + const latencyMs = performance.now() - start; + if (channel.state !== "joined") { + const msg = `Channel failed to subscribe (topic: ${channel.topic}, state: ${channel.state}, elapsed: ${latencyMs.toFixed(0)}ms)`; + span.setStatus({ code: SpanStatusCode.ERROR, message: msg }); + span.end(); + throw new Error(msg); + } + span.setAttribute("latency_ms", latencyMs); + span.setStatus({ code: SpanStatusCode.OK }); + span.end(); + return latencyMs; + }); +} + +// Subscribes a channel and waits until it is fully joined. +// All data operations must happen after this returns to avoid delivery races. +async function openChannel(channel: ReturnType): Promise { + channel.subscribe(); + return waitForSubscribed(channel); +} + +// Subscribes a postgres_changes channel and waits for both the join and the +// system:ok confirmation that the server-side WAL subscription is active. +async function openPostgresChannel(channel: ReturnType): Promise<{ subscribeMs: number; systemMs: number }> { + const start = performance.now(); + let systemOk = false; + channel.on("system", "*", ({ status }: { status: string }) => { if (status === "ok") systemOk = true; }); + const subscribeMs = await openChannel(channel); + const { latencyMs: systemMs } = await waitFor(() => systemOk ? true : null, "system ok"); + return { subscribeMs, systemMs: performance.now() - start }; +} + +type TableName = "pg_changes" | "dummy" | "authorization" | "broadcast_changes" | "wallet" | "replay_check"; + +async function executeInsert(supabase: SupabaseClient, table: TableName, value?: string): Promise { + const { data, error } = await supabase.from(table).insert([{ value: value ?? crypto.randomUUID() }]).select("id"); + if (error) throw new Error(`Error inserting into ${table}: ${error.message}`); + return (data as { id: number }[])[0].id; +} + +async function executeUpdate(supabase: SupabaseClient, table: TableName, id: number) { + const { error } = await supabase.from(table).update({ value: crypto.randomUUID() }).eq("id", id); + if (error) throw new Error(`Error updating ${table}: ${error.message}`); +} + +async function executeDelete(supabase: SupabaseClient, table: TableName, id: number) { + const { error } = await supabase.from(table).delete().eq("id", id); + if (error) throw new Error(`Error deleting from ${table}: ${error.message}`); +} + +async function setup(): Promise<{ userId: string; testUser: { email: string; password: string }; supabase: SupabaseClient }> { + const start = performance.now(); + const email = `realtime-check-${crypto.randomUUID()}@${EMAIL_DOMAIN}`; + const password = crypto.randomUUID(); + + log("setup: connecting to database"); + const sql = new SQL(DB_URL, { tls: DB_SSL || undefined }); + let userId: string; + try { + let stepStart = performance.now(); + log(kleur.dim("setup: truncating existing tables")); + await Promise.allSettled([ + sql`TRUNCATE TABLE public.pg_changes, public.dummy, public.authorization, public.broadcast_changes, public.replay_check`.then( + () => log(kleur.dim("setup: truncate ok")), + (e: unknown) => log(kleur.dim(`setup: truncate skipped (${e instanceof Error ? e.message : String(e)})`)) + ), + ]); + log(kleur.dim(`setup: truncate done (${(performance.now() - stepStart).toFixed(0)}ms)`)); + + stepStart = performance.now(); + log(kleur.dim("setup: creating tables")); + await Promise.allSettled([ + runSql("table pg_changes", sql`CREATE TABLE IF NOT EXISTS public.pg_changes ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + value text NOT NULL DEFAULT gen_random_uuid(), + details text + )`), + runSql("table dummy", sql`CREATE TABLE IF NOT EXISTS public.dummy ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + value text NOT NULL DEFAULT gen_random_uuid() + )`), + runSql("table authorization", sql`CREATE TABLE IF NOT EXISTS public.authorization ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + value text NOT NULL DEFAULT gen_random_uuid() + )`), + runSql("table broadcast_changes", sql`CREATE TABLE IF NOT EXISTS public.broadcast_changes (id text PRIMARY KEY, value text NOT NULL, topic text NOT NULL)`), + runSql("table wallet", sql`CREATE TABLE IF NOT EXISTS public.wallet (id text PRIMARY KEY, wallet_id text NOT NULL)`), + runSql("table replay_check", sql`CREATE TABLE IF NOT EXISTS public.replay_check ( + id text PRIMARY KEY, + topic text NOT NULL, + event text NOT NULL, + payload jsonb NOT NULL DEFAULT '{}' + )`), + ]); + await runSql("pg_changes details column", sql`ALTER TABLE public.pg_changes ADD COLUMN IF NOT EXISTS details text`); + await runSql("pg_changes nullable_value column", sql`ALTER TABLE public.pg_changes ADD COLUMN IF NOT EXISTS nullable_value text`); + await runSql("pg_changes replica identity", sql`ALTER TABLE public.pg_changes REPLICA IDENTITY FULL`); + log(kleur.dim(`setup: tables done (${(performance.now() - stepStart).toFixed(0)}ms)`)); + + stepStart = performance.now(); + log(kleur.dim("setup: configuring RLS and publications")); + await Promise.allSettled([ + runSql("wallet seed", sql`INSERT INTO public.wallet (id, wallet_id) VALUES ('1', 'wallet_1') ON CONFLICT (id) DO NOTHING`), + runSql("dummy RLS disable", sql`ALTER TABLE public.dummy DISABLE ROW LEVEL SECURITY`), + runSql("pg_changes RLS enable", sql`ALTER TABLE public.pg_changes ENABLE ROW LEVEL SECURITY`), + runSql("authorization RLS enable", sql`ALTER TABLE public.authorization ENABLE ROW LEVEL SECURITY`), + runSql("broadcast_changes RLS enable", sql`ALTER TABLE public.broadcast_changes ENABLE ROW LEVEL SECURITY`), + runSql("wallet RLS enable", sql`ALTER TABLE public.wallet ENABLE ROW LEVEL SECURITY`), + runSql("replay_check RLS enable", sql`ALTER TABLE public.replay_check ENABLE ROW LEVEL SECURITY`), + sql`ALTER PUBLICATION supabase_realtime ADD TABLE public.pg_changes` + .then((r) => log(kleur.dim(`setup: publication pg_changes ok (${fmtSqlResult(r)})`))) + .catch((e: unknown) => log(kleur.dim(`setup: publication pg_changes skipped (${e instanceof Error ? e.message : String(e)})`))), + sql`ALTER PUBLICATION supabase_realtime ADD TABLE public.dummy` + .then((r) => log(kleur.dim(`setup: publication dummy ok (${fmtSqlResult(r)})`))) + .catch((e: unknown) => log(kleur.dim(`setup: publication dummy skipped (${e instanceof Error ? e.message : String(e)})`))), + ]); + log(kleur.dim(`setup: RLS and publications done (${(performance.now() - stepStart).toFixed(0)}ms)`)); + + stepStart = performance.now(); + log(kleur.dim("setup: creating policies")); + await Promise.allSettled([ + runSql("policy 'authenticated receive on topic'", sql`DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'authenticated receive on topic' AND tablename = 'messages' AND schemaname = 'realtime') THEN + CREATE POLICY "authenticated receive on topic" ON "realtime"."messages" AS PERMISSIVE + FOR SELECT TO authenticated USING (realtime.topic() like 'topic:%'); + END IF; + END $$`), + runSql("policy 'authenticated broadcast on topic'", sql`DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'authenticated broadcast on topic' AND tablename = 'messages' AND schemaname = 'realtime') THEN + CREATE POLICY "authenticated broadcast on topic" ON "realtime"."messages" AS PERMISSIVE + FOR INSERT TO authenticated WITH CHECK (realtime.topic() like 'topic:%'); + END IF; + END $$`), + runSql("policy 'allow authenticated users all access'", sql`DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'allow authenticated users all access' AND tablename = 'pg_changes' AND schemaname = 'public') THEN + CREATE POLICY "allow authenticated users all access" ON "public"."pg_changes" AS PERMISSIVE + FOR ALL TO authenticated USING (TRUE); + END IF; + END $$`), + runSql("policy 'authenticated have full access to read on broadcast_changes'", sql`DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'authenticated have full access to read on broadcast_changes' AND tablename = 'broadcast_changes' AND schemaname = 'public') THEN + CREATE POLICY "authenticated have full access to read on broadcast_changes" ON "public"."broadcast_changes" AS PERMISSIVE + FOR ALL TO authenticated USING (TRUE); + END IF; + END $$`), + runSql("policy 'authenticated have full access to replay_check'", sql`DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'authenticated have full access to replay_check' AND tablename = 'replay_check' AND schemaname = 'public') THEN + CREATE POLICY "authenticated have full access to replay_check" ON "public"."replay_check" AS PERMISSIVE + FOR ALL TO authenticated USING (TRUE) WITH CHECK (TRUE); + END IF; + END $$`), + ]); + log(kleur.dim(`setup: policies done (${(performance.now() - stepStart).toFixed(0)}ms)`)); + + stepStart = performance.now(); + log(kleur.dim("setup: creating functions and triggers")); + await runSql("function broadcast_changes_for_table_trigger", sql` + CREATE OR REPLACE FUNCTION broadcast_changes_for_table_trigger() RETURNS TRIGGER AS $$ + DECLARE topic text; + BEGIN + topic = COALESCE(NEW.topic, OLD.topic); + PERFORM realtime.broadcast_changes(topic, TG_OP, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA, NEW, OLD, TG_LEVEL); + RETURN NULL; + END; + $$ LANGUAGE plpgsql + `); + await runSql("broadcast_changes topic column", sql`ALTER TABLE public.broadcast_changes ADD COLUMN IF NOT EXISTS topic text NOT NULL`); + + await runSql("trigger broadcast_changes_for_table_public_broadcast_changes_trigger", sql` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'broadcast_changes_for_table_public_broadcast_changes_trigger') THEN + CREATE TRIGGER broadcast_changes_for_table_public_broadcast_changes_trigger + AFTER INSERT OR UPDATE OR DELETE ON broadcast_changes + FOR EACH ROW EXECUTE FUNCTION broadcast_changes_for_table_trigger(); + END IF; + END $$ + `); + + await runSql("function replay_check_trigger", sql` + CREATE OR REPLACE FUNCTION replay_check_trigger() RETURNS TRIGGER AS $$ + BEGIN + PERFORM realtime.send(NEW.payload, NEW.event, NEW.topic, true); + RETURN NULL; + END; + $$ LANGUAGE plpgsql + `); + + await runSql("trigger replay_check_send_trigger", sql` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'replay_check_send_trigger') THEN + CREATE TRIGGER replay_check_send_trigger + AFTER INSERT ON public.replay_check + FOR EACH ROW EXECUTE FUNCTION replay_check_trigger(); + END IF; + END $$ + `); + + log(kleur.dim(`setup: functions and triggers done (${(performance.now() - stepStart).toFixed(0)}ms)`)); + + log(kleur.dim("setup: creating test user")); + const admin = createClient(PROJECT_URL, SERVICE_KEY); + const { data, error } = await admin.auth.admin.createUser({ email, password, email_confirm: true }); + if (error) throw new Error(`Failed to create test user: ${error.message}`); + userId = data.user.id; + log(kleur.dim(`setup: done (${(performance.now() - start).toFixed(0)}ms)`)); + } finally { + await sql.close().catch(() => {}); + } + + const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + await signInUser(supabase, email, password); + return { userId: userId!, testUser: { email, password }, supabase }; +} + +async function cleanup(userId: string) { + log("cleanup: deleting test user"); + const sql = new SQL(DB_URL, { tls: DB_SSL || undefined }); + try { + await sql`DELETE FROM auth.users WHERE id = ${userId}`; + log(kleur.dim("cleanup: done")); + } catch (_e) { + log(kleur.yellow("Warning: failed to clean up test user")); + } finally { + await sql.close().catch(() => {}); + } +} + +async function runConnectionTest() { + suite("connection"); + + await test("first connect latency", async () => { + const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + try { + const channel = supabase.channel(randomTopic()); + const connectMs = await openChannel(channel); + return [{ label: "connect", value: connectMs, unit: "ms" }]; + } finally { + await stopClient(supabase); + } + }); + + await test("broadcast message throughput", async () => { + const MESSAGES = 50; + const SETTLE_MS = 3000; + const DELIVERY_SLO = 99; + const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + try { + const topic = randomTopic(); + const event = "load"; + const sendTimes = new Map(); + const latencies: number[] = []; + + const channel = supabase + .channel(topic, BROADCAST_CONFIG) + .on("broadcast", { event }, ({ payload }) => { + const t = sendTimes.get(payload.seq); + if (t !== undefined) latencies.push(performance.now() - t); + }); + + await openChannel(channel); + + for (let i = 0; i < MESSAGES; i++) { + sendTimes.set(i, performance.now()); + await channel.send({ type: "broadcast", event, payload: { seq: i } }); + } + + await settle(() => latencies.length, MESSAGES, SETTLE_MS); + + return measureThroughput(latencies, MESSAGES, "messages", DELIVERY_SLO); + } finally { + await stopClient(supabase); + } + }); +} + +async function runLoadPostgresChangesTests(testUser: { email: string; password: string }) { + suite("load-postgres-changes"); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("postgres changes system message latency", async () => { + const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + try { + await signInUser(supabase, testUser.email, testUser.password); + const channel = supabase + .channel(randomTopic(), BROADCAST_CONFIG) + .on("postgres_changes", { event: "INSERT", schema: "public", table: "pg_changes" }, () => {}); + const { systemMs } = await openPostgresChannel(channel); + return [{ label: "system", value: systemMs, unit: "ms" }]; + } finally { + await stopClient(supabase); + } + }); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("postgres changes INSERT throughput", async () => { + const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + try { + await signInUser(supabase, testUser.email, testUser.password); + const sendTimes = new Map(); + const latencies: number[] = []; + + const channel = supabase + .channel(randomTopic(), BROADCAST_CONFIG) + .on("postgres_changes", { event: "INSERT", schema: "public", table: "pg_changes" }, (p) => { + const t = sendTimes.get(p.new.id); + if (t !== undefined) latencies.push(performance.now() - t); + }); + + await openPostgresChannel(channel); + + for (let i = 0; i < LOAD_MESSAGES; i++) { + const t = performance.now(); + const id = await executeInsert(supabase, "pg_changes"); + sendTimes.set(id, t); + } + + await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS); + + return measureThroughput(latencies, LOAD_MESSAGES, "INSERT events", LOAD_DELIVERY_SLO); + } finally { + await stopClient(supabase); + } + }); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("postgres changes UPDATE throughput", async () => { + const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + try { + await signInUser(supabase, testUser.email, testUser.password); + const sendTimes = new Map(); + const latencies: number[] = []; + + const channel = supabase + .channel(randomTopic(), BROADCAST_CONFIG) + .on("postgres_changes", { event: "UPDATE", schema: "public", table: "pg_changes" }, (p) => { + const t = sendTimes.get(p.new.id); + if (t !== undefined) latencies.push(performance.now() - t); + }); + + await openPostgresChannel(channel); + + const ids = await Promise.all(Array.from({ length: LOAD_MESSAGES }, () => executeInsert(supabase, "pg_changes"))); + + await Promise.all(ids.map((id) => { + sendTimes.set(id, performance.now()); + return executeUpdate(supabase, "pg_changes", id); + })); + + await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS); + + return measureThroughput(latencies, LOAD_MESSAGES, "UPDATE events", LOAD_DELIVERY_SLO); + } finally { + await stopClient(supabase); + } + }); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("postgres changes DELETE throughput", async () => { + const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + try { + await signInUser(supabase, testUser.email, testUser.password); + const sendTimes = new Map(); + const latencies: number[] = []; + + const channel = supabase + .channel(randomTopic(), BROADCAST_CONFIG) + .on("postgres_changes", { event: "DELETE", schema: "public", table: "pg_changes" }, (p) => { + const t = sendTimes.get(p.old.id); + if (t !== undefined) latencies.push(performance.now() - t); + }); + + await openPostgresChannel(channel); + + const ids = await Promise.all(Array.from({ length: LOAD_MESSAGES }, () => executeInsert(supabase, "pg_changes"))); + + await Promise.all(ids.map((id) => { + sendTimes.set(id, performance.now()); + return executeDelete(supabase, "pg_changes", id); + })); + + await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS); + + return measureThroughput(latencies, LOAD_MESSAGES, "DELETE events", LOAD_DELIVERY_SLO); + } finally { + await stopClient(supabase); + } + }); +} + +async function runLoadPresenceTests() { + suite("load-presence"); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("presence join throughput", async () => { + const CLIENTS = 10; + const observer = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + const senders: ReturnType[] = []; + try { + const topic = randomTopic(); + const trackTimes = new Map(); + const latencies: number[] = []; + + const observerChannel = observer + .channel(topic, { config: { broadcast: { self: true }, presence: { key: "observer" } } }) + .on("presence", { event: "join" }, (e) => { + if (e.key === "observer") return; + const t = trackTimes.get(e.key); + if (t !== undefined) latencies.push(performance.now() - t); + }); + await openChannel(observerChannel); + + const clients = Array.from({ length: CLIENTS }, (_, i) => ({ + client: createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }), + key: `client-${i}`, + })); + senders.push(...clients.map((c) => c.client)); + + const channels = await Promise.all(clients.map(async ({ client, key }) => { + const ch = client.channel(topic, { config: { presence: { key } } }); + await openChannel(ch); + return { ch, key }; + })); + + await Promise.all(channels.map(({ ch, key }) => { + trackTimes.set(key, performance.now()); + return ch.track({ key }); + })); + + await settle(() => latencies.length, CLIENTS, LOAD_SETTLE_MS); + + return measureThroughput(latencies, CLIENTS, "presence joins", LOAD_DELIVERY_SLO); + } finally { + await Promise.all(senders.map((c) => stopClient(c))); + await stopClient(observer); + } + }); +} + +async function runLoadBroadcastFromDbTests(testUser: { email: string; password: string }) { + suite("load-broadcast-from-db"); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("broadcast from database throughput", async () => { + const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + try { + await signInUser(supabase, testUser.email, testUser.password); + const testTopic = randomTopic(); + const sendTimes = new Map(); + const latencies: number[] = []; + + const channel = supabase + .channel(testTopic, { config: { private: true } }) + .on("broadcast", { event: "INSERT" }, (res) => { + const t = sendTimes.get(res.payload.record.id); + if (t !== undefined) latencies.push(performance.now() - t); + }); + + await openChannel(channel); + + await Promise.all(Array.from({ length: LOAD_MESSAGES }, async () => { + const id = crypto.randomUUID(); + sendTimes.set(id, performance.now()); + await supabase.from("broadcast_changes").insert({ id, value: crypto.randomUUID(), topic: testTopic }); + })); + + await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS); + + await supabase.from("broadcast_changes").delete().in("id", [...sendTimes.keys()]); + + return measureThroughput(latencies, LOAD_MESSAGES, "broadcast-from-db events", LOAD_DELIVERY_SLO); + } finally { + await stopClient(supabase); + } + }); +} + +async function runLoadBroadcastTests() { + suite("load-broadcast"); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("broadcast self throughput", async () => { + const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + try { + const event = "load"; + const topic = randomTopic(); + const sendTimes = new Map(); + const latencies: number[] = []; + + const channel = supabase + .channel(topic, BROADCAST_CONFIG) + .on("broadcast", { event }, ({ payload }) => { + const t = sendTimes.get(payload.seq); + if (t !== undefined) latencies.push(performance.now() - t); + }); + + await openChannel(channel); + + for (let i = 0; i < LOAD_MESSAGES; i++) { + sendTimes.set(i, performance.now()); + await channel.send({ type: "broadcast", event, payload: { seq: i } }); + } + + await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS); + + return measureThroughput(latencies, LOAD_MESSAGES, "broadcast events", LOAD_DELIVERY_SLO); + } finally { + await stopClient(supabase); + } + }); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("broadcast API endpoint throughput", async () => { + const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + try { + const event = "load"; + const topic = randomTopic(); + const sendTimes = new Map(); + const latencies: number[] = []; + + const channel = supabase + .channel(topic, BROADCAST_CONFIG) + .on("broadcast", { event }, ({ payload }) => { + const t = sendTimes.get(payload.seq); + if (t !== undefined) latencies.push(performance.now() - t); + }); + + await openChannel(channel); + + await Promise.all(Array.from({ length: LOAD_MESSAGES }, async (_, i) => { + sendTimes.set(i, performance.now()); + const res = await fetch(`${PROJECT_URL}/realtime/v1/api/broadcast`, { + method: "POST", + headers: BROADCAST_API_HEADERS, + body: JSON.stringify({ messages: [{ topic, event, payload: { seq: i } }] }), + }); + if (!res.ok) throw new Error(`Broadcast API returned ${res.status}`); + })); + + await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS); + + return measureThroughput(latencies, LOAD_MESSAGES, "broadcast API events", LOAD_DELIVERY_SLO); + } finally { + await stopClient(supabase); + } + }); +} + +async function runLoadBroadcastReplayTests(testUser: { email: string; password: string }) { + suite("load-broadcast-replay"); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("broadcast replay throughput", async () => { + const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS_REPLAY }); + try { + await signInUser(supabase, testUser.email, testUser.password); + const event = crypto.randomUUID(); + const topic = randomTopic(); + + const since = Date.now() - 1000; + await Promise.all(Array.from({ length: LOAD_MESSAGES }, (_, i) => + supabase.from("replay_check").insert({ id: crypto.randomUUID(), topic, event, payload: { seq: i } }) + )); + + const latencies: number[] = []; + const replayStart = performance.now(); + const receiver = supabase.channel(topic, { + config: { private: true, broadcast: { replay: { since, limit: 25 } } }, + }).on("broadcast", { event }, () => { + latencies.push(performance.now() - replayStart); + }); + await openChannel(receiver); + + await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS); + + return measureThroughput(latencies, LOAD_MESSAGES, "replayed broadcast events", LOAD_DELIVERY_SLO); + } finally { + await stopClient(supabase); + } + }); +} + + +async function runBroadcastTests() { + suite("broadcast extension"); + + await test("user is able to receive self broadcast", async () => { + const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + try { + let result: any = null; + const event = crypto.randomUUID(); + const topic = randomTopic(); + const expectedPayload = { message: crypto.randomUUID() }; + + const channel = supabase + .channel(topic, BROADCAST_CONFIG) + .on("broadcast", { event }, ({ payload }) => (result = payload)); + + const subscribeMs = await openChannel(channel); + await channel.send({ type: "broadcast", event, payload: expectedPayload }); + const { latencyMs: eventMs } = await waitFor(() => result, "broadcast event"); + + assert.deepStrictEqual(result, expectedPayload); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }]; + } finally { + await stopClient(supabase); + } + }); + + await test("user is able to use the endpoint to broadcast", async () => { + const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + try { + let result: any = null; + const event = crypto.randomUUID(); + const topic = randomTopic(); + const expectedPayload = { message: crypto.randomUUID() }; + + const channel = supabase + .channel(topic, BROADCAST_CONFIG) + .on("broadcast", { event }, ({ payload }) => (result = payload)); + + const subscribeMs = await openChannel(channel); + // Small settle window so server-side subscription routing is ready before the HTTP broadcast arrives. + await sleep(100); + + const res = await fetch(`${PROJECT_URL}/realtime/v1/api/broadcast`, { + method: "POST", + headers: BROADCAST_API_HEADERS, + body: JSON.stringify({ messages: [{ topic, event, payload: expectedPayload }] }), + }); + if (!res.ok) throw new Error(`Broadcast API returned ${res.status}`); + + const { latencyMs: eventMs } = await waitFor(() => result, "broadcast event"); + assert.deepStrictEqual(result, expectedPayload); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }]; + } finally { + await stopClient(supabase); + } + }); +} + +async function runPresenceTests(_testUser: { email: string; password: string }, supabase: SupabaseClient) { + suite("presence extension"); + + await test("user is able to receive presence updates", async () => { + try { + let joinEvent: any = null; + const topic = randomTopic(); + const message = crypto.randomUUID(); + const key = crypto.randomUUID(); + + const channel = supabase + .channel(topic, { config: { broadcast: { self: true }, presence: { key } } }) + .on("presence", { event: "join" }, (e) => (joinEvent = e)); + + const subscribeMs = await openChannel(channel); + const trackStart = performance.now(); + if (await channel.track({ message }, { timeout: 5000 }) === "timed out") throw new Error("track() timed out"); + const trackMs = performance.now() - trackStart; + const { latencyMs: eventMs } = await waitFor(() => joinEvent, "presence join"); + + assert.strictEqual(joinEvent.key, key); + assert.strictEqual(joinEvent.newPresences[0].message, message); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "track", value: trackMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }]; + } finally { + await supabase.removeAllChannels(); + } + }); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("user is able to receive presence updates on private channels", async () => { + try { + + let joinEvent: any = null; + const topic = randomTopic(); + const message = crypto.randomUUID(); + const key = crypto.randomUUID(); + + const channel = supabase + .channel(topic, { config: { private: true, broadcast: { self: true }, presence: { key } } }) + .on("presence", { event: "join" }, (e) => (joinEvent = e)); + + const subscribeMs = await openChannel(channel); + const trackStart = performance.now(); + if (await channel.track({ message }, { timeout: 5000 }) === "timed out") throw new Error("track() timed out"); + const trackMs = performance.now() - trackStart; + const { latencyMs: eventMs } = await waitFor(() => joinEvent, "presence join"); + + assert.strictEqual(joinEvent.key, key); + assert.strictEqual(joinEvent.newPresences[0].message, message); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "track", value: trackMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }]; + } finally { + await supabase.removeAllChannels(); + } + }); +} + +async function runAuthorizationTests(_testUser: { email: string; password: string }, supabase: SupabaseClient) { + suite("authorization check"); + + await test("user using private channel cannot connect without permissions", async () => { + try { + const topic = "restricted:" + crypto.randomUUID(); + const channel = supabase.channel(topic, { config: { private: true } }).subscribe(); + + const { value: finalState, latencyMs: rejectMs } = await waitFor( + () => channel.state !== "joining" ? channel.state : null, + "channel rejection" + ); + + assert.notStrictEqual(finalState, "joined", `Expected channel to be rejected but state is: ${finalState}`); + return [{ label: "rejection", value: rejectMs, unit: "ms" }]; + } finally { + await supabase.removeAllChannels(); + } + }); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("user using private channel can connect with enough permissions", async () => { + try { + const channel = supabase.channel(randomTopic(), { config: { private: true } }); + const subscribeMs = await openChannel(channel); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }]; + } finally { + await supabase.removeAllChannels(); + } + }); +} + +async function runBroadcastChangesTests(_testUser: { email: string; password: string }, supabase: SupabaseClient) { + suite("broadcast changes"); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("authenticated user receives INSERT broadcast change", async () => { + try { + const testTopic = randomTopic(); + const id = crypto.randomUUID(); + const value = crypto.randomUUID(); + let result: any = null; + + const channel = supabase + .channel(testTopic, { config: { private: true } }) + .on("broadcast", { event: "INSERT" }, (res) => (result = res)); + + const subscribeMs = await openChannel(channel); + await sleep(500); + await supabase.from("broadcast_changes").insert({ value, id, topic: testTopic }); + const { latencyMs: eventMs } = await waitFor(() => result, "INSERT event"); + + assert.strictEqual(result.payload.record.id, id); + assert.strictEqual(result.payload.record.value, value); + assert.strictEqual(result.payload.old_record, null); + assert.strictEqual(result.payload.operation, "INSERT"); + assert.strictEqual(result.payload.schema, "public"); + assert.strictEqual(result.payload.table, "broadcast_changes"); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }]; + } finally { + await supabase.removeAllChannels(); + } + }); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("authenticated user receives UPDATE broadcast change", async () => { + try { + const testTopic = randomTopic(); + const id = crypto.randomUUID(); + const originalValue = crypto.randomUUID(); + const updatedValue = crypto.randomUUID(); + let result: any = null; + + const channel = supabase + .channel(testTopic, { config: { private: true } }) + .on("broadcast", { event: "UPDATE" }, (res) => (result = res)); + + const subscribeMs = await openChannel(channel); + await sleep(100); + await supabase.from("broadcast_changes").insert({ value: originalValue, id, topic: testTopic }); + await supabase.from("broadcast_changes").update({ value: updatedValue }).eq("id", id); + const { latencyMs: eventMs } = await waitFor(() => result, "UPDATE event"); + + assert.strictEqual(result.payload.record.id, id); + assert.strictEqual(result.payload.record.value, updatedValue); + assert.strictEqual(result.payload.old_record.id, id); + assert.strictEqual(result.payload.old_record.value, originalValue); + assert.strictEqual(result.payload.operation, "UPDATE"); + assert.strictEqual(result.payload.schema, "public"); + assert.strictEqual(result.payload.table, "broadcast_changes"); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }]; + } finally { + await supabase.removeAllChannels(); + } + }); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("authenticated user receives DELETE broadcast change", async () => { + try { + const testTopic = randomTopic(); + const id = crypto.randomUUID(); + const value = crypto.randomUUID(); + let result: any = null; + + const channel = supabase + .channel(testTopic, { config: { private: true } }) + .on("broadcast", { event: "DELETE" }, (res) => (result = res)); + + const subscribeMs = await openChannel(channel); + await sleep(100); + await supabase.from("broadcast_changes").insert({ value, id, topic: testTopic }); + await supabase.from("broadcast_changes").delete().eq("id", id); + const { latencyMs: eventMs } = await waitFor(() => result, "DELETE event"); + + assert.strictEqual(result.payload.record, null); + assert.strictEqual(result.payload.old_record.id, id); + assert.strictEqual(result.payload.old_record.value, value); + assert.strictEqual(result.payload.operation, "DELETE"); + assert.strictEqual(result.payload.schema, "public"); + assert.strictEqual(result.payload.table, "broadcast_changes"); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }]; + } finally { + await supabase.removeAllChannels(); + } + }); +} + +async function runPostgresChangesTests(_testUser: { email: string; password: string }, supabase: SupabaseClient) { + suite("postgres changes extension"); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("user receives INSERT events with filter", async () => { + try { + + let result: unknown = null; + const uniqueValue = crypto.randomUUID(); + + const channel = supabase + .channel(randomTopic(), BROADCAST_CONFIG) + .on("postgres_changes", + { event: "INSERT", schema: "public", table: "pg_changes", filter: `value=eq.${uniqueValue}` }, + (payload) => (result = payload)); + + const { subscribeMs } = await openPostgresChannel(channel); + await executeInsert(supabase, "pg_changes", uniqueValue); + await executeInsert(supabase, "dummy"); + const { latencyMs: eventMs } = await waitFor(() => result, "INSERT event"); + + assert.strictEqual(result.eventType, "INSERT"); + assert.strictEqual(result.new.value, uniqueValue); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }]; + } finally { + await supabase.removeAllChannels(); + } + }); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("user receives UPDATE events with filter", async () => { + try { + + let result: unknown = null; + const mainId = await executeInsert(supabase, "pg_changes"); + const fakeId = await executeInsert(supabase, "pg_changes"); + const dummyId = await executeInsert(supabase, "dummy"); + + const channel = supabase + .channel(randomTopic(), BROADCAST_CONFIG) + .on("postgres_changes", + { event: "UPDATE", schema: "public", table: "pg_changes", filter: `id=eq.${mainId}` }, + (payload) => (result = payload)); + + const { subscribeMs } = await openPostgresChannel(channel); + await Promise.all([ + executeUpdate(supabase, "pg_changes", mainId), + executeUpdate(supabase, "pg_changes", fakeId), + executeUpdate(supabase, "dummy", dummyId), + ]); + const { latencyMs: eventMs } = await waitFor(() => result, "UPDATE event"); + + assert.strictEqual(result.eventType, "UPDATE"); + assert.strictEqual(result.new.id, mainId); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }]; + } finally { + await supabase.removeAllChannels(); + } + }); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("user receives DELETE events with filter", async () => { + try { + + let result: unknown = null; + const mainId = await executeInsert(supabase, "pg_changes"); + const fakeId = await executeInsert(supabase, "pg_changes"); + const dummyId = await executeInsert(supabase, "dummy"); + + const channel = supabase + .channel(randomTopic(), BROADCAST_CONFIG) + .on("postgres_changes", + { event: "DELETE", schema: "public", table: "pg_changes", filter: `id=eq.${mainId}` }, + (payload) => (result = payload)); + + const { subscribeMs } = await openPostgresChannel(channel); + await Promise.all([ + executeDelete(supabase, "pg_changes", mainId), + executeDelete(supabase, "pg_changes", fakeId), + executeDelete(supabase, "dummy", dummyId), + ]); + const { latencyMs: eventMs } = await waitFor(() => result, "DELETE event"); + + assert.strictEqual(result.eventType, "DELETE"); + assert.strictEqual(result.old.id, mainId); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }]; + } finally { + await supabase.removeAllChannels(); + } + }); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("user receives INSERT, UPDATE and DELETE concurrently", async () => { + try { + let insertResult: unknown = null, updateResult: unknown = null, deleteResult: unknown = null; + + const insertValue = crypto.randomUUID(); + const updateId = await executeInsert(supabase, "pg_changes"); + const deleteId = await executeInsert(supabase, "pg_changes"); + + const channel = supabase + .channel(randomTopic(), BROADCAST_CONFIG) + .on("postgres_changes", { event: "INSERT", schema: "public", table: "pg_changes", filter: `value=eq.${insertValue}` }, (p) => (insertResult = p)) + .on("postgres_changes", { event: "UPDATE", schema: "public", table: "pg_changes", filter: `id=eq.${updateId}` }, (p) => (updateResult = p)) + .on("postgres_changes", { event: "DELETE", schema: "public", table: "pg_changes", filter: `id=eq.${deleteId}` }, (p) => (deleteResult = p)); + + const { subscribeMs } = await openPostgresChannel(channel); + + await Promise.all([ + executeInsert(supabase, "pg_changes", insertValue), + executeUpdate(supabase, "pg_changes", updateId), + executeDelete(supabase, "pg_changes", deleteId), + ]); + + const [{ latencyMs: insertMs }, { latencyMs: updateMs }, { latencyMs: deleteMs }] = await Promise.all([ + waitFor(() => insertResult, "INSERT event"), + waitFor(() => updateResult, "UPDATE event"), + waitFor(() => deleteResult, "DELETE event"), + ]); + + assert.strictEqual(insertResult.eventType, "INSERT"); + assert.strictEqual(updateResult.eventType, "UPDATE"); + assert.strictEqual(deleteResult.eventType, "DELETE"); + return [ + { label: "subscribe", value: subscribeMs, unit: "ms" }, + { label: "INSERT", value: insertMs, unit: "ms" }, + { label: "UPDATE", value: updateMs, unit: "ms" }, + { label: "DELETE", value: deleteMs, unit: "ms" }, + ]; + } finally { + await supabase.removeAllChannels(); + } + }); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("select — omitting select returns full payload (backward compatible)", async () => { + try { + let result: any = null; + const uniqueValue = crypto.randomUUID(); + const details = crypto.randomUUID(); + + const channel = supabase + .channel(randomTopic(), BROADCAST_CONFIG) + .on("postgres_changes", + { event: "INSERT", schema: "public", table: "pg_changes", filter: `value=eq.${uniqueValue}` }, + (payload) => (result = payload)); + + const { subscribeMs } = await openPostgresChannel(channel); + await supabase.from("pg_changes").insert({ value: uniqueValue, details }); + const { latencyMs: eventMs } = await waitFor(() => result, "INSERT event"); + + assert.strictEqual(result.eventType, "INSERT"); + assert.ok(result.new.id !== undefined, "id must be present"); + assert.strictEqual(result.new.value, uniqueValue, "value must be present when no select is used"); + assert.strictEqual(result.new.details, details, "details must be present when no select is used"); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }]; + } finally { + await supabase.removeAllChannels(); + } + }); + +} + +async function runPostgresChangesFiltersTests(_testUser: { email: string; password: string }, supabase: SupabaseClient) { + suite("postgres-changes-filters"); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("in: delivers row whose value is in the list", async () => { + try { + const tag = crypto.randomUUID().replace(/-/g, ""); + const values = [`inA${tag}`, `inB${tag}`, `inC${tag}`]; + const chosen = values[1]; + let result: any = null; + + const channel = supabase + .channel(randomTopic(), BROADCAST_CONFIG) + .on("postgres_changes", { event: "INSERT", schema: "public", table: "pg_changes", filter: `value=in.(${values.join(",")})` }, (p) => { if (p.new.value === chosen) result = p; }); + + const { subscribeMs } = await openPostgresChannel(channel); + await executeInsert(supabase, "pg_changes", chosen); + await waitFor(() => result, "in event"); + + assert.strictEqual(result.eventType, "INSERT"); + assert.strictEqual(result.new.value, chosen); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }]; + } finally { + await supabase.removeAllChannels(); + } + }); + +} + +async function runBroadcastReplayTests(_testUser: { email: string; password: string }, supabase: SupabaseClient) { + suite("broadcast replay"); + + await test("replayed messages are delivered on join", async () => { + try { + const event = crypto.randomUUID(); + const topic = randomTopic(); + const payload = { message: crypto.randomUUID() }; + + const since = Date.now() - 1000; + await supabase.from("replay_check").insert({ id: crypto.randomUUID(), topic, event, payload }); + + await sleep(500); + + let result: any = null; + const receiver = supabase.channel(topic, { + config: { private: true, broadcast: { replay: { since, limit: 1 } } }, + }).on("broadcast", { event }, (msg) => (result = msg.payload)); + const subscribeMs = await openChannel(receiver); + + const { latencyMs: replayMs } = await waitFor(() => result, "replayed broadcast event"); + + assert.strictEqual(result.message, payload.message); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "replay", value: replayMs, unit: "ms" }]; + } finally { + await supabase.removeAllChannels(); + } + }); + + await test("replayed messages carry meta.replayed flag", async () => { + try { + const event = crypto.randomUUID(); + const topic = randomTopic(); + + const since = Date.now() - 1000; + await supabase.from("replay_check").insert({ id: crypto.randomUUID(), topic, event, payload: { value: 1 } }); + + await sleep(500); + + let receivedMeta: any = null; + const receiver = supabase.channel(topic, { + config: { private: true, broadcast: { replay: { since, limit: 1 } } }, + }).on("broadcast", { event }, (msg) => (receivedMeta = msg.meta)); + await openChannel(receiver); + + await waitFor(() => receivedMeta, "replayed broadcast meta"); + + assert.strictEqual(receivedMeta?.replayed, true); + return []; + } finally { + await supabase.removeAllChannels(); + } + }); + + await test("messages before since are not replayed", async () => { + try { + const event = crypto.randomUUID(); + const topic = randomTopic(); + + await supabase.from("replay_check").insert({ id: crypto.randomUUID(), topic, event, payload: { value: "old" } }); + + // Sleep to ensure the DB insert timestamp is clearly before `since`, + // guarding against clock skew between JS client and DB server. + await sleep(1000); + const since = Date.now(); + + let result: any = null; + const receiver = supabase.channel(topic, { + config: { private: true, broadcast: { replay: { since, limit: 25 } } }, + }).on("broadcast", { event }, (msg) => (result = msg.payload)); + await openChannel(receiver); + + await sleep(500); + + assert.strictEqual(result, null); + return []; + } finally { + await supabase.removeAllChannels(); + } + }); +} + + +function printSummary(totalMs: number) { + const passed = results.filter((r) => r.passed); + const failed = results.filter((r) => !r.passed); + const suites = [...new Set(results.map((r) => r.suite))]; + + if (JSON_OUTPUT) { + const slis: Record> = {}; + for (const r of passed) { + for (const m of r.metrics) { + const key = `${r.suite} / ${r.name}`; + slis[key] ??= {}; + slis[key][m.label] = { value: m.value, unit: m.unit }; + } + } + const output = { + passed: failed.length === 0, + durationMs: Math.round(totalMs), + summary: { total: results.length, passed: passed.length, failed: failed.length }, + slis, + suites: Object.fromEntries(suites.map((suite) => { + const suiteResults = results.filter((r) => r.suite === suite); + return [suite, { + passed: suiteResults.every((r) => r.passed), + tests: suiteResults.map((r) => ({ + name: r.name, + passed: r.passed, + durationMs: Math.round(r.durationMs), + ...(r.error ? { error: r.error } : {}), + slis: Object.fromEntries(r.metrics.map((m) => [m.label, { value: m.value, unit: m.unit }])), + })), + }]; + })), + }; + process.stdout.write(JSON.stringify(output, null, 2) + "\n"); + return; + } + + log(`\n${kleur.bold(`${passed.length} passed, ${failed.length} failed`)} ${kleur.dim(`total ${(totalMs / 1000).toFixed(2)}s`)}`); + + if (failed.length > 0) { + log("\nFailed:"); + for (const r of failed) { + log(` ${kleur.red("✗")} ${r.suite} / ${r.name}`); + if (r.error) log(` ${kleur.dim(r.error)}`); + } + } +} + +type SuiteCtx = { testUser: { email: string; password: string }; supabase: SupabaseClient }; + +const SUITES: Record Promise> = { + "connection": () => runConnectionTest(), + "load-postgres-changes": ({ testUser }) => runLoadPostgresChangesTests(testUser), + "load-presence": () => runLoadPresenceTests(), + "load-broadcast": () => runLoadBroadcastTests(), + "load-broadcast-from-db": ({ testUser }) => runLoadBroadcastFromDbTests(testUser), + "load-broadcast-replay": ({ testUser }) => runLoadBroadcastReplayTests(testUser), + "broadcast": () => runBroadcastTests(), + "broadcast-replay": ({ testUser, supabase }) => runBroadcastReplayTests(testUser, supabase), + "presence": ({ testUser, supabase }) => runPresenceTests(testUser, supabase), + "authorization": ({ testUser, supabase }) => runAuthorizationTests(testUser, supabase), + "postgres-changes": ({ testUser, supabase }) => runPostgresChangesTests(testUser, supabase), + "postgres-changes-filters": ({ testUser, supabase }) => runPostgresChangesFiltersTests(testUser, supabase), + "broadcast-changes": ({ testUser, supabase }) => runBroadcastChangesTests(testUser, supabase), + "broadcast-binary": ({ supabase }) => runBroadcastBinaryTests(supabase), +}; + +async function runBroadcastBinaryTests(supabase: SupabaseClient) { + suite("broadcast binary"); + + await sleep(RATE_LIMIT_PAUSE_MS); + await test("send_binary delivers a binary broadcast", async () => { + const sql = new SQL(DB_URL, { tls: DB_SSL || undefined }); + try { + const event = crypto.randomUUID(); + const topic = randomTopic(); + const binary = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x00, 0xff]); + + let result: any = null; + const channel = supabase + .channel(topic, { config: { private: true } }) + .on("broadcast", { event }, (msg) => (result = msg.payload)); + + const subscribeMs = await openChannel(channel); + await sleep(100); + + await sql`SELECT realtime.send_binary(${binary}::bytea, ${event}::text, ${topic}::text, true)`; + + const { latencyMs: eventMs } = await waitFor(() => result, "binary broadcast event"); + + const received = result instanceof Uint8Array ? result : new Uint8Array(result); + assert.strictEqual(received.length, binary.length, "binary payload length mismatch"); + assert.ok(binary.every((b, i) => received[i] === b), "binary payload bytes mismatch"); + return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }]; + } finally { + await sql.close().catch(() => {}); + await supabase.removeAllChannels(); + } + }); +} + +const LOAD_SUITES = Object.keys(SUITES).filter((k) => k.startsWith("load")); +const FUNCTIONAL_SUITES = Object.keys(SUITES).filter((k) => !k.startsWith("load")); + +const DB_REQUIRED_SUITES = new Set([ + "load-postgres-changes", + "load-broadcast-from-db", + "load-broadcast-replay", + "broadcast-replay", + "presence", + "authorization", + "postgres-changes", + "postgres-changes-filters", + "broadcast-changes", + "broadcast-binary", +]); + +async function main() { + initOtel(); + patchFetch(); + + const activeCategories = TEST_CATEGORIES + ? TEST_CATEGORIES.flatMap((c: string) => { + if (c === "functional") return FUNCTIONAL_SUITES; + if (c === "load") return LOAD_SUITES; + return [c]; + }) + : null; + + if (activeCategories) { + const unknown = activeCategories.filter((c: string) => !(c in SUITES)); + if (unknown.length > 0) { + const valid = ["functional", "load", ...Object.keys(SUITES)].join(", "); + log(`Unknown test categories: ${unknown.join(", ")}\nValid categories: ${valid}`); + process.exit(1); + } + } + + const suitesToRun = activeCategories + ? Object.entries(SUITES).filter(([key]) => activeCategories.includes(key)) + : Object.entries(SUITES); + + const needsDb = suitesToRun.some(([key]) => DB_REQUIRED_SUITES.has(key)); + + if (needsDb && !SERVICE_KEY) { + console.error("--secret-key is required"); + process.exit(1); + } + + if (needsDb && env !== "local" && !dbPassword && !DB_URL_ARG) { + console.error("--db-password is required for staging and prod environments"); + process.exit(1); + } + + let userId: string | null = null; + let testUser: { email: string; password: string } = { email: "", password: "" }; + let supabase: SupabaseClient = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }); + + if (needsDb) { + const setupResult = await setup(); + userId = setupResult.userId; + testUser = setupResult.testUser; + supabase = setupResult.supabase; + } + + const start = performance.now(); + try { + for (const [, fn] of suitesToRun) await fn({ testUser, supabase }); + } finally { + await stopClient(supabase); + if (userId) await cleanup(userId); + } + + printSummary(performance.now() - start); + await flushOtel(); + + if (results.some((r) => !r.passed)) process.exit(1); +} + +main().catch((e) => { + console.error(kleur.red("Fatal error:"), e.message); + if (e?.stack) console.error(kleur.dim(e.stack)); + process.exit(1); +}); diff --git a/test/e2e/supabase/.branches/_current_branch b/test/e2e/supabase/.branches/_current_branch new file mode 100644 index 000000000..88d050b19 --- /dev/null +++ b/test/e2e/supabase/.branches/_current_branch @@ -0,0 +1 @@ +main \ No newline at end of file diff --git a/test/e2e/supabase/.gitignore b/test/e2e/supabase/.gitignore new file mode 100644 index 000000000..ad9264f0b --- /dev/null +++ b/test/e2e/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/test/e2e/supabase/.temp/cli-latest b/test/e2e/supabase/.temp/cli-latest new file mode 100644 index 000000000..f98a4ce08 --- /dev/null +++ b/test/e2e/supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.105.0 \ No newline at end of file diff --git a/test/e2e/supabase/config.toml b/test/e2e/supabase/config.toml new file mode 100644 index 000000000..cfbb86938 --- /dev/null +++ b/test/e2e/supabase/config.toml @@ -0,0 +1,388 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "e2e" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +# This feature is only available on the hosted platform. +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/test/extensions/extensions_test.exs b/test/extensions/extensions_test.exs new file mode 100644 index 000000000..ba02da309 --- /dev/null +++ b/test/extensions/extensions_test.exs @@ -0,0 +1,54 @@ +defmodule Realtime.ExtensionsTest do + use ExUnit.Case, async: true + + alias Realtime.Extensions + + describe "db_settings/1" do + test "returns default and required for postgres_cdc_rls" do + result = Extensions.db_settings("postgres_cdc_rls") + + assert %{default: default, required: required} = result + assert is_map(default) + assert is_list(required) + end + + test "default contains expected keys" do + %{default: default} = Extensions.db_settings("postgres_cdc_rls") + + assert Map.has_key?(default, "poll_interval_ms") + assert Map.has_key?(default, "poll_max_changes") + assert Map.has_key?(default, "poll_max_record_bytes") + assert Map.has_key?(default, "publication") + assert Map.has_key?(default, "slot_name") + end + + test "required contains expected fields" do + %{required: required} = Extensions.db_settings("postgres_cdc_rls") + + field_names = Enum.map(required, fn {name, _validator, _encrypt} -> name end) + + assert "db_host" in field_names + assert "db_port" in field_names + assert "db_name" in field_names + assert "db_user" in field_names + assert "db_password" in field_names + end + + test "optional contains the runtime credentials" do + %{required: required, optional: optional} = Extensions.db_settings("postgres_cdc_rls") + required_names = Enum.map(required, fn {name, _validator, _encrypt} -> name end) + optional_names = Enum.map(optional, fn {name, _validator, _encrypt} -> name end) + + assert "db_user_realtime" in optional_names + assert "db_pass_realtime" in optional_names + + refute "db_user_realtime" in required_names + refute "db_pass_realtime" in required_names + end + + test "returns empty default for unknown extension type" do + result = Extensions.db_settings("unknown_extension") + assert %{default: %{}, required: [], optional: []} = result + end + end +end diff --git a/test/extensions/postgres_cdc_rls/db_settings_test.exs b/test/extensions/postgres_cdc_rls/db_settings_test.exs new file mode 100644 index 000000000..49d6de918 --- /dev/null +++ b/test/extensions/postgres_cdc_rls/db_settings_test.exs @@ -0,0 +1,50 @@ +defmodule Extensions.PostgresCdcRls.DbSettingsTest do + use ExUnit.Case, async: true + + alias Extensions.PostgresCdcRls.DbSettings + + describe "default/0" do + test "returns a map with expected keys and values" do + default = DbSettings.default() + + assert default["poll_interval_ms"] == 100 + assert default["poll_max_changes"] == 100 + assert default["poll_max_record_bytes"] == 1_048_576 + assert default["publication"] == "supabase_realtime" + assert default["slot_name"] == "supabase_realtime_replication_slot" + end + end + + describe "required/0" do + test "returns a list of tuples" do + required = DbSettings.required() + + assert is_list(required) + assert length(required) > 0 + + for {name, validator, required_flag} <- required do + assert is_binary(name) + assert is_function(validator, 1) + assert is_boolean(required_flag) + end + end + + test "db_host is required" do + required = DbSettings.required() + assert {"db_host", _, true} = List.keyfind!(required, "db_host", 0) + end + + test "region is not required" do + required = DbSettings.required() + assert {"region", _, false} = List.keyfind!(required, "region", 0) + end + + test "validators accept binary values" do + required = DbSettings.required() + + for {_name, validator, _required} <- required do + assert validator.("some_value") == true + end + end + end +end diff --git a/test/extensions/postgres_cdc_rls/message_dispatcher_test.exs b/test/extensions/postgres_cdc_rls/message_dispatcher_test.exs new file mode 100644 index 000000000..3761f41d5 --- /dev/null +++ b/test/extensions/postgres_cdc_rls/message_dispatcher_test.exs @@ -0,0 +1,110 @@ +defmodule Extensions.PostgresCdcRls.MessageDispatcherTest do + use ExUnit.Case, async: true + + alias Extensions.PostgresCdcRls.MessageDispatcher + alias Phoenix.Socket.Broadcast + + defmodule FakeSerializer do + def fastlane!(msg), do: {:encoded, msg} + end + + describe "dispatch/3" do + test "dispatches to fastlane subscribers with matching sub_ids using new api" do + parent = self() + + fastlane_pid = + spawn(fn -> + receive do + msg -> send(parent, {:received, msg}) + end + end) + + sub_ids = MapSet.new(["sub_1"]) + ids = [{"sub_1", 1}] + + subscriptions = [ + {self(), {:subscriber_fastlane, fastlane_pid, FakeSerializer, ids, "realtime:topic", true}} + ] + + payload = Jason.encode!(%{data: "test"}) + + assert :ok = MessageDispatcher.dispatch(subscriptions, self(), {"INSERT", payload, sub_ids}) + + assert_receive {:received, {:encoded, %Broadcast{topic: "realtime:topic", event: "postgres_changes"}}} + end + + test "dispatches to fastlane subscribers with matching sub_ids using old api" do + parent = self() + + fastlane_pid = + spawn(fn -> + receive do + msg -> send(parent, {:received, msg}) + end + end) + + sub_ids = MapSet.new(["sub_1"]) + ids = [{"sub_1", 1}] + + subscriptions = [ + {self(), {:subscriber_fastlane, fastlane_pid, FakeSerializer, ids, "realtime:topic", false}} + ] + + payload = Jason.encode!(%{data: "test"}) + + assert :ok = MessageDispatcher.dispatch(subscriptions, self(), {"INSERT", payload, sub_ids}) + + assert_receive {:received, {:encoded, %Broadcast{topic: "realtime:topic", event: "INSERT"}}} + end + + test "does not dispatch when sub_ids do not match" do + parent = self() + + fastlane_pid = + spawn(fn -> + receive do + msg -> send(parent, {:received, msg}) + after + 1000 -> :ok + end + end) + + sub_ids = MapSet.new(["sub_2"]) + ids = [{"sub_1", 1}] + + subscriptions = [ + {self(), {:subscriber_fastlane, fastlane_pid, FakeSerializer, ids, "realtime:topic", true}} + ] + + assert :ok = MessageDispatcher.dispatch(subscriptions, self(), {"INSERT", "payload", sub_ids}) + + refute_receive {:received, _} + end + + test "caches encoded messages across multiple subscribers" do + parent = self() + + pids = + for _ <- 1..2 do + spawn(fn -> + receive do + msg -> send(parent, {:received, msg}) + end + end) + end + + sub_ids = MapSet.new(["sub_1"]) + ids = [{"sub_1", 1}] + + subscriptions = + Enum.map(pids, fn pid -> + {self(), {:subscriber_fastlane, pid, FakeSerializer, ids, "realtime:topic", true}} + end) + + assert :ok = MessageDispatcher.dispatch(subscriptions, self(), {"INSERT", "payload", sub_ids}) + + assert_receive {:received, {:encoded, %Broadcast{}}} + assert_receive {:received, {:encoded, %Broadcast{}}} + end + end +end diff --git a/test/extensions/postgres_cdc_rls/replications_test.exs b/test/extensions/postgres_cdc_rls/replications_test.exs new file mode 100644 index 000000000..b61f8e73c --- /dev/null +++ b/test/extensions/postgres_cdc_rls/replications_test.exs @@ -0,0 +1,202 @@ +defmodule Extensions.PostgresCdcRls.ReplicationsTest do + use Realtime.DataCase, async: false + + alias Extensions.PostgresCdcRls.Replications + alias Extensions.PostgresCdcRls.Subscriptions + alias Realtime.Database + + setup do + tenant = Containers.checkout_tenant(run_migrations: true) + {:ok, conn} = Database.connect(tenant, "realtime_rls", :stop) + Integrations.setup_postgres_changes(conn) + %{conn: conn, tenant: tenant} + end + + defp drop_slot_on_exit(tenant, slot_name) do + on_exit(fn -> + {:ok, conn} = Database.connect(tenant, "realtime_rls", :stop) + Postgrex.query(conn, "select pg_drop_replication_slot($1)", [slot_name]) + GenServer.stop(conn) + end) + end + + describe "prepare_replication/2" do + test "creates a replication slot", %{conn: conn, tenant: tenant} do + slot_name = "test_slot_#{System.unique_integer([:positive])}" + drop_slot_on_exit(tenant, slot_name) + + assert {:ok, %Postgrex.Result{}} = Replications.prepare_replication(conn, slot_name) + + assert {:ok, %Postgrex.Result{num_rows: 1}} = + Postgrex.query(conn, "select 1 from pg_replication_slots where slot_name = $1", [slot_name]) + end + + test "is idempotent when slot already exists", %{conn: conn, tenant: tenant} do + slot_name = "test_slot_#{System.unique_integer([:positive])}" + drop_slot_on_exit(tenant, slot_name) + + assert {:ok, _} = Replications.prepare_replication(conn, slot_name) + assert {:ok, _} = Replications.prepare_replication(conn, slot_name) + end + end + + describe "terminate_backend/2" do + test "returns slot_not_found when slot does not exist", %{conn: conn} do + assert {:error, :slot_not_found} = Replications.terminate_backend(conn, "nonexistent_slot") + end + + test "returns error when connection is in a failed transaction", %{tenant: tenant} do + {:ok, bad_conn} = Realtime.Database.connect(tenant, "realtime_rls", :stop) + + Postgrex.transaction(bad_conn, fn trans_conn -> + # Put the transaction in failed state + Postgrex.query(trans_conn, "SELECT 1/0", []) + # Subsequent queries return {:error, %Postgrex.Error{}} due to failed transaction + assert {:error, %Postgrex.Error{}} = Replications.terminate_backend(trans_conn, "any_slot") + # Return error to trigger rollback + {:error, :rollback} + end) + + GenServer.stop(bad_conn) + end + + test "returns slot_not_found when slot exists but has no active backend", %{conn: conn, tenant: tenant} do + slot_name = "test_slot_#{System.unique_integer([:positive])}" + drop_slot_on_exit(tenant, slot_name) + + # Use a permanent (non-temporary) slot via a separate connection to avoid + # connection state issues that temporary slots cause on the same connection + {:ok, slot_conn} = Realtime.Database.connect(tenant, "realtime_rls", :stop) + Postgrex.query!(slot_conn, "select pg_create_logical_replication_slot($1, 'pgoutput')", [slot_name]) + GenServer.stop(slot_conn) + + assert {:error, :slot_not_found} = Replications.terminate_backend(conn, slot_name) + end + end + + describe "get_pg_stat_activity_diff/2" do + test "returns error when pid is not in pg_stat_activity", %{conn: conn} do + assert {:error, :pid_not_found} = Replications.get_pg_stat_activity_diff(conn, 0) + end + + test "returns diff when pid is found in pg_stat_activity", %{conn: conn} do + {:ok, %Postgrex.Result{rows: [[backend_pid]]}} = Postgrex.query(conn, "SELECT pg_backend_pid()", []) + + result = Replications.get_pg_stat_activity_diff(conn, backend_pid) + + assert {:ok, diff} = result + assert is_integer(diff) + end + end + + describe "list_changes/5" do + @publication "supabase_realtime_test" + + test "slot empty: returns only the sentinel row with slot_changes_count of 0", %{conn: conn, tenant: tenant} do + slot_name = "test_slot_#{System.unique_integer([:positive])}" + drop_slot_on_exit(tenant, slot_name) + + {:ok, _} = Replications.prepare_replication(conn, slot_name) + + assert {:ok, %Postgrex.Result{rows: rows}} = + Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) + + assert [sentinel] = rows + [nil, nil, nil, "[]", "{}", "{}", nil, nil, nil, slot_changes_count] = sentinel + assert slot_changes_count == 0 + end + + test "slot has changes visible to subscriber: returns real row and slot_changes_count of 1", %{ + conn: conn, + tenant: tenant + } do + slot_name = "test_slot_#{System.unique_integer([:positive])}" + drop_slot_on_exit(tenant, slot_name) + + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{"event" => "*", "schema" => "public", "table" => "test"}) + + Subscriptions.create( + conn, + @publication, + [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}], + self(), + self() + ) + + {:ok, _} = Replications.prepare_replication(conn, slot_name) + + Postgrex.query!(conn, "INSERT INTO public.test (details) VALUES ('hello')", []) + + assert {:ok, %Postgrex.Result{rows: rows}} = + Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) + + assert [row] = rows + + assert [ + "INSERT", + "public", + "test", + _columns, + _record, + _old_record, + _commit_timestamp, + _sub_ids, + _errors, + slot_changes_count + ] = row + + assert slot_changes_count == 1 + end + + test "slot has changes but subscriber does not match the INSERT: returns only the sentinel row with slot_changes_count of 1", + %{ + conn: conn, + tenant: tenant + } do + slot_name = "test_slot_#{System.unique_integer([:positive])}" + drop_slot_on_exit(tenant, slot_name) + + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{"event" => "UPDATE", "schema" => "public", "table" => "test"}) + + Subscriptions.create( + conn, + @publication, + [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}], + self(), + self() + ) + + {:ok, _} = Replications.prepare_replication(conn, slot_name) + + Postgrex.query!(conn, "INSERT INTO public.test (details) VALUES ('hello')", []) + + assert {:ok, %Postgrex.Result{rows: rows}} = + Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) + + assert [sentinel] = rows + [nil, nil, nil, "[]", "{}", "{}", nil, nil, nil, slot_changes_count] = sentinel + assert slot_changes_count == 1 + end + + test "slot has changes but no subscribers: returns only the sentinel row with slot_changes_count of 1", %{ + conn: conn, + tenant: tenant + } do + slot_name = "test_slot_#{System.unique_integer([:positive])}" + drop_slot_on_exit(tenant, slot_name) + + {:ok, _} = Replications.prepare_replication(conn, slot_name) + + Postgrex.query!(conn, "INSERT INTO public.test (details) VALUES ('hello'), ('hithere')", []) + + assert {:ok, %Postgrex.Result{rows: rows}} = + Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) + + assert [sentinel] = rows + [nil, nil, nil, "[]", "{}", "{}", nil, nil, nil, slot_changes_count] = sentinel + assert slot_changes_count == 2 + end + end +end diff --git a/test/extensions/postgres_cdc_rls/worker_supervisor_test.exs b/test/extensions/postgres_cdc_rls/worker_supervisor_test.exs new file mode 100644 index 000000000..d8fc33cf9 --- /dev/null +++ b/test/extensions/postgres_cdc_rls/worker_supervisor_test.exs @@ -0,0 +1,158 @@ +defmodule Extensions.PostgresCdcRls.WorkerSupervisorTest do + use Realtime.DataCase, async: false + + alias Extensions.PostgresCdcRls.WorkerSupervisor + alias Extensions.PostgresCdcRls.ReplicationPoller + alias Extensions.PostgresCdcRls.SubscriptionManager + + setup do + tenant = Containers.checkout_tenant(run_migrations: true) + extension = hd(tenant.extensions).settings + + args = + extension + |> Map.put("id", tenant.external_id) + |> Map.put("region", extension["region"]) + + %{args: args, tenant: tenant} + end + + describe "start_link/1" do + test "starts the supervisor with all children", %{args: args} do + pid = start_link_supervised!({WorkerSupervisor, args}) + + assert Process.alive?(pid) + + children = Supervisor.which_children(pid) + child_ids = Enum.map(children, fn {id, _pid, _type, _modules} -> id end) + + assert ReplicationPoller in child_ids + assert SubscriptionManager in child_ids + end + + test "creates ETS tables for subscribers", %{args: args} do + pid = start_link_supervised!({WorkerSupervisor, args}) + + children = Supervisor.which_children(pid) + + replication_poller_pid = + Enum.find_value(children, fn + {ReplicationPoller, pid, _, _} when is_pid(pid) -> pid + _ -> nil + end) + + assert replication_poller_pid != nil + assert Process.alive?(replication_poller_pid) + end + + test "raises exception when tenant is not in cache" do + args = %{ + "id" => "nonexistent_tenant_#{System.unique_integer()}", + "region" => "us-east-1", + "db_host" => "localhost", + "db_name" => "realtime", + "db_user" => "user", + "db_password" => "pass", + "db_port" => "5432" + } + + {pid, ref} = spawn_monitor(fn -> WorkerSupervisor.start_link(args) end) + assert_receive {:DOWN, ^ref, :process, ^pid, {%Realtime.PostgresCdc.Exception{}, _}} + end + end + + describe "supervisor registration" do + test "registers in syn under the tenant scope", %{args: args, tenant: tenant} do + start_link_supervised!({WorkerSupervisor, args}) + + scope = Realtime.Syn.PostgresCdc.scope(tenant.external_id) + assert {pid, _meta} = :syn.lookup(scope, tenant.external_id) + assert is_pid(pid) + end + end + + describe "restart behaviour" do + test "abnormal exit of ReplicationPoller restarts only itself", %{args: args} do + sup = start_link_supervised!({WorkerSupervisor, args}) + + poller = child_pid(sup, ReplicationPoller) + manager = child_pid(sup, SubscriptionManager) + + Process.exit(poller, :kill) + + # rest_for_one restarts the poller and everything after it (the manager) + new_poller = wait_for_restart(sup, ReplicationPoller, poller) + + assert new_poller != poller + assert child_pid(sup, SubscriptionManager) == manager + assert Process.alive?(sup) + end + + test "abnormal exit of SubscriptionManager restarts only itself", %{args: args} do + sup = start_link_supervised!({WorkerSupervisor, args}) + + poller = child_pid(sup, ReplicationPoller) + manager = child_pid(sup, SubscriptionManager) + + Process.exit(manager, :kill) + + # rest_for_one: the manager is last, so the poller is left untouched + new_manager = wait_for_restart(sup, SubscriptionManager, manager) + + assert new_manager != manager + assert child_pid(sup, ReplicationPoller) == poller + assert Process.alive?(sup) + end + end + + describe "shutdown behaviour" do + test "{:shutdown, _} from ReplicationPoller stops the supervisor", %{args: args} do + # start_supervised! (not the linking variant) so the supervisor's :shutdown + # exit does not propagate to the test process. + sup = start_supervised!({WorkerSupervisor, args}) + ref = Process.monitor(sup) + + poller = child_pid(sup, ReplicationPoller) + Process.exit(poller, {:shutdown, :max_retries_reached}) + + assert_receive {:DOWN, ^ref, :process, ^sup, :shutdown}, 2000 + end + + test "{:shutdown, _} from SubscriptionManager stops the supervisor", %{args: args} do + sup = start_supervised!({WorkerSupervisor, args}) + ref = Process.monitor(sup) + + manager = child_pid(sup, SubscriptionManager) + Process.exit(manager, {:shutdown, :test}) + + assert_receive {:DOWN, ^ref, :process, ^sup, :shutdown}, 2000 + end + end + + defp child_pid(sup, id) do + Enum.find_value(Supervisor.which_children(sup), fn + {^id, pid, _type, _modules} when is_pid(pid) -> pid + _ -> nil + end) + end + + defp wait_for_restart(sup, id, old_pid, timeout \\ 2000) do + deadline = System.monotonic_time(:millisecond) + timeout + do_wait_for_restart(sup, id, old_pid, deadline) + end + + defp do_wait_for_restart(sup, id, old_pid, deadline) do + case child_pid(sup, id) do + pid when is_pid(pid) and pid != old_pid -> + pid + + _ -> + if System.monotonic_time(:millisecond) >= deadline do + flunk("child #{inspect(id)} was not restarted within the timeout") + else + Process.sleep(20) + do_wait_for_restart(sup, id, old_pid, deadline) + end + end + end +end diff --git a/test/integration/distributed_realtime_channel_test.exs b/test/integration/distributed_realtime_channel_test.exs new file mode 100644 index 000000000..faed00a27 --- /dev/null +++ b/test/integration/distributed_realtime_channel_test.exs @@ -0,0 +1,48 @@ +defmodule Realtime.Integration.DistributedRealtimeChannelTest do + # Use of Clustered + use RealtimeWeb.ConnCase, + async: false, + parameterize: [%{serializer: Phoenix.Socket.V1.JSONSerializer}, %{serializer: RealtimeWeb.Socket.V2Serializer}] + + alias Phoenix.Socket.Message + + alias Realtime.Tenants.Connect + alias Realtime.Integration.WebsocketClient + + setup do + tenant = Containers.checkout_tenant_unboxed(run_migrations: true) + + {:ok, node} = Clustered.start() + region = Realtime.Tenants.region(tenant) + {:ok, db_conn} = :erpc.call(node, Connect, :connect, [tenant.external_id, region]) + assert Connect.ready?(tenant.external_id) + + assert node(db_conn) == node + %{tenant: tenant, topic: random_string()} + end + + describe "distributed broadcast" do + @tag mode: :distributed + test "it works", %{tenant: tenant, topic: topic, serializer: serializer} do + {:ok, token} = + generate_token(tenant, %{exp: System.system_time(:second) + 1000, role: "authenticated", sub: random_string()}) + + {:ok, remote_socket} = + WebsocketClient.connect(self(), uri(tenant, serializer, 4012), serializer, [{"x-api-key", token}]) + + {:ok, socket} = WebsocketClient.connect(self(), uri(tenant, serializer), serializer, [{"x-api-key", token}]) + + config = %{broadcast: %{self: false}, private: false} + topic = "realtime:#{topic}" + + :ok = WebsocketClient.join(remote_socket, topic, %{config: config}) + :ok = WebsocketClient.join(socket, topic, %{config: config}) + + # Send through one socket and receive through the other (self: false) + payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} + :ok = WebsocketClient.send_event(remote_socket, topic, "broadcast", payload) + + assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 2000 + end + end +end diff --git a/test/integration/measure_traffic_test.exs b/test/integration/measure_traffic_test.exs new file mode 100644 index 000000000..e225da89e --- /dev/null +++ b/test/integration/measure_traffic_test.exs @@ -0,0 +1,232 @@ +defmodule Realtime.Integration.MeasureTrafficTest do + use RealtimeWeb.ConnCase, async: false + + alias Phoenix.Socket.Message + alias Realtime.Integration.WebsocketClient + alias Realtime.Tenants.ReplicationConnection + + setup [:checkout_tenant_and_connect] + + def handle_telemetry(event, measurements, metadata, name) do + tenant = metadata[:tenant] + [key] = Enum.take(event, -1) + value = Map.get(measurements, :sum) || Map.get(measurements, :value) || Map.get(measurements, :size) || 0 + + Agent.update(name, fn state -> + state = + Map.put_new( + state, + tenant, + %{ + joins: 0, + events: 0, + db_events: 0, + presence_events: 0, + output_bytes: 0, + input_bytes: 0 + } + ) + + update_in(state, [metadata[:tenant], key], fn v -> (v || 0) + value end) + end) + end + + defp get_count(event, tenant) do + [key] = Enum.take(event, -1) + + :"TestCounter_#{tenant}" + |> Agent.get(fn state -> get_in(state, [tenant, key]) || 0 end) + end + + describe "measure traffic" do + setup %{tenant: tenant} do + events = [ + [:realtime, :channel, :output_bytes], + [:realtime, :channel, :input_bytes] + ] + + name = :"TestCounter_#{tenant.external_id}" + + {:ok, _} = + start_supervised(%{ + id: 1, + start: {Agent, :start_link, [fn -> %{} end, [name: name]]} + }) + + RateCounterHelper.stop(tenant.external_id) + on_exit(fn -> :telemetry.detach({__MODULE__, tenant.external_id}) end) + :telemetry.attach_many({__MODULE__, tenant.external_id}, events, &__MODULE__.handle_telemetry/4, name) + + measure_traffic_interval_in_ms = Application.get_env(:realtime, :measure_traffic_interval_in_ms) + Application.put_env(:realtime, :measure_traffic_interval_in_ms, 10) + :persistent_term.put({RealtimeWeb.UserSocket, :measure_traffic_interval_in_ms}, 10) + + on_exit(fn -> + Application.put_env(:realtime, :measure_traffic_interval_in_ms, measure_traffic_interval_in_ms) + :persistent_term.put({RealtimeWeb.UserSocket, :measure_traffic_interval_in_ms}, measure_traffic_interval_in_ms) + end) + + :ok + end + + test "measure traffic for broadcast events", %{tenant: tenant} do + {socket, _} = get_connection(tenant) + config = %{broadcast: %{self: true}} + topic = "realtime:any" + + WebsocketClient.join(socket, topic, %{config: config}) + + # Wait for join to complete + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 1000 + + for _ <- 1..5 do + WebsocketClient.send_event(socket, topic, "broadcast", %{ + "event" => "TEST", + "payload" => %{"msg" => 1}, + "type" => "broadcast" + }) + + assert_receive %Message{ + event: "broadcast", + payload: %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}, + topic: ^topic + }, + 500 + end + + # Wait for RateCounter to run + RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id) + Process.sleep(100) + + output_bytes = get_count([:realtime, :channel, :output_bytes], tenant.external_id) + input_bytes = get_count([:realtime, :channel, :input_bytes], tenant.external_id) + + assert output_bytes > 0 + assert input_bytes > 0 + end + + test "measure traffic for presence events", %{tenant: tenant} do + {socket, _} = get_connection(tenant) + config = %{broadcast: %{self: true}, presence: %{enabled: true}} + topic = "realtime:any" + + WebsocketClient.join(socket, topic, %{config: config}) + + # Wait for join to complete + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 1000 + + for _ <- 1..5 do + WebsocketClient.send_event(socket, topic, "presence", %{ + "event" => "TRACK", + "payload" => %{name: "realtime_presence_#{:rand.uniform(1000)}", t: 1814.7000000029802}, + "type" => "presence" + }) + end + + # Wait for RateCounter to run + RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id) + Process.sleep(100) + + output_bytes = get_count([:realtime, :channel, :output_bytes], tenant.external_id) + input_bytes = get_count([:realtime, :channel, :input_bytes], tenant.external_id) + + assert output_bytes > 0, "Expected output_bytes to be greater than 0, got #{output_bytes}" + assert input_bytes > 0, "Expected input_bytes to be greater than 0, got #{input_bytes}" + end + + test "measure traffic for postgres changes events", %{tenant: tenant, db_conn: db_conn} do + Integrations.setup_postgres_changes(db_conn) + {socket, _} = get_connection(tenant) + config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "public"}]} + topic = "realtime:any" + + WebsocketClient.join(socket, topic, %{config: config}) + + # Wait for join to complete + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 1000 + + # Wait for postgres_changes subscription to be ready + assert_receive %Message{ + event: "system", + payload: %{ + "channel" => "any", + "extension" => "postgres_changes", + "status" => "ok" + }, + topic: ^topic + }, + 8000 + + for _ <- 1..5 do + Postgrex.query!(db_conn, "INSERT INTO test (details) VALUES ($1)", [random_string()]) + end + + for _ <- 1..5 do + assert_receive %Message{ + event: "postgres_changes", + payload: %{"data" => %{"schema" => "public", "table" => "test", "type" => "INSERT"}}, + topic: ^topic + }, + 500 + end + + # Wait for RateCounter to run + RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id) + Process.sleep(100) + + output_bytes = get_count([:realtime, :channel, :output_bytes], tenant.external_id) + input_bytes = get_count([:realtime, :channel, :input_bytes], tenant.external_id) + + assert output_bytes > 0, "Expected output_bytes to be greater than 0, got #{output_bytes}" + assert input_bytes > 0, "Expected input_bytes to be greater than 0, got #{input_bytes}" + end + + test "measure traffic for db events", %{tenant: tenant, db_conn: db_conn} do + {socket, _} = get_connection(tenant) + config = %{broadcast: %{self: true}, db: %{enabled: true}} + topic = "realtime:any" + channel_name = "any" + + WebsocketClient.join(socket, topic, %{config: config}) + + # Wait for join to complete + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 1000 + + assert ReplicationConnection.ready?(tenant.external_id) + + for _ <- 1..5 do + event = random_string() + value = random_string() + + Postgrex.query!( + db_conn, + "SELECT realtime.send (json_build_object ('value', $1 :: text)::jsonb, $2 :: text, $3 :: text, FALSE::bool);", + [value, event, channel_name] + ) + + assert_receive %Message{ + event: "broadcast", + payload: %{ + "event" => ^event, + "payload" => %{"value" => ^value}, + "type" => "broadcast" + }, + topic: ^topic, + join_ref: nil, + ref: nil + }, + 2000 + end + + # Wait for RateCounter to run + RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id) + Process.sleep(100) + + output_bytes = get_count([:realtime, :channel, :output_bytes], tenant.external_id) + input_bytes = get_count([:realtime, :channel, :input_bytes], tenant.external_id) + + assert output_bytes > 0, "Expected output_bytes to be greater than 0, got #{output_bytes}" + assert input_bytes > 0, "Expected input_bytes to be greater than 0, got #{input_bytes}" + end + end +end diff --git a/test/integration/region_aware_migrations_test.exs b/test/integration/region_aware_migrations_test.exs new file mode 100644 index 000000000..8f18629ba --- /dev/null +++ b/test/integration/region_aware_migrations_test.exs @@ -0,0 +1,75 @@ +defmodule Realtime.Integration.RegionAwareMigrationsTest do + use Realtime.DataCase, async: false + use Mimic + + alias Containers + alias Realtime.Tenants + alias Realtime.Tenants.Migrations + + setup do + {:ok, port} = Containers.checkout() + + settings = [ + %{ + "type" => "postgres_cdc_rls", + "settings" => %{ + "db_host" => "127.0.0.1", + "db_name" => "postgres", + "db_user" => "supabase_realtime_admin", + "db_password" => "postgres", + "db_port" => "#{port}", + "poll_interval" => 100, + "poll_max_changes" => 100, + "poll_max_record_bytes" => 1_048_576, + "region" => "ap-southeast-2", + "publication" => "supabase_realtime_test", + "ssl_enforced" => false + } + } + ] + + tenant = tenant_fixture(%{extensions: settings}) + region = Application.get_env(:realtime, :region) + + {:ok, node} = + Clustered.start(nil, + extra_config: [ + {:realtime, :region, Tenants.region(tenant)}, + {:realtime, :master_region, region} + ] + ) + + Process.sleep(100) + + %{tenant: tenant, node: node} + end + + test "run_migrations routes to node in tenant's region with expected arguments", %{tenant: tenant, node: node} do + assert tenant.migrations_ran == 0 + + Realtime.GenRpc + |> Mimic.expect(:call, fn + called_node, Realtime.Nodes, func, args, opts -> + call_original(Realtime.GenRpc, :call, [called_node, Realtime.Nodes, func, args, opts]) + + called_node, Migrations, func, args, opts -> + assert called_node == node + assert func == :start_migration + assert opts[:tenant_id] == tenant.external_id + + arg = hd(args) + assert arg.tenant_external_id == tenant.external_id + assert arg.migrations_ran == tenant.migrations_ran + assert arg.settings == hd(tenant.extensions).settings + + assert opts[:timeout] == 50_000 + + call_original(Realtime.GenRpc, :call, [node, Migrations, func, args, opts]) + end) + + assert :ok = Migrations.run_migrations(tenant) + Process.sleep(1000) + tenant = Realtime.Repo.reload!(tenant) + refute tenant.migrations_ran == 0 + end +end diff --git a/test/integration/region_aware_routing_test.exs b/test/integration/region_aware_routing_test.exs new file mode 100644 index 000000000..ed1de1fb1 --- /dev/null +++ b/test/integration/region_aware_routing_test.exs @@ -0,0 +1,289 @@ +defmodule Realtime.Integration.RegionAwareRoutingTest do + use Realtime.DataCase, async: false + use Mimic + + import Ecto.Query + + alias Realtime.Api + alias Realtime.Api.FeatureFlag + alias Realtime.Api.Tenant + alias Realtime.GenRpc + alias Realtime.Nodes + + setup do + original_master_region = Application.get_env(:realtime, :master_region) + + on_exit(fn -> + Application.put_env(:realtime, :master_region, original_master_region) + end) + + Application.put_env(:realtime, :master_region, "eu-west-2") + + {:ok, master_node} = + Clustered.start(nil, + extra_config: [ + {:realtime, :region, "eu-west-2"}, + {:realtime, :master_region, "eu-west-2"} + ] + ) + + Process.sleep(100) + + %{master_node: master_node} + end + + test "create_tenant automatically routes to master region", %{master_node: master_node} do + external_id = "test_routing_#{System.unique_integer([:positive])}" + + attrs = %{ + "external_id" => external_id, + "name" => external_id, + "jwt_secret" => "secret", + "public_key" => "public", + "extensions" => [], + "postgres_cdc_default" => "postgres_cdc_rls", + "max_concurrent_users" => 200, + "max_events_per_second" => 100 + } + + Mimic.expect(Realtime.GenRpc, :call, fn node, mod, func, args, opts -> + assert node == master_node + assert mod == Realtime.Api + assert func == :create_tenant + assert opts[:tenant_id] == external_id + + call_original(GenRpc, :call, [node, mod, func, args, opts]) + end) + + result = Api.create_tenant(attrs) + + assert {:ok, %Tenant{} = tenant} = result + assert tenant.external_id == external_id + + assert Realtime.Repo.get_by(Tenant, external_id: external_id) + end + + test "update_tenant automatically routes to master region", %{master_node: master_node} do + # Create tenant on master node first + tenant_attrs = %{ + "external_id" => "test_update_#{System.unique_integer([:positive])}", + "name" => "original", + "jwt_secret" => "secret", + "public_key" => "public", + "extensions" => [], + "postgres_cdc_default" => "postgres_cdc_rls", + "max_concurrent_users" => 200, + "max_events_per_second" => 100 + } + + Realtime.GenRpc + |> Mimic.expect(:call, fn node, mod, func, args, opts -> + assert node == master_node + assert mod == Realtime.Api + assert func == :create_tenant + assert opts[:tenant_id] == tenant_attrs["external_id"] + + call_original(GenRpc, :call, [node, mod, func, args, opts]) + end) + |> Mimic.expect(:call, fn node, mod, func, args, opts -> + assert node == master_node + assert mod == Realtime.Api + assert func == :update_tenant_by_external_id + assert opts[:tenant_id] == tenant_attrs["external_id"] + + call_original(GenRpc, :call, [node, mod, func, args, opts]) + end) + + tenant = tenant_fixture(tenant_attrs) + + new_name = "updated_via_routing" + result = Api.update_tenant_by_external_id(tenant.external_id, %{name: new_name}) + + assert {:ok, %Tenant{} = updated} = result + assert updated.name == new_name + + reloaded = Realtime.Repo.get(Tenant, tenant.id) + assert reloaded.name == new_name + end + + test "delete_tenant_by_external_id automatically routes to master region", %{master_node: master_node} do + # Create tenant on master node first + tenant_attrs = %{ + "external_id" => "test_delete_#{System.unique_integer([:positive])}", + "name" => "to_delete", + "jwt_secret" => "secret", + "public_key" => "public", + "extensions" => [], + "postgres_cdc_default" => "postgres_cdc_rls", + "max_concurrent_users" => 200, + "max_events_per_second" => 100 + } + + Realtime.GenRpc + |> Mimic.expect(:call, fn node, mod, func, args, opts -> + assert node == master_node + assert mod == Realtime.Api + assert func == :create_tenant + assert opts[:tenant_id] == tenant_attrs["external_id"] + + call_original(GenRpc, :call, [node, mod, func, args, opts]) + end) + |> Mimic.expect(:call, fn node, mod, func, args, opts -> + assert node == master_node + assert mod == Realtime.Api + assert func == :delete_tenant_by_external_id + assert opts[:tenant_id] == tenant_attrs["external_id"] + + call_original(GenRpc, :call, [node, mod, func, args, opts]) + end) + + tenant = tenant_fixture(tenant_attrs) + + result = Api.delete_tenant_by_external_id(tenant.external_id) + + assert result == true + + refute Realtime.Repo.get(Tenant, tenant.id) + end + + test "update_migrations_ran automatically routes to master region", %{master_node: master_node} do + # Create tenant on master node first + tenant_attrs = %{ + "external_id" => "test_migrations_#{System.unique_integer([:positive])}", + "name" => "migrations_test", + "jwt_secret" => "secret", + "public_key" => "public", + "extensions" => [], + "postgres_cdc_default" => "postgres_cdc_rls", + "max_concurrent_users" => 200, + "max_events_per_second" => 100, + "migrations_ran" => 0 + } + + Realtime.GenRpc + |> Mimic.expect(:call, fn node, mod, func, args, opts -> + assert node == master_node + assert mod == Realtime.Api + assert func == :create_tenant + assert opts[:tenant_id] == tenant_attrs["external_id"] + + call_original(GenRpc, :call, [node, mod, func, args, opts]) + end) + |> Mimic.expect(:call, fn node, mod, func, args, opts -> + assert node == master_node + assert mod == Realtime.Api + assert func == :update_migrations_ran + assert opts[:tenant_id] == tenant_attrs["external_id"] + + call_original(GenRpc, :call, [node, mod, func, args, opts]) + end) + + tenant = tenant_fixture(tenant_attrs) + + new_migrations_ran = 5 + result = Api.update_migrations_ran(tenant.external_id, new_migrations_ran) + + assert {:ok, updated} = result + assert updated.migrations_ran == new_migrations_ran + + reloaded = Realtime.Repo.get(Tenant, tenant.id) + assert reloaded.migrations_ran == new_migrations_ran + end + + test "returns error when Nodes.node_from_region returns {:error, :not_available}" do + external_id = "test_error_node_unavailable_#{System.unique_integer([:positive])}" + + attrs = %{ + "external_id" => external_id, + "name" => external_id, + "jwt_secret" => "secret", + "public_key" => "public", + "extensions" => [], + "postgres_cdc_default" => "postgres_cdc_rls", + "max_concurrent_users" => 200, + "max_events_per_second" => 100 + } + + Mimic.expect(Nodes, :node_from_region, fn _region, _key -> {:error, :not_available} end) + result = Api.create_tenant(attrs) + assert {:error, :not_available} = result + end + + test "returns error when GenRpc.call returns {:error, :rpc_error, reason}" do + external_id = "test_error_rpc_error_#{System.unique_integer([:positive])}" + rpc_error_reason = :timeout + + attrs = %{ + "external_id" => external_id, + "name" => external_id, + "jwt_secret" => "secret", + "public_key" => "public", + "extensions" => [], + "postgres_cdc_default" => "postgres_cdc_rls", + "max_concurrent_users" => 200, + "max_events_per_second" => 100 + } + + Mimic.expect(GenRpc, :call, fn _node, _mod, _func, _args, _opts -> {:error, :rpc_error, rpc_error_reason} end) + result = Api.create_tenant(attrs) + assert {:error, ^rpc_error_reason} = result + end + + test "upsert_feature_flag automatically routes to master region", %{master_node: master_node} do + flag_name = "test_routing_flag_#{System.unique_integer([:positive])}" + on_exit(fn -> Realtime.Repo.delete_all(from f in FeatureFlag, where: f.name == ^flag_name) end) + + Mimic.expect(GenRpc, :call, fn node, mod, func, args, opts -> + assert node == master_node + assert mod == Realtime.Api + assert func == :upsert_feature_flag + assert opts == [] + + call_original(GenRpc, :call, [node, mod, func, args, opts]) + end) + + assert {:ok, %FeatureFlag{name: ^flag_name, enabled: true}} = + Api.upsert_feature_flag(%{name: flag_name, enabled: true}) + + assert Realtime.Repo.get_by(FeatureFlag, name: flag_name) + end + + test "upsert_feature_flag surfaces error", %{master_node: master_node} do + # validation will fail + flag_name = "" + on_exit(fn -> Realtime.Repo.delete_all(from f in FeatureFlag, where: f.name == ^flag_name) end) + + Mimic.expect(GenRpc, :call, fn node, mod, func, args, opts -> + assert node == master_node + assert mod == Realtime.Api + assert func == :upsert_feature_flag + assert opts == [] + + call_original(GenRpc, :call, [node, mod, func, args, opts]) + end) + + assert {:error, %Ecto.Changeset{errors: [name: {"can't be blank", [validation: :required]}]}} = + Api.upsert_feature_flag(%{name: flag_name, enabled: true}) + end + + test "delete_feature_flag automatically routes to master region", %{master_node: master_node} do + flag_name = "test_routing_delete_#{System.unique_integer([:positive])}" + + GenRpc + |> Mimic.expect(:call, fn node, mod, func, args, opts -> + assert node == master_node + assert func == :upsert_feature_flag + call_original(GenRpc, :call, [node, mod, func, args, opts]) + end) + |> Mimic.expect(:call, fn node, mod, func, args, opts -> + assert node == master_node + assert func == :delete_feature_flag + assert opts == [] + call_original(GenRpc, :call, [node, mod, func, args, opts]) + end) + + {:ok, flag} = Api.upsert_feature_flag(%{name: flag_name, enabled: true}) + assert {:ok, _} = Api.delete_feature_flag(flag) + refute Realtime.Repo.get_by(FeatureFlag, name: flag_name) + end +end diff --git a/test/integration/rt_channel/authorization_test.exs b/test/integration/rt_channel/authorization_test.exs new file mode 100644 index 000000000..42e154be7 --- /dev/null +++ b/test/integration/rt_channel/authorization_test.exs @@ -0,0 +1,163 @@ +defmodule Realtime.Integration.RtChannel.AuthorizationTest do + use RealtimeWeb.ConnCase, + async: true, + parameterize: [ + %{serializer: Phoenix.Socket.V1.JSONSerializer}, + %{serializer: RealtimeWeb.Socket.V2Serializer} + ] + + import ExUnit.CaptureLog + import Generators + + alias Phoenix.Socket.Message + alias Realtime.Integration.WebsocketClient + + @moduletag :capture_log + + setup [:checkout_tenant_and_connect] + + describe "private only channels" do + setup [:rls_context] + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "user with only private channels enabled will not be able to join public channels", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + change_tenant_configuration(tenant, :private_only, true) + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + topic = "realtime:#{topic}" + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{ + "response" => %{ + "reason" => "PrivateOnly: This project only allows private channels" + }, + "status" => "error" + } + }, + 500 + end + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "user with only private channels enabled will be able to join private channels", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + change_tenant_configuration(tenant, :private_only, true) + + Process.sleep(100) + + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: true} + topic = "realtime:#{topic}" + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + end + end + + describe "RLS policy enforcement" do + setup [:rls_context] + + @tag policies: [:read_matching_user_role, :write_matching_user_role], role: "anon" + test "role policies are respected when accessing the channel", %{tenant: tenant, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer, role: "anon") + config = %{broadcast: %{self: true}, private: true, presence: %{enabled: false}} + topic = random_string() + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500 + + {socket, _} = get_connection(tenant, serializer, role: "potato") + topic = random_string() + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config}) + refute_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500 + end + + @tag policies: [:authenticated_read_matching_user_sub, :authenticated_write_matching_user_sub], + sub: Ecto.UUID.generate() + test "sub policies are respected when accessing the channel", %{tenant: tenant, sub: sub, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer, role: "authenticated", claims: %{sub: sub}) + config = %{broadcast: %{self: true}, private: true, presence: %{enabled: false}} + topic = random_string() + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500 + + {socket, _} = get_connection(tenant, serializer, role: "authenticated", claims: %{sub: Ecto.UUID.generate()}) + topic = random_string() + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config}) + refute_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500 + end + + @tag role: "authenticated", policies: [:broken_read_presence, :broken_write_presence] + test "handle failing rls policy", %{tenant: tenant, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: true} + topic = random_string() + realtime_topic = "realtime:#{topic}" + + log = + capture_log(fn -> + WebsocketClient.join(socket, realtime_topic, %{config: config}) + + msg = "Unauthorized: You do not have permissions to read from this Channel topic: #{topic}" + + assert_receive %Message{ + event: "phx_reply", + payload: %{ + "response" => %{ + "reason" => ^msg + }, + "status" => "error" + } + }, + 500 + + refute_receive %Message{event: "phx_reply"} + refute_receive %Message{event: "presence_state"} + end) + + assert log =~ "RlsPolicyError" + end + end + + describe "topic validation" do + test "handle empty topic by closing the socket", %{tenant: tenant, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:" + + WebsocketClient.join(socket, realtime_topic, %{config: config}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{ + "response" => %{ + "reason" => "TopicNameRequired: You must provide a topic name" + }, + "status" => "error" + } + }, + 500 + + refute_receive %Message{event: "phx_reply"} + refute_receive %Message{event: "presence_state"} + end + end +end diff --git a/test/integration/rt_channel/billable_events_test.exs b/test/integration/rt_channel/billable_events_test.exs new file mode 100644 index 000000000..68f387896 --- /dev/null +++ b/test/integration/rt_channel/billable_events_test.exs @@ -0,0 +1,272 @@ +defmodule Realtime.Integration.RtChannel.BillableEventsTest do + use RealtimeWeb.ConnCase, + async: true, + parameterize: [ + %{serializer: Phoenix.Socket.V1.JSONSerializer}, + %{serializer: RealtimeWeb.Socket.V2Serializer} + ] + + import Generators + + alias Phoenix.Socket.Message + alias Postgrex + alias Realtime.Database + alias Realtime.Integration.WebsocketClient + alias Realtime.Tenants + + @moduletag :capture_log + + setup [:checkout_tenant_connect_and_setup_postgres_changes] + + setup %{tenant: tenant} do + events = [ + [:realtime, :rate_counter, :channel, :joins], + [:realtime, :rate_counter, :channel, :events], + [:realtime, :rate_counter, :channel, :db_events], + [:realtime, :rate_counter, :channel, :presence_events] + ] + + name = :"TestCounter_#{tenant.external_id}" + + {:ok, _} = + start_supervised(%{ + id: 1, + start: {Agent, :start_link, [fn -> %{} end, [name: name]]} + }) + + RateCounterHelper.stop(tenant.external_id) + on_exit(fn -> :telemetry.detach({__MODULE__, tenant.external_id}) end) + :telemetry.attach_many({__MODULE__, tenant.external_id}, events, &__MODULE__.handle_telemetry/4, name) + + :ok + end + + def handle_telemetry(event, measurements, metadata, name) do + tenant = metadata[:tenant] + [key] = Enum.take(event, -1) + value = Map.get(measurements, :sum) || Map.get(measurements, :value) || Map.get(measurements, :size) || 0 + + Agent.update(name, fn state -> + state = + Map.put_new( + state, + tenant, + %{ + joins: 0, + events: 0, + db_events: 0, + presence_events: 0, + output_bytes: 0, + input_bytes: 0 + } + ) + + update_in(state, [metadata[:tenant], key], fn v -> (v || 0) + value end) + end) + end + + describe "join events" do + test "join events", %{tenant: tenant, serializer: serializer} do + external_id = tenant.external_id + {socket, _} = get_connection(tenant, serializer) + config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "public"}]} + topic = "realtime:any" + + WebsocketClient.join(socket, topic, %{config: config}) + + # Join events + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + assert_receive %Message{topic: ^topic, event: "system"}, 5000 + + # Wait for RateCounter to run + RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id) + + # Expected billed + # 1 joins due to two sockets + # 0 presence events + # 0 db events as no postgres changes used + # 0 events broadcast is not used + assert 1 = get_count([:realtime, :rate_counter, :channel, :joins], external_id) + assert 0 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id) + assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id) + assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id) + end + end + + describe "broadcast events" do + test "broadcast events", %{tenant: tenant, serializer: serializer} do + external_id = tenant.external_id + {socket1, _} = get_connection(tenant, serializer) + config = %{broadcast: %{self: true}} + topic = "realtime:any" + + WebsocketClient.join(socket1, topic, %{config: config}) + + # Join events + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + # Add second client so we can test the "multiplication" of billable events + {socket2, _} = get_connection(tenant, serializer) + WebsocketClient.join(socket2, topic, %{config: config}) + + # Join events + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + # Broadcast event + payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} + + for _ <- 1..5 do + WebsocketClient.send_event(socket1, topic, "broadcast", payload) + # both sockets + assert_receive %Message{topic: ^topic, event: "broadcast", payload: ^payload} + assert_receive %Message{topic: ^topic, event: "broadcast", payload: ^payload} + end + + refute_receive _any + + # Wait for RateCounter to run + RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id) + + # Expected billed + # 2 joins due to two sockets + # 0 presence events + # 0 db events as no postgres changes used + # 15 events as 5 events sent, 5 events received on client 1 and 5 events received on client 2 + assert 2 = get_count([:realtime, :rate_counter, :channel, :joins], external_id) + assert 0 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id) + assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id) + assert 15 = get_count([:realtime, :rate_counter, :channel, :events], external_id) + end + end + + describe "presence events" do + test "presence events", %{tenant: tenant, serializer: serializer} do + external_id = tenant.external_id + {socket, _} = get_connection(tenant, serializer) + config = %{broadcast: %{self: true}, presence: %{enabled: true}} + topic = "realtime:any" + + WebsocketClient.join(socket, topic, %{config: config}) + + # Join events + assert_receive %Message{event: "phx_reply", topic: ^topic}, 1000 + + payload = %{ + type: "presence", + event: "TRACK", + payload: %{name: "realtime_presence_1", t: 1814.7000000029802} + } + + WebsocketClient.send_event(socket, topic, "presence", payload) + assert_receive %Message{event: "presence_diff", payload: %{"joins" => _, "leaves" => %{}}, topic: ^topic} + + # Presence events + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + + payload = %{ + type: "presence", + event: "TRACK", + payload: %{name: "realtime_presence_2", t: 1814.7000000029802} + } + + WebsocketClient.send_event(socket, topic, "presence", payload) + assert_receive %Message{event: "presence_diff", payload: %{"joins" => _, "leaves" => %{}}, topic: ^topic} + assert_receive %Message{event: "presence_diff", payload: %{"joins" => _, "leaves" => %{}}, topic: ^topic} + + # Wait for RateCounter to run + RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id) + + # Expected billed + # 2 joins due to two sockets + # 7 presence events + # 0 db events as no postgres changes used + # 0 events as no broadcast used + assert 2 = get_count([:realtime, :rate_counter, :channel, :joins], external_id) + assert 7 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id) + assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id) + assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id) + end + end + + describe "postgres changes events" do + test "postgres changes events", %{tenant: tenant, serializer: serializer} do + external_id = tenant.external_id + {socket, _} = get_connection(tenant, serializer) + config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "public"}]} + topic = "realtime:any" + + WebsocketClient.join(socket, topic, %{config: config}) + + # Join events + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + assert_receive %Message{topic: ^topic, event: "system"}, 5000 + + # Add second user to test the "multiplication" of billable events + {socket, _} = get_connection(tenant, serializer) + WebsocketClient.join(socket, topic, %{config: config}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + assert_receive %Message{topic: ^topic, event: "system"}, 5000 + + tenant = Tenants.get_tenant_by_external_id(tenant.external_id) + {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) + + # Postgres Change events + for _ <- 1..5, do: Postgrex.query!(conn, "insert into test (details) values ('test')", []) + + for _ <- 1..10 do + assert_receive %Message{ + topic: ^topic, + event: "postgres_changes", + payload: %{"data" => %{"schema" => "public", "table" => "test", "type" => "INSERT"}} + }, + 5000 + end + + # Wait for RateCounter to run + RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id) + + # Expected billed + # 2 joins due to two sockets + # 0 presence events due to two sockets + # 10 db events due to 5 inserts events sent to client 1 and 5 inserts events sent to client 2 + # 0 events as no broadcast used + assert 2 = get_count([:realtime, :rate_counter, :channel, :joins], external_id) + assert 0 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id) + # (5 for each websocket) + assert 10 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id) + assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id) + end + + test "postgres changes error events", %{tenant: tenant, serializer: serializer} do + external_id = tenant.external_id + {socket, _} = get_connection(tenant, serializer) + config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "none"}]} + topic = "realtime:any" + + WebsocketClient.join(socket, topic, %{config: config}) + + # Join events + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + assert_receive %Message{topic: ^topic, event: "system"}, 5000 + + # Wait for RateCounter to run + RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id) + + # Expected billed + # 1 joins due to one socket + # 0 presence events due to one socket + # 0 db events + # 0 events as no broadcast used + assert 1 = get_count([:realtime, :rate_counter, :channel, :joins], external_id) + assert 0 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id) + assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id) + assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id) + end + end + + defp get_count(event, tenant) do + [key] = Enum.take(event, -1) + Agent.get(:"TestCounter_#{tenant}", fn state -> get_in(state, [tenant, key]) || 0 end) + end +end diff --git a/test/integration/rt_channel/broadcast_test.exs b/test/integration/rt_channel/broadcast_test.exs new file mode 100644 index 000000000..3a4351263 --- /dev/null +++ b/test/integration/rt_channel/broadcast_test.exs @@ -0,0 +1,557 @@ +defmodule Realtime.Integration.RtChannel.BroadcastTest do + use RealtimeWeb.ConnCase, + async: true, + parameterize: [ + %{serializer: Phoenix.Socket.V1.JSONSerializer}, + %{serializer: RealtimeWeb.Socket.V2Serializer} + ] + + import ExUnit.CaptureLog + import Generators + + alias Phoenix.Socket.Message + alias Postgrex + alias Realtime.Database + alias Realtime.Integration.WebsocketClient + alias Realtime.Tenants.Connect + alias Realtime.Tenants.ReplicationConnection + + @moduletag :capture_log + + setup [:checkout_tenant_and_connect] + + describe "public broadcast" do + setup [:rls_context] + + test "public broadcast", %{tenant: tenant, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer) + config = %{broadcast: %{self: true}, private: false} + topic = "realtime:any" + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + + payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} + WebsocketClient.send_event(socket, topic, "broadcast", payload) + + assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 + end + + test "broadcast to another tenant does not get mixed up", %{tenant: tenant, serializer: serializer} do + other_tenant = Containers.checkout_tenant(run_migrations: true) + + Realtime.Tenants.Cache.update_cache(other_tenant) + + {socket, _} = get_connection(tenant, serializer) + config = %{broadcast: %{self: false}, private: false} + topic = "realtime:any" + WebsocketClient.join(socket, topic, %{config: config}) + + {other_socket, _} = get_connection(other_tenant, serializer) + WebsocketClient.join(other_socket, topic, %{config: config}) + + # Both sockets joined + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + + payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} + WebsocketClient.send_event(socket, topic, "broadcast", payload) + + # No message received + refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 + end + + @tag policies: [] + test "lack of connection to database error does not impact public channels", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + topic = "realtime:#{topic}" + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + WebsocketClient.join(socket, topic, %{config: %{broadcast: %{self: true}, private: false}}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + + {service_role_socket, _} = get_connection(tenant, serializer, role: "service_role") + WebsocketClient.join(service_role_socket, topic, %{config: %{broadcast: %{self: false}, private: false}}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + + log = + capture_log(fn -> + :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end) + payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} + WebsocketClient.send_event(service_role_socket, topic, "broadcast", payload) + assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 + end) + + refute log =~ "UnableToHandleBroadcast" + end + end + + describe "private broadcast" do + setup [:rls_context] + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "private broadcast with valid channel with permissions sends message", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: true} + topic = "realtime:#{topic}" + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + + payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} + WebsocketClient.send_event(socket, topic, "broadcast", payload) + + assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic} + end + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence], + serializer: RealtimeWeb.Socket.V2Serializer + test "private broadcast with binary payload and ack returns reply and delivers self-broadcast", %{ + tenant: tenant, + topic: topic, + serializer: RealtimeWeb.Socket.V2Serializer = serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true, ack: true}, private: true} + full_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, full_topic, %{config: config}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^full_topic}, 500 + + binary = <<0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x11, 0x22, 0x33>> + event = "my-binary-event" + + WebsocketClient.send_user_broadcast(socket, full_topic, event, binary, encoding: :binary) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^full_topic}, 500 + + assert_receive %Message{ + event: "broadcast", + topic: ^full_topic, + payload: %{ + "event" => ^event, + "payload" => {:binary, ^binary}, + "type" => "broadcast" + } + }, + 1000 + end + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence], + topic: "topic" + test "private broadcast with valid channel a colon character sends message and won't intercept in public channels", + %{topic: topic, tenant: tenant, serializer: serializer} do + {anon_socket, _} = get_connection(tenant, serializer, role: "anon") + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + valid_topic = "realtime:#{topic}" + malicious_topic = "realtime:private:#{topic}" + + WebsocketClient.join(socket, valid_topic, %{config: %{broadcast: %{self: true}, private: true}}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^valid_topic}, 300 + + WebsocketClient.join(anon_socket, malicious_topic, %{config: %{broadcast: %{self: true}, private: false}}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^malicious_topic}, 300 + + payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} + WebsocketClient.send_event(socket, valid_topic, "broadcast", payload) + + assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^valid_topic}, 500 + refute_receive %Message{event: "broadcast"} + end + + @tag policies: [:authenticated_read_broadcast_and_presence] + test "private broadcast with valid channel no write permissions won't send message but will receive message", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + config = %{broadcast: %{self: true}, private: true} + topic = "realtime:#{topic}" + + {service_role_socket, _} = get_connection(tenant, serializer, role: "service_role") + WebsocketClient.join(service_role_socket, topic, %{config: config}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + WebsocketClient.join(socket, topic, %{config: config}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + + payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} + + WebsocketClient.send_event(socket, topic, "broadcast", payload) + refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 + + WebsocketClient.send_event(service_role_socket, topic, "broadcast", payload) + assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 + assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 + end + + @tag policies: [] + test "private broadcast with valid channel and no read permissions won't join", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + config = %{private: true} + expected = "Unauthorized: You do not have permissions to read from this Channel topic: #{topic}" + + topic = "realtime:#{topic}" + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + + log = + capture_log(fn -> + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{ + topic: ^topic, + event: "phx_reply", + payload: %{ + "response" => %{ + "reason" => ^expected + }, + "status" => "error" + } + }, + 300 + + refute_receive %Message{event: "phx_reply", topic: ^topic}, 300 + end) + + assert log =~ expected + end + + @tag policies: [:authenticated_read_broadcast_and_presence] + test "handles lack of connection to database error on private channels", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + topic = "realtime:#{topic}" + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + WebsocketClient.join(socket, topic, %{config: %{broadcast: %{self: true}, private: true}}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + + {service_role_socket, _} = get_connection(tenant, serializer, role: "service_role") + WebsocketClient.join(service_role_socket, topic, %{config: %{broadcast: %{self: false}, private: true}}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + + log = + capture_log(fn -> + :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end) + payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} + WebsocketClient.send_event(service_role_socket, topic, "broadcast", payload) + # Waiting more than 15 seconds as this is the amount of time we will wait for the Connection to be ready + refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 16000 + end) + + assert log =~ "UnableToHandleBroadcast" + end + end + + describe "trigger-based broadcast changes" do + setup [:rls_context, :setup_trigger] + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "broadcast insert event changes on insert in table with trigger", %{ + tenant: tenant, + topic: topic, + db_conn: db_conn, + table_name: table_name, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: true} + topic = "realtime:#{topic}" + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + assert ReplicationConnection.ready?(tenant.external_id) + + value = random_string() + Postgrex.query!(db_conn, "INSERT INTO #{table_name} (details) VALUES ($1)", [value]) + + record = %{"details" => value, "id" => 1} + + assert_receive %Message{ + event: "broadcast", + payload: %{ + "event" => "INSERT", + "payload" => %{ + "old_record" => nil, + "operation" => "INSERT", + "record" => ^record, + "schema" => "public", + "table" => ^table_name + }, + "type" => "broadcast" + }, + topic: ^topic + }, + 1000 + end + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence], + requires_data: true, + requires_pg_140006: true + test "broadcast update event changes on update in table with trigger", %{ + tenant: tenant, + topic: topic, + db_conn: db_conn, + table_name: table_name, + serializer: serializer + } do + value = random_string() + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: true} + topic = "realtime:#{topic}" + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + new_value = random_string() + + assert ReplicationConnection.ready?(tenant.external_id) + + Postgrex.query!(db_conn, "INSERT INTO #{table_name} (details) VALUES ($1)", [value]) + Postgrex.query!(db_conn, "UPDATE #{table_name} SET details = $1 WHERE details = $2", [new_value, value]) + + old_record = %{"details" => value, "id" => 1} + record = %{"details" => new_value, "id" => 1} + + assert_receive %Message{ + event: "broadcast", + payload: %{ + "event" => "UPDATE", + "payload" => %{ + "old_record" => ^old_record, + "operation" => "UPDATE", + "record" => ^record, + "schema" => "public", + "table" => ^table_name + }, + "type" => "broadcast" + }, + topic: ^topic + }, + 1000 + end + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence], + requires_pg_140006: true + test "broadcast delete event changes on delete in table with trigger", %{ + tenant: tenant, + topic: topic, + db_conn: db_conn, + table_name: table_name, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: true} + topic = "realtime:#{topic}" + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + value = random_string() + + assert ReplicationConnection.ready?(tenant.external_id) + + Postgrex.query!(db_conn, "INSERT INTO #{table_name} (details) VALUES ($1)", [value]) + Postgrex.query!(db_conn, "DELETE FROM #{table_name} WHERE details = $1", [value]) + + record = %{"details" => value, "id" => 1} + + assert_receive %Message{ + event: "broadcast", + payload: %{ + "event" => "DELETE", + "payload" => %{ + "old_record" => ^record, + "operation" => "DELETE", + "record" => nil, + "schema" => "public", + "table" => ^table_name + }, + "type" => "broadcast" + }, + topic: ^topic + }, + 1000 + end + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "broadcast event when function 'send' is called with private topic", %{ + tenant: tenant, + topic: topic, + db_conn: db_conn, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: true} + full_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, full_topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + value = random_string() + event = random_string() + + assert ReplicationConnection.ready?(tenant.external_id) + + Postgrex.query!( + db_conn, + "SELECT realtime.send (jsonb_build_object ('value', $1 :: text), $2 :: text, $3 :: text, TRUE::bool);", + [value, event, topic] + ) + + assert_receive %Message{ + event: "broadcast", + payload: %{ + "event" => ^event, + "payload" => %{"value" => ^value}, + "type" => "broadcast" + }, + topic: ^full_topic, + join_ref: nil, + ref: nil + }, + 1000 + end + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "broadcast event when function 'send_binary' is called", %{ + tenant: tenant, + topic: topic, + db_conn: db_conn, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: true} + full_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, full_topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + binary = <<0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF, 0x01, 0x02>> + event = random_string() + + assert ReplicationConnection.ready?(tenant.external_id) + + Postgrex.query!( + db_conn, + "SELECT realtime.send_binary($1::bytea, $2::text, $3::text, TRUE::bool);", + [binary, event, topic] + ) + + case serializer do + RealtimeWeb.Socket.V2Serializer -> + assert_receive %Message{ + event: "broadcast", + payload: %{ + "event" => ^event, + "payload" => {:binary, ^binary}, + "type" => "broadcast", + "meta" => %{"id" => _} + }, + topic: ^full_topic + }, + 1000 + + Phoenix.Socket.V1.JSONSerializer -> + # V1 cannot represent binary payloads; the broadcast is dropped for this socket. + refute_receive %Message{event: "broadcast"}, 500 + end + end + + test "broadcast event when function 'send' is called with public topic", %{ + tenant: tenant, + topic: topic, + db_conn: db_conn, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + full_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, full_topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + value = random_string() + event = random_string() + + assert ReplicationConnection.ready?(tenant.external_id) + + Postgrex.query!( + db_conn, + "SELECT realtime.send (json_build_object ('value', $1 :: text)::jsonb, $2 :: text, $3 :: text, FALSE::bool);", + [value, event, topic] + ) + + assert_receive %Message{ + event: "broadcast", + payload: %{ + "event" => ^event, + "payload" => %{"value" => ^value}, + "type" => "broadcast" + }, + topic: ^full_topic + }, + 1000 + end + end + + defp setup_trigger(%{tenant: tenant, topic: topic}) do + {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) + random_name = String.downcase("test_#{random_string()}") + query = "CREATE TABLE #{random_name} (id serial primary key, details text)" + Postgrex.query!(db_conn, query, []) + + query = """ + CREATE OR REPLACE FUNCTION broadcast_changes_for_table_#{random_name}_trigger () + RETURNS TRIGGER + AS $$ + DECLARE + topic text; + BEGIN + topic = '#{topic}'; + PERFORM + realtime.broadcast_changes (topic, TG_OP, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA, NEW, OLD, TG_LEVEL); + RETURN NULL; + END; + $$ + LANGUAGE plpgsql; + """ + + Postgrex.query!(db_conn, query, []) + + query = """ + CREATE TRIGGER broadcast_changes_for_#{random_name}_table + AFTER INSERT OR UPDATE OR DELETE ON #{random_name} + FOR EACH ROW + EXECUTE FUNCTION broadcast_changes_for_table_#{random_name}_trigger (); + """ + + Postgrex.query!(db_conn, query, []) + + on_exit(fn -> + {:ok, cleanup_conn} = Database.connect(tenant, "realtime_test", :stop) + Postgrex.query!(cleanup_conn, "DROP TABLE #{random_name} CASCADE", []) + GenServer.stop(cleanup_conn) + end) + + %{table_name: random_name, db_conn: db_conn} + end +end diff --git a/test/integration/rt_channel/connection_lifecycle_test.exs b/test/integration/rt_channel/connection_lifecycle_test.exs new file mode 100644 index 000000000..3f7de0625 --- /dev/null +++ b/test/integration/rt_channel/connection_lifecycle_test.exs @@ -0,0 +1,412 @@ +defmodule Realtime.Integration.RtChannel.ConnectionLifecycleTest do + use RealtimeWeb.ConnCase, + async: true, + parameterize: [ + %{serializer: Phoenix.Socket.V1.JSONSerializer}, + %{serializer: RealtimeWeb.Socket.V2Serializer} + ] + + import ExUnit.CaptureLog + import Generators + + alias Phoenix.Socket.Message + alias Realtime.Integration.WebsocketClient + alias Realtime.Tenants + alias RealtimeWeb.UserSocket + + @moduletag :capture_log + + @service_restart_close_code 1012 + @normal_close_code 1000 + + setup [:checkout_tenant_and_connect] + + describe "socket connect - tenant not found" do + test "logs TenantNotFound and rejects connection for unknown external_id", %{serializer: serializer} do + external_id = "nonexistent-#{System.unique_integer([:positive])}" + fake_tenant = %{external_id: external_id} + # Our code does not store values that are not Tenant structs + # but we do it here to avoid an Ecto.Sandbox issue due to the async tests + # Because Cachex.fetch will try to call the DB when there is no cached information + Cachex.put(Realtime.Tenants.Cache, {:get_tenant_by_external_id, external_id}, {:error, :not_found}) + + log = + capture_log(fn -> + assert {:error, _} = + WebsocketClient.connect(self(), uri(fake_tenant, serializer), serializer, [ + {"x-api-key", "some-token"} + ]) + end) + + assert log =~ "TenantNotFound" + end + end + + describe "socket connect - missing api key" do + test "logs MissingAPIKey and rejects connection when no token provided", %{tenant: tenant, serializer: serializer} do + log = + capture_log(fn -> + assert {:error, _} = WebsocketClient.connect(self(), uri(tenant, serializer), serializer, []) + end) + + assert log =~ "MissingAPIKey" + end + end + + describe "socket disconnect - tenant suspension" do + setup [:rls_context] + + test "tenant already suspended", %{tenant: tenant, serializer: serializer} do + log = + capture_log(fn -> + change_tenant_configuration(tenant, :suspend, true) + {:error, %Mint.WebSocket.UpgradeFailureError{}} = get_connection(tenant, serializer, role: "anon") + refute_receive _any + end) + + assert log =~ "RealtimeDisabledForTenant" + end + end + + describe "socket disconnect - configuration changes" do + setup [:rls_context] + + test "on jwks the socket closes and sends a system message", %{tenant: tenant, topic: topic, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{jwt_jwks: %{keys: ["potato"]}}) + + assert_receive {:close_code, @service_restart_close_code}, 1000 + + assert_process_down(socket) + end + + test "on jwt_secret the socket closes and sends a system message", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{jwt_secret: "potato"}) + + assert_receive {:close_code, @service_restart_close_code}, 1000 + + assert_process_down(socket) + end + + test "on private_only the socket closes and sends a system message", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{private_only: true}) + + assert_receive {:close_code, @service_restart_close_code}, 1000 + + assert_process_down(socket) + end + + test "on other param changes the socket won't close and no message is sent", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{max_concurrent_users: 100}) + + refute_receive %Message{ + topic: ^realtime_topic, + event: "system", + payload: %{ + "extension" => "system", + "message" => "Server requested disconnect", + "status" => "ok" + } + }, + 500 + + assert :ok = WebsocketClient.send_heartbeat(socket) + refute_receive {:close_code, @service_restart_close_code} + end + end + + describe "socket disconnect - token expiry" do + setup [:rls_context] + + test "invalid JWT with expired token", %{tenant: tenant, serializer: serializer} do + log = + capture_log(fn -> + get_connection(tenant, serializer, + role: "authenticated", + claims: %{:exp => System.system_time(:second) - 1000}, + params: %{log_level: :info} + ) + end) + + assert log =~ "InvalidJWTToken: Token has expired" + end + end + + describe "socket disconnect" do + setup [:rls_context] + + test "on disconnect called, socket is killed", %{ + tenant: tenant, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + + topics = + for i <- 1..10 do + topic = "realtime:#{serializer}:#{i}" + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 500 + topic + end + + assert :ok = WebsocketClient.send_heartbeat(socket) + # heartbeat reply + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: "phoenix"}, 500 + + UserSocket.disconnect(tenant.external_id) + + for topic <- topics do + assert_receive %Message{ + topic: ^topic, + event: "system", + payload: %{ + "extension" => "system", + "message" => "Server requested disconnect", + "status" => "ok" + } + }, + 5000 + end + + assert_receive {:close_code, @service_restart_close_code}, 1000 + refute_receive _any + + assert_process_down(socket, 1000) + end + end + + describe "socket disconnect - tenant deleted during session" do + setup [:rls_context] + + test "sends disconnect to socket when tenant not found during channel join", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + Cachex.put(Realtime.Tenants.Cache, {:get_tenant_by_external_id, tenant.external_id}, {:error, :not_found}) + + realtime_topic_2 = "realtime:#{random_string()}" + WebsocketClient.join(socket, realtime_topic_2, %{config: config}) + + assert_receive {:close_code, @normal_close_code}, 1000 + + assert_process_down(socket, 1000) + end + end + + describe "rate limits - concurrent users" do + setup [:rls_context] + + test "max_concurrent_users limit respected", %{tenant: tenant, serializer: serializer} do + Tenants.get_tenant_by_external_id(tenant.external_id) + change_tenant_configuration(tenant, :max_concurrent_users, 1) + + {socket1, _} = get_connection(tenant, serializer, role: "authenticated") + {socket2, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + topic1 = "realtime:#{random_string()}" + topic2 = "realtime:#{random_string()}" + WebsocketClient.join(socket1, topic1, %{config: config}) + WebsocketClient.join(socket1, topic2, %{config: config}) + + assert_receive %Message{ + event: "phx_reply", + topic: ^topic1, + payload: %{"response" => %{"postgres_changes" => []}, "status" => "ok"} + }, + 500 + + assert_receive %Message{ + event: "phx_reply", + topic: ^topic2, + payload: %{"response" => %{"postgres_changes" => []}, "status" => "ok"} + }, + 500 + + topic3 = "realtime:#{random_string()}" + WebsocketClient.join(socket2, topic3, %{config: config}) + + assert_receive %Message{ + event: "phx_reply", + topic: ^topic3, + payload: %{ + "response" => %{ + "reason" => "ConnectionRateLimitReached: Too many connected users" + }, + "status" => "error" + } + }, + 500 + + Realtime.Tenants.Cache.update_cache(%{tenant | max_concurrent_users: 2}) + + WebsocketClient.join(socket2, topic3, %{config: config}) + + assert_receive %Message{ + event: "phx_reply", + topic: ^topic3, + payload: %{"response" => %{"postgres_changes" => []}, "status" => "ok"} + }, + 500 + end + end + + describe "rate limits - events per second" do + setup [:rls_context] + + test "max_events_per_second limit respected", %{tenant: tenant, serializer: serializer} do + RateCounterHelper.stop(tenant.external_id) + + log = + capture_log(fn -> + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true, ack: false}, private: false, presence: %{enabled: false}} + realtime_topic = "realtime:#{random_string()}" + + WebsocketClient.join(socket, realtime_topic, %{config: config}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500 + + for _ <- 1..1000, Process.alive?(socket) do + WebsocketClient.send_event(socket, realtime_topic, "broadcast", %{}) + assert_receive %Message{event: "broadcast", topic: ^realtime_topic}, 500 + end + + RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id) + + WebsocketClient.send_event(socket, realtime_topic, "broadcast", %{}) + + assert_receive %Message{event: "phx_close"}, 1000 + end) + + assert log =~ "MessagePerSecondRateLimitReached" + end + end + + describe "rate limits - channels per client" do + setup [:rls_context] + + test "max_channels_per_client limit respected", %{tenant: tenant, serializer: serializer} do + change_tenant_configuration(tenant, :max_channels_per_client, 1) + + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + realtime_topic_1 = "realtime:#{random_string()}" + realtime_topic_2 = "realtime:#{random_string()}" + + WebsocketClient.join(socket, realtime_topic_1, %{config: config}) + WebsocketClient.join(socket, realtime_topic_2, %{config: config}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{"response" => %{"postgres_changes" => []}, "status" => "ok"}, + topic: ^realtime_topic_1 + }, + 500 + + assert_receive %Message{ + event: "phx_reply", + payload: %{ + "status" => "error", + "response" => %{ + "reason" => "ChannelRateLimitReached: Too many channels" + } + }, + topic: ^realtime_topic_2 + }, + 500 + + refute_receive %Message{event: "phx_reply", topic: ^realtime_topic_2}, 500 + + Realtime.Tenants.Cache.update_cache(%{tenant | max_channels_per_client: 2}) + + WebsocketClient.join(socket, realtime_topic_2, %{config: config}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{"response" => %{"postgres_changes" => []}, "status" => "ok"}, + topic: ^realtime_topic_2 + }, + 500 + end + end + + describe "rate limits - joins per second" do + setup [:rls_context] + + test "max_joins_per_second limit respected", %{tenant: tenant, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{random_string()}" + + log = + capture_log(fn -> + for _ <- 1..1500 do + WebsocketClient.join(socket, realtime_topic, %{config: config}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500 + end + + RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id) + + WebsocketClient.join(socket, realtime_topic, %{config: config}) + assert_process_down(socket) + end) + + assert log =~ + "project=#{tenant.external_id} external_id=#{tenant.external_id} [critical] ClientJoinRateLimitReached: Too many joins per second" + + assert length(String.split(log, "ClientJoinRateLimitReached")) <= 3 + end + end +end diff --git a/test/integration/rt_channel/postgres_changes_test.exs b/test/integration/rt_channel/postgres_changes_test.exs new file mode 100644 index 000000000..f11907d96 --- /dev/null +++ b/test/integration/rt_channel/postgres_changes_test.exs @@ -0,0 +1,916 @@ +defmodule Realtime.Integration.RtChannel.PostgresChangesTest do + use RealtimeWeb.ConnCase, + async: true, + parameterize: [ + %{serializer: Phoenix.Socket.V1.JSONSerializer}, + %{serializer: RealtimeWeb.Socket.V2Serializer} + ] + + import ExUnit.CaptureLog + import Generators + + alias Extensions.PostgresCdcRls + alias Phoenix.Socket.Message + alias Postgrex + alias Realtime.Database + alias Realtime.Integration.WebsocketClient + + @moduletag :capture_log + + setup [:checkout_tenant_connect_and_setup_postgres_changes] + + describe "insert" do + test "handle insert", %{tenant: tenant, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + config = %{postgres_changes: [%{event: "INSERT", schema: "public"}]} + + WebsocketClient.join(socket, topic, %{config: config}) + sub_id = :erlang.phash2(%{"event" => "INSERT", "schema" => "public"}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{ + "response" => %{ + "postgres_changes" => [ + %{"event" => "INSERT", "id" => ^sub_id, "schema" => "public"} + ] + }, + "status" => "ok" + }, + topic: ^topic + }, + 200 + + assert_receive %Message{ + event: "system", + payload: %{ + "channel" => "any", + "extension" => "postgres_changes", + "message" => "Subscribed to PostgreSQL", + "status" => "ok" + }, + ref: nil, + topic: ^topic + }, + 8000 + + {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + %{rows: [[id]]} = + Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{ + "data" => %{ + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"}, + %{"name" => "binary_data", "type" => "bytea"} + ], + "commit_timestamp" => _ts, + "errors" => nil, + "record" => %{"details" => "test", "id" => ^id}, + "schema" => "public", + "table" => "test", + "type" => "INSERT" + }, + "ids" => [^sub_id] + }, + ref: nil, + topic: "realtime:any" + }, + 500 + end + end + + describe "bytea column" do + test "handle insert with bytea data without double-encoding", %{ + tenant: tenant, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + config = %{postgres_changes: [%{event: "INSERT", schema: "public"}]} + + WebsocketClient.join(socket, topic, %{config: config}) + sub_id = :erlang.phash2(%{"event" => "INSERT", "schema" => "public"}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{"status" => "ok"}, + topic: ^topic + }, + 200 + + assert_receive %Message{ + event: "system", + payload: %{ + "channel" => "any", + "extension" => "postgres_changes", + "message" => "Subscribed to PostgreSQL", + "status" => "ok" + }, + ref: nil, + topic: ^topic + }, + 8000 + + {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + binary_value = <<1, 2, 3, 4, 5>> + + %{rows: [[_id]]} = + Postgrex.query!( + conn, + "insert into test (details, binary_data) values ('test', $1) returning id", + [binary_value] + ) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{ + "data" => %{ + "record" => record, + "type" => "INSERT" + }, + "ids" => [^sub_id] + }, + ref: nil, + topic: "realtime:any" + }, + 500 + + # The bytea value should be the hex string as provided by wal2json + assert record["binary_data"] == "0102030405" + end + end + + describe "update" do + test "handle update", %{tenant: tenant, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + config = %{postgres_changes: [%{event: "UPDATE", schema: "public"}]} + + WebsocketClient.join(socket, topic, %{config: config}) + sub_id = :erlang.phash2(%{"event" => "UPDATE", "schema" => "public"}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{ + "response" => %{ + "postgres_changes" => [ + %{"event" => "UPDATE", "id" => ^sub_id, "schema" => "public"} + ] + }, + "status" => "ok" + }, + ref: "1", + topic: ^topic + }, + 200 + + assert_receive %Message{ + event: "system", + payload: %{ + "channel" => "any", + "extension" => "postgres_changes", + "message" => "Subscribed to PostgreSQL", + "status" => "ok" + }, + ref: nil, + topic: ^topic + }, + 8000 + + {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + %{rows: [[id]]} = + Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) + + Postgrex.query!(conn, "update test set details = 'test' where id = #{id}", []) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{ + "data" => %{ + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"}, + %{"name" => "binary_data", "type" => "bytea"} + ], + "commit_timestamp" => _ts, + "errors" => nil, + "old_record" => %{"id" => ^id}, + "record" => %{"details" => "test", "id" => ^id}, + "schema" => "public", + "table" => "test", + "type" => "UPDATE" + }, + "ids" => [^sub_id] + }, + ref: nil, + topic: "realtime:any" + }, + 500 + end + end + + describe "delete" do + test "handle delete", %{tenant: tenant, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + config = %{postgres_changes: [%{event: "DELETE", schema: "public"}]} + + WebsocketClient.join(socket, topic, %{config: config}) + sub_id = :erlang.phash2(%{"event" => "DELETE", "schema" => "public"}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{ + "response" => %{ + "postgres_changes" => [ + %{"event" => "DELETE", "id" => ^sub_id, "schema" => "public"} + ] + }, + "status" => "ok" + }, + ref: "1", + topic: ^topic + }, + 200 + + assert_receive %Message{ + event: "system", + payload: %{ + "channel" => "any", + "extension" => "postgres_changes", + "message" => "Subscribed to PostgreSQL", + "status" => "ok" + }, + ref: nil, + topic: ^topic + }, + 8000 + + {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + %{rows: [[id]]} = + Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) + + Postgrex.query!(conn, "delete from test where id = #{id}", []) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{ + "data" => %{ + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"}, + %{"name" => "binary_data", "type" => "bytea"} + ], + "commit_timestamp" => _ts, + "errors" => nil, + "old_record" => %{"id" => ^id}, + "schema" => "public", + "table" => "test", + "type" => "DELETE" + }, + "ids" => [^sub_id] + }, + ref: nil, + topic: "realtime:any" + }, + 500 + end + end + + describe "wildcard" do + test "handle wildcard", %{tenant: tenant, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + config = %{postgres_changes: [%{event: "*", schema: "public"}]} + + WebsocketClient.join(socket, topic, %{config: config}) + sub_id = :erlang.phash2(%{"event" => "*", "schema" => "public"}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{ + "response" => %{ + "postgres_changes" => [ + %{"event" => "*", "id" => ^sub_id, "schema" => "public"} + ] + }, + "status" => "ok" + }, + ref: "1", + topic: ^topic + }, + 200 + + assert_receive %Message{ + event: "system", + payload: %{ + "channel" => "any", + "extension" => "postgres_changes", + "message" => "Subscribed to PostgreSQL", + "status" => "ok" + }, + ref: nil, + topic: ^topic + }, + 8000 + + {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + %{rows: [[id]]} = + Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{ + "data" => %{ + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"}, + %{"name" => "binary_data", "type" => "bytea"} + ], + "commit_timestamp" => _ts, + "errors" => nil, + "record" => %{"id" => ^id}, + "schema" => "public", + "table" => "test", + "type" => "INSERT" + }, + "ids" => [^sub_id] + }, + ref: nil, + topic: "realtime:any" + }, + 500 + + Postgrex.query!(conn, "update test set details = 'test' where id = #{id}", []) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{ + "data" => %{ + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"}, + %{"name" => "binary_data", "type" => "bytea"} + ], + "commit_timestamp" => _ts, + "errors" => nil, + "old_record" => %{"id" => ^id}, + "record" => %{"details" => "test", "id" => ^id}, + "schema" => "public", + "table" => "test", + "type" => "UPDATE" + }, + "ids" => [^sub_id] + }, + ref: nil, + topic: "realtime:any" + }, + 500 + + Postgrex.query!(conn, "delete from test where id = #{id}", []) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{ + "data" => %{ + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"}, + %{"name" => "binary_data", "type" => "bytea"} + ], + "commit_timestamp" => _ts, + "errors" => nil, + "old_record" => %{"id" => ^id}, + "schema" => "public", + "table" => "test", + "type" => "DELETE" + }, + "ids" => [^sub_id] + }, + ref: nil, + topic: "realtime:any" + }, + 500 + end + end + + describe "AND filter composition" do + test "delivers row matching all filters", %{tenant: tenant, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + + # details=eq.match AND id=gt.0 — all rows have id > 0 (auto-increment from 1), + # so the second condition is always true, making details=eq.match the effective selector. + filter = "details=eq.match,id=gt.0" + + config = %{ + postgres_changes: [%{event: "INSERT", schema: "public", table: "test", filter: filter}] + } + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{"status" => "ok"}, + topic: ^topic + }, + 200 + + assert_receive %Message{ + event: "system", + payload: %{ + "channel" => "any", + "extension" => "postgres_changes", + "message" => "Subscribed to PostgreSQL", + "status" => "ok" + }, + ref: nil, + topic: ^topic + }, + 8000 + + {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + %{rows: [[matching_id]]} = + Postgrex.query!(conn, "insert into test (details) values ('match') returning id", []) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{ + "data" => %{ + "record" => %{"id" => ^matching_id, "details" => "match"}, + "type" => "INSERT" + } + }, + ref: nil, + topic: ^topic + }, + 500 + end + + test "ignores row matching only one filter", %{tenant: tenant, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + + # details=eq.match AND id=gt.0 — all rows have id > 0 (auto-increment from 1), + # so the second condition is always true, making details=eq.match the effective selector. + filter = "details=eq.match,id=gt.0" + + config = %{ + postgres_changes: [%{event: "INSERT", schema: "public", table: "test", filter: filter}] + } + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{"status" => "ok"}, + topic: ^topic + }, + 200 + + assert_receive %Message{ + event: "system", + payload: %{ + "channel" => "any", + "extension" => "postgres_changes", + "message" => "Subscribed to PostgreSQL", + "status" => "ok" + }, + ref: nil, + topic: ^topic + }, + 8000 + + {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + # Row matching only the second filter (id>0) but not the first (details!='match') — should be ignored + Postgrex.query!(conn, "insert into test (details) values ('no-match') returning id", []) + + refute_receive %Message{ + event: "postgres_changes", + payload: %{"data" => %{"type" => "INSERT"}}, + topic: ^topic + }, + 500 + end + end + + describe "select column filtering" do + test "subscribe with select filters payload columns — INSERT", %{ + tenant: tenant, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + + config = %{ + postgres_changes: [ + %{event: "INSERT", schema: "public", table: "test", select: ["details"]} + ] + } + + WebsocketClient.join(socket, topic, %{config: config}) + sub_id = :erlang.phash2(%{"event" => "INSERT", "schema" => "public", "table" => "test", "select" => ["details"]}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, + 200 + + assert_receive %Message{ + event: "system", + payload: %{ + "extension" => "postgres_changes", + "message" => "Subscribed to PostgreSQL", + "status" => "ok" + }, + topic: ^topic + }, + 8000 + + {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + %{rows: [[id]]} = + Postgrex.query!(conn, "insert into test (details) values ('hello') returning id", []) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{ + "data" => %{ + "columns" => columns, + "record" => record, + "type" => "INSERT" + }, + "ids" => [^sub_id] + }, + topic: ^topic + }, + 500 + + # PK always included even when not in select + assert record["id"] == id + assert record["details"] == "hello" + # binary_data not in select — must be absent + refute Map.has_key?(record, "binary_data") + # columns metadata only shows selected + PK columns + column_names = Enum.map(columns, & &1["name"]) + assert "id" in column_names + assert "details" in column_names + refute "binary_data" in column_names + end + + test "subscribe with select filters payload columns — UPDATE", %{ + tenant: tenant, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + + config = %{ + postgres_changes: [ + %{event: "UPDATE", schema: "public", table: "test", select: ["details"]} + ] + } + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, + 200 + + assert_receive %Message{ + event: "system", + payload: %{"extension" => "postgres_changes", "status" => "ok"}, + topic: ^topic + }, + 8000 + + {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + %{rows: [[id]]} = + Postgrex.query!(conn, "insert into test (details) values ('before') returning id", []) + + Postgrex.query!(conn, "update test set details = 'after' where id = #{id}", []) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{ + "data" => %{ + "record" => record, + "old_record" => old_record, + "type" => "UPDATE" + } + }, + topic: ^topic + }, + 500 + + # new record: only selected + PK + assert record["id"] == id + assert record["details"] == "after" + refute Map.has_key?(record, "binary_data") + + # old_record: only selected + PK + assert old_record["id"] == id + refute Map.has_key?(old_record, "binary_data") + end + + test "subscribe with select filters payload columns — DELETE", %{ + tenant: tenant, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + + config = %{ + postgres_changes: [ + %{event: "DELETE", schema: "public", table: "test", select: ["details"]} + ] + } + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, + 200 + + assert_receive %Message{ + event: "system", + payload: %{"extension" => "postgres_changes", "status" => "ok"}, + topic: ^topic + }, + 8000 + + {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + %{rows: [[id]]} = + Postgrex.query!(conn, "insert into test (details) values ('bye') returning id", []) + + Postgrex.query!(conn, "delete from test where id = #{id}", []) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{ + "data" => %{ + "old_record" => old_record, + "type" => "DELETE" + } + }, + topic: ^topic + }, + 500 + + # old_record filtered to selected + PK + assert old_record["id"] == id + refute Map.has_key?(old_record, "binary_data") + end + + test "subscribe without select receives full payload — backward compat", %{ + tenant: tenant, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + config = %{postgres_changes: [%{event: "INSERT", schema: "public", table: "test"}]} + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, + 200 + + assert_receive %Message{ + event: "system", + payload: %{"extension" => "postgres_changes", "status" => "ok"}, + topic: ^topic + }, + 8000 + + {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + %{rows: [[id]]} = + Postgrex.query!(conn, "insert into test (details) values ('full') returning id", []) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{ + "data" => %{ + "columns" => columns, + "record" => record, + "type" => "INSERT" + } + }, + topic: ^topic + }, + 500 + + # All columns present + assert record["id"] == id + assert record["details"] == "full" + column_names = Enum.map(columns, & &1["name"]) + assert "id" in column_names + assert "details" in column_names + assert "binary_data" in column_names + end + + test "select with filter only delivers matching rows with filtered columns", %{ + tenant: tenant, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + + config = %{ + postgres_changes: [ + %{ + event: "INSERT", + schema: "public", + table: "test", + filter: "details=eq.match", + select: ["details"] + } + ] + } + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, + 200 + + assert_receive %Message{ + event: "system", + payload: %{"extension" => "postgres_changes", "status" => "ok"}, + topic: ^topic + }, + 8000 + + {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + # Non-matching row — should not be received + Postgrex.query!(conn, "insert into test (details) values ('no-match') returning id", []) + + refute_receive %Message{event: "postgres_changes", topic: ^topic}, 300 + + # Matching row + %{rows: [[id]]} = + Postgrex.query!(conn, "insert into test (details) values ('match') returning id", []) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{ + "data" => %{ + "record" => record, + "type" => "INSERT" + } + }, + topic: ^topic + }, + 500 + + assert record["id"] == id + assert record["details"] == "match" + refute Map.has_key?(record, "binary_data") + end + + test "payload size is reduced when using select — performance proxy", %{ + tenant: tenant, + serializer: serializer + } do + large_value = String.duplicate("x", 2048) + + # Subscriber with select — only id (start this first to boot the CDC manager) + {socket_select, _} = get_connection(tenant, serializer) + topic_select = "realtime:select" + + WebsocketClient.join(socket_select, topic_select, %{ + config: %{postgres_changes: [%{event: "INSERT", schema: "public", table: "test", select: ["id"]}]} + }) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic_select}, + 200 + + assert_receive %Message{ + event: "system", + payload: %{"extension" => "postgres_changes", "status" => "ok"}, + topic: ^topic_select + }, + 8000 + + # Manager is now running — add the large_text column + {:ok, _, setup_conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + Postgrex.query!(setup_conn, "alter table test add column if not exists large_text text", []) + + # Subscriber without select — full payload + {socket_full, _} = get_connection(tenant, serializer) + topic_full = "realtime:full" + + WebsocketClient.join(socket_full, topic_full, %{ + config: %{postgres_changes: [%{event: "INSERT", schema: "public", table: "test"}]} + }) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic_full}, + 200 + + assert_receive %Message{ + event: "system", + payload: %{"extension" => "postgres_changes", "status" => "ok"}, + topic: ^topic_full + }, + 8000 + + {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + Postgrex.query!(conn, "insert into test (details, large_text) values ('hi', $1)", [large_value]) + + assert_receive %Message{ + event: "postgres_changes", + payload: %{"data" => full_data}, + topic: ^topic_full + } = full_msg, + 500 + + assert_receive %Message{ + event: "postgres_changes", + payload: %{"data" => select_data}, + topic: ^topic_select + } = select_msg, + 500 + + full_size = full_msg |> :erlang.term_to_binary() |> byte_size() + select_size = select_msg |> :erlang.term_to_binary() |> byte_size() + + assert select_size < full_size + assert Map.has_key?(full_data["record"], "large_text") + refute Map.has_key?(select_data["record"], "large_text") + end + end + + describe "error handling" do + test "error subscribing", %{tenant: tenant, serializer: serializer} do + {:ok, conn} = Database.connect(tenant, "realtime_test") + + {:ok, _} = + Database.transaction(conn, fn db_conn -> + Postgrex.query!(db_conn, "drop publication if exists supabase_realtime_test") + end) + + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + config = %{postgres_changes: [%{event: "INSERT", schema: "public"}]} + + log = + capture_log(fn -> + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{ + event: "system", + payload: %{ + "channel" => "any", + "extension" => "postgres_changes", + "message" => + "Unable to subscribe to changes with given parameters. Please check Realtime is enabled for the given connect parameters: [event: INSERT, schema: public, table: *, filters: [], select: nil]", + "status" => "error" + }, + ref: nil, + topic: ^topic + }, + 8000 + end) + + assert log =~ "RealtimeDisabledForConfiguration" + assert log =~ "Unable to subscribe to changes with given parameters" + end + + test "handle nil postgres changes params as empty param changes", %{ + tenant: tenant, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer) + topic = "realtime:any" + config = %{postgres_changes: [nil]} + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, + 200 + + refute_receive %Message{ + event: "system", + payload: %{ + "channel" => "any", + "extension" => "postgres_changes", + "message" => "Subscribed to PostgreSQL", + "status" => "ok" + }, + ref: nil, + topic: ^topic + }, + 1000 + end + end +end diff --git a/test/integration/rt_channel/presence_test.exs b/test/integration/rt_channel/presence_test.exs new file mode 100644 index 000000000..d4c125a10 --- /dev/null +++ b/test/integration/rt_channel/presence_test.exs @@ -0,0 +1,316 @@ +defmodule Realtime.Integration.RtChannel.PresenceTest do + use RealtimeWeb.ConnCase, + async: true, + parameterize: [ + %{serializer: Phoenix.Socket.V1.JSONSerializer}, + %{serializer: RealtimeWeb.Socket.V2Serializer} + ] + + import ExUnit.CaptureLog + import Generators + + alias Phoenix.Socket.Message + alias Realtime.Integration.WebsocketClient + alias Realtime.Tenants.Connect + + @moduletag :capture_log + + setup [:checkout_tenant_and_connect] + + describe "public presence" do + setup [:rls_context] + + test "public presence", %{tenant: tenant, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer) + config = %{presence: %{key: "", enabled: true}, private: false} + topic = "realtime:any" + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + assert_receive %Message{event: "presence_state", payload: %{}, topic: ^topic}, 500 + + payload = %{ + type: "presence", + event: "TRACK", + payload: %{name: "realtime_presence_96", t: 1814.7000000029802} + } + + WebsocketClient.send_event(socket, topic, "presence", payload) + + assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic} + + join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd() + assert get_in(join_payload, ["name"]) == payload.payload.name + assert get_in(join_payload, ["t"]) == payload.payload.t + end + + test "presence enabled if param enabled is set in configuration for public channels", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + topic = "realtime:#{topic}" + + WebsocketClient.join(socket, topic, %{config: %{private: false, presence: %{enabled: true}}}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + assert_receive %Message{event: "presence_state"}, 500 + end + + test "presence disabled if param 'enabled' is set to false in configuration for public channels", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + topic = "realtime:#{topic}" + + WebsocketClient.join(socket, topic, %{config: %{private: false, presence: %{enabled: false}}}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + refute_receive %Message{event: "presence_state"}, 500 + end + + test "presence automatically enabled when user sends track message for public channel", %{ + tenant: tenant, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer) + config = %{presence: %{key: "", enabled: false}, private: false} + topic = "realtime:any" + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + refute_receive %Message{event: "presence_state"}, 500 + + payload = %{ + type: "presence", + event: "TRACK", + payload: %{name: "realtime_presence_96", t: 1814.7000000029802} + } + + WebsocketClient.send_event(socket, topic, "presence", payload) + + assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic} + + join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd() + assert get_in(join_payload, ["name"]) == payload.payload.name + assert get_in(join_payload, ["t"]) == payload.payload.t + end + end + + describe "private presence" do + setup [:rls_context] + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "private presence with read and write permissions will be able to track and receive presence changes", + %{tenant: tenant, topic: topic, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{presence: %{key: "", enabled: true}, private: true} + topic = "realtime:#{topic}" + + WebsocketClient.join(socket, topic, %{config: config}) + assert_receive %Message{event: "presence_state", payload: %{}, topic: ^topic}, 500 + + payload = %{ + type: "presence", + event: "TRACK", + payload: %{name: "realtime_presence_96", t: 1814.7000000029802} + } + + WebsocketClient.send_event(socket, topic, "presence", payload) + refute_receive %Message{event: "phx_leave", topic: ^topic} + assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}, 500 + join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd() + assert get_in(join_payload, ["name"]) == payload.payload.name + assert get_in(join_payload, ["t"]) == payload.payload.t + end + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence], + mode: :distributed + test "private presence with read and write permissions will be able to track and receive presence changes using a remote node", + %{tenant: tenant, topic: topic, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{presence: %{key: "", enabled: true}, private: true} + topic = "realtime:#{topic}" + + WebsocketClient.join(socket, topic, %{config: config}) + assert_receive %Message{event: "presence_state", payload: %{}, topic: ^topic}, 500 + + payload = %{ + type: "presence", + event: "TRACK", + payload: %{name: "realtime_presence_96", t: 1814.7000000029802} + } + + WebsocketClient.send_event(socket, topic, "presence", payload) + refute_receive %Message{event: "phx_leave", topic: ^topic} + assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}, 500 + join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd() + assert get_in(join_payload, ["name"]) == payload.payload.name + assert get_in(join_payload, ["t"]) == payload.payload.t + end + + @tag policies: [:authenticated_read_broadcast_and_presence] + test "private presence with read permissions will be able to receive presence changes but won't be able to track", + %{tenant: tenant, topic: topic, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + {secondary_socket, _} = get_connection(tenant, serializer, role: "service_role") + config = fn key -> %{presence: %{key: key, enabled: true}, private: true} end + topic = "realtime:#{topic}" + + WebsocketClient.join(socket, topic, %{config: config.("authenticated")}) + + payload = %{ + type: "presence", + event: "TRACK", + payload: %{name: "realtime_presence_96", t: 1814.7000000029802} + } + + # This will be ignored + WebsocketClient.send_event(socket, topic, "presence", payload) + + assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500 + assert_receive %Message{event: "presence_state", payload: %{}, ref: nil, topic: ^topic} + refute_receive %Message{event: "presence_diff", payload: _, ref: _, topic: ^topic} + + payload = %{ + type: "presence", + event: "TRACK", + payload: %{name: "realtime_presence_97", t: 1814.7000000029802} + } + + # This will be tracked + WebsocketClient.join(secondary_socket, topic, %{config: config.("service_role")}) + WebsocketClient.send_event(secondary_socket, topic, "presence", payload) + + assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500 + assert_receive %Message{topic: ^topic, event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}} + assert_receive %Message{event: "presence_state", payload: %{}, ref: nil, topic: ^topic} + + join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd() + assert get_in(join_payload, ["name"]) == payload.payload.name + assert get_in(join_payload, ["t"]) == payload.payload.t + + assert_receive %Message{topic: ^topic, event: "presence_diff"} = res + + assert join_payload = + res + |> Map.from_struct() + |> get_in([:payload, "joins", "service_role", "metas"]) + |> hd() + + assert get_in(join_payload, ["name"]) == payload.payload.name + assert get_in(join_payload, ["t"]) == payload.payload.t + end + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "presence enabled if param enabled is set in configuration for private channels", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + topic = "realtime:#{topic}" + + WebsocketClient.join(socket, topic, %{config: %{private: true, presence: %{enabled: true}}}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + assert_receive %Message{event: "presence_state"}, 500 + end + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "presence disabled if param 'enabled' is set to false in configuration for private channels", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + topic = "realtime:#{topic}" + + WebsocketClient.join(socket, topic, %{config: %{private: true, presence: %{enabled: false}}}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + refute_receive %Message{event: "presence_state"}, 500 + end + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "presence automatically enabled when user sends track message for private channel", + %{tenant: tenant, topic: topic, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + config = %{presence: %{key: "", enabled: false}, private: true} + topic = "realtime:#{topic}" + + WebsocketClient.join(socket, topic, %{config: config}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + refute_receive %Message{event: "presence_state"}, 500 + + payload = %{ + type: "presence", + event: "TRACK", + payload: %{name: "realtime_presence_96", t: 1814.7000000029802} + } + + WebsocketClient.send_event(socket, topic, "presence", payload) + + assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}, 500 + join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd() + assert get_in(join_payload, ["name"]) == payload.payload.name + assert get_in(join_payload, ["t"]) == payload.payload.t + end + end + + describe "database connection errors" do + setup [:rls_context] + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "handles lack of connection to database error on private channels", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + topic = "realtime:#{topic}" + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + WebsocketClient.join(socket, topic, %{config: %{private: true, presence: %{enabled: true}}}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + assert_receive %Message{event: "presence_state"} + + log = + capture_log(fn -> + :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end) + payload = %{type: "presence", event: "TRACK", payload: %{name: "realtime_presence_96", t: 1814.7000000029802}} + WebsocketClient.send_event(socket, topic, "presence", payload) + + refute_receive %Message{event: "presence_diff"}, 500 + # Waiting more than 5 seconds as this is the amount of time we will wait for the Connection to be ready + refute_receive %Message{event: "phx_leave", topic: ^topic}, 16000 + end) + + assert log =~ ~r/external_id=#{tenant.external_id}.*UnableToHandlePresence/ + end + + @tag policies: [] + test "lack of connection to database error does not impact public channels", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + topic = "realtime:#{topic}" + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + WebsocketClient.join(socket, topic, %{config: %{private: false, presence: %{enabled: true}}}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + assert_receive %Message{event: "presence_state"} + + log = + capture_log(fn -> + :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end) + payload = %{type: "presence", event: "TRACK", payload: %{name: "realtime_presence_96", t: 1814.7000000029802}} + WebsocketClient.send_event(socket, topic, "presence", payload) + + assert_receive %Message{event: "presence_diff"}, 500 + refute_receive %Message{event: "phx_leave", topic: ^topic} + end) + + refute log =~ ~r/external_id=#{tenant.external_id}.*UnableToHandlePresence/ + end + end +end diff --git a/test/integration/rt_channel/token_handling_test.exs b/test/integration/rt_channel/token_handling_test.exs new file mode 100644 index 000000000..d6d63037e --- /dev/null +++ b/test/integration/rt_channel/token_handling_test.exs @@ -0,0 +1,460 @@ +defmodule Realtime.Integration.RtChannel.TokenHandlingTest do + use RealtimeWeb.ConnCase, + async: true, + parameterize: [%{serializer: Phoenix.Socket.V1.JSONSerializer}, %{serializer: RealtimeWeb.Socket.V2Serializer}] + + import ExUnit.CaptureLog + import Generators + + alias Phoenix.Socket.Message + alias Realtime.Database + alias Realtime.Integration.WebsocketClient + + @moduletag :capture_log + + setup [:checkout_tenant_and_connect] + + describe "token validation" do + setup [:rls_context] + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "badly formatted jwt token", %{tenant: tenant, serializer: serializer} do + log = + capture_log(fn -> + WebsocketClient.connect(self(), uri(tenant, serializer), serializer, [{"x-api-key", "bad_token"}]) + end) + + assert log =~ "MalformedJWT: The token provided is not a valid JWT" + end + + test "invalid JWT with expired token", %{tenant: tenant, serializer: serializer} do + log = + capture_log(fn -> + get_connection(tenant, serializer, + role: "authenticated", + claims: %{:exp => System.system_time(:second) - 1000}, + params: %{log_level: :info} + ) + end) + + assert log =~ "InvalidJWTToken: Token has expired" + end + + test "token required the role key", %{tenant: tenant, serializer: serializer} do + {:ok, token} = token_no_role(tenant) + + assert {:error, %{status_code: 403}} = + WebsocketClient.connect(self(), uri(tenant, serializer), serializer, [{"x-api-key", token}]) + end + + test "handles connection with valid api-header but ignorable access_token payload", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + realtime_topic = "realtime:#{topic}" + + log = + capture_log(fn -> + {:ok, token} = + generate_token(tenant, %{ + exp: System.system_time(:second) + 1000, + role: "authenticated", + sub: random_string() + }) + + {:ok, socket} = WebsocketClient.connect(self(), uri(tenant, serializer), serializer, [{"x-api-key", token}]) + + WebsocketClient.join(socket, realtime_topic, %{ + config: %{broadcast: %{self: true}, private: false}, + access_token: "sb_#{random_string()}" + }) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + end) + + refute log =~ "MalformedJWT: The token provided is not a valid JWT" + end + + test "missing claims close connection", %{tenant: tenant, topic: topic, serializer: serializer} do + {socket, access_token} = get_connection(tenant, serializer, role: "authenticated") + + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) + 2000}) + + # Update token to be a near expiring token + WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token}) + + assert_receive %Message{ + event: "system", + payload: %{ + "extension" => "system", + "message" => "Fields `role` and `exp` are required in JWT", + "status" => "error" + } + }, + 500 + + assert_receive %Message{event: "phx_close"} + end + end + + describe "access token refresh" do + setup [:rls_context] + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "on new access_token and channel is private policies are reevaluated for read policy", + %{tenant: tenant, topic: topic, serializer: serializer} do + {socket, access_token} = get_connection(tenant, serializer, role: "authenticated") + + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{ + config: %{broadcast: %{self: true}, private: true}, + access_token: access_token + }) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + {:ok, new_token} = token_valid(tenant, "anon") + + WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => new_token}) + + error_message = "You do not have permissions to read from this Channel topic: #{topic}" + + assert_receive %Message{ + event: "system", + payload: %{"channel" => ^topic, "extension" => "system", "message" => ^error_message, "status" => "error"}, + topic: ^realtime_topic + } + + assert_receive %Message{event: "phx_close", topic: ^realtime_topic} + end + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "on new access_token and channel is private policies are reevaluated for write policy", %{ + topic: topic, + tenant: tenant, + serializer: serializer + } do + {socket, access_token} = get_connection(tenant, serializer, role: "authenticated") + realtime_topic = "realtime:#{topic}" + config = %{broadcast: %{self: true}, private: true} + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + # Checks first send which will set write policy to true + payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} + WebsocketClient.send_event(socket, realtime_topic, "broadcast", payload) + + assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^realtime_topic}, 500 + + # RLS policies changed to only allow read + {:ok, db_conn} = Database.connect(tenant, "realtime_test") + clean_table(db_conn, "realtime", "messages") + create_rls_policies(db_conn, [:authenticated_read_broadcast_and_presence], %{topic: topic}) + + # Set new token to recheck policies + {:ok, new_token} = + generate_token(tenant, %{exp: System.system_time(:second) + 1000, role: "authenticated", sub: random_string()}) + + WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => new_token}) + + # Send message to be ignored + payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} + WebsocketClient.send_event(socket, realtime_topic, "broadcast", payload) + + refute_receive %Message{ + event: "broadcast", + payload: ^payload, + topic: ^realtime_topic + }, + 1500 + end + + test "on new access_token and channel is public policies are not reevaluated", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + {socket, access_token} = get_connection(tenant, serializer, role: "authenticated") + {:ok, new_token} = token_valid(tenant, "anon") + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => new_token}) + + refute_receive %Message{} + end + + test "on empty string access_token the socket sends an error message", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + {socket, access_token} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => ""}) + + assert_receive %Message{ + topic: ^realtime_topic, + event: "system", + payload: %{ + "extension" => "system", + "message" => msg, + "status" => "error" + } + } + + assert_receive %Message{event: "phx_close"} + assert msg =~ "The token provided is not a valid JWT" + end + + test "on expired access_token the socket sends an error message", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + sub = random_string() + + {socket, access_token} = get_connection(tenant, serializer, role: "authenticated", claims: %{sub: sub}) + + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) - 1000, sub: sub}) + + log = + capture_log(fn -> + WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token}) + + assert_receive %Message{ + topic: ^realtime_topic, + event: "system", + payload: %{"extension" => "system", "message" => "Token has expired " <> _, "status" => "error"} + } + + assert_receive %Message{event: "phx_close", topic: ^realtime_topic} + end) + + assert log =~ "ChannelShutdown: Token has expired" + end + + test "ChannelShutdown include sub if available in jwt claims", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + exp = System.system_time(:second) + 10_000 + + {socket, access_token} = + get_connection(tenant, serializer, role: "authenticated", claims: %{exp: exp}, params: %{log_level: :warning}) + + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + sub = random_string() + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500 + + {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) - 1000, sub: sub}) + + log = + capture_log([level: :warning], fn -> + WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token}) + + assert_receive %Message{event: "system"}, 1000 + assert_receive %Message{event: "phx_close", topic: ^realtime_topic} + end) + + assert log =~ "ChannelShutdown" + assert log =~ "sub=#{sub}" + end + + test "on sb prefixed access_token the socket ignores the message and respects JWT expiry time", %{ + tenant: tenant, + topic: topic, + serializer: serializer + } do + sub = random_string() + + {socket, access_token} = + get_connection(tenant, serializer, + role: "authenticated", + claims: %{sub: sub, exp: System.system_time(:second) + 5} + ) + + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + WebsocketClient.send_event(socket, realtime_topic, "access_token", %{ + "access_token" => "sb_publishable_-fake_key" + }) + + # Check if the new token does not trigger a shutdown + refute_receive %Message{event: "system", topic: ^realtime_topic}, 100 + + # Await to check if channel respects token expiry time + assert_receive %Message{ + event: "system", + payload: %{"extension" => "system", "message" => msg, "status" => "error"}, + topic: ^realtime_topic + }, + 5000 + + assert_receive %Message{event: "phx_close", topic: ^realtime_topic} + assert msg =~ "Token has expired" + end + end + + describe "token expiry" do + setup [:rls_context] + + test "checks token periodically", %{tenant: tenant, topic: topic, serializer: serializer} do + {socket, access_token} = get_connection(tenant, serializer, role: "authenticated") + + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + {:ok, token} = + generate_token(tenant, %{:exp => System.system_time(:second) + 2, role: "authenticated"}) + + # Update token to be a near expiring token + WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token}) + + # Awaits to see if connection closes automatically + assert_receive %Message{ + event: "system", + payload: %{"extension" => "system", "message" => msg, "status" => "error"} + }, + 3000 + + assert_receive %Message{event: "phx_close"} + + assert msg =~ "Token has expired" + end + + test "token expires in between joins", %{tenant: tenant, topic: topic, serializer: serializer} do + {socket, access_token} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + {:ok, access_token} = + generate_token(tenant, %{:exp => System.system_time(:second) + 1, role: "authenticated"}) + + # token expires in between joins so it needs to be handled by the channel and not the socket + Process.sleep(1000) + realtime_topic = "realtime:#{topic}" + + log = + capture_log(fn -> + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{ + "status" => "error", + "response" => %{"reason" => reason} + }, + topic: ^realtime_topic + }, + 500 + + assert reason =~ "InvalidJWTToken: Token has expired" + end) + + assert_receive %Message{event: "phx_close"} + assert log =~ "#{tenant.external_id}" + end + + test "token loses claims in between joins", %{tenant: tenant, topic: topic, serializer: serializer} do + {socket, access_token} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + {:ok, access_token} = generate_token(tenant, %{:exp => System.system_time(:second) + 10}) + + # token breaks claims in between joins so it needs to be handled by the channel and not the socket + realtime_topic = "realtime:#{topic}" + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{ + "status" => "error", + "response" => %{ + "reason" => "InvalidJWTToken: Fields `role` and `exp` are required in JWT" + } + }, + topic: ^realtime_topic + }, + 500 + + assert_receive %Message{event: "phx_close"} + end + + test "token is badly formatted in between joins", %{tenant: tenant, topic: topic, serializer: serializer} do + {socket, access_token} = get_connection(tenant, serializer, role: "authenticated") + config = %{broadcast: %{self: true}, private: false} + realtime_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + + # token becomes a string in between joins so it needs to be handled by the channel and not the socket + WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: "potato"}) + + assert_receive %Message{ + event: "phx_reply", + payload: %{ + "status" => "error", + "response" => %{ + "reason" => "MalformedJWT: The token provided is not a valid JWT" + } + }, + topic: ^realtime_topic + }, + 500 + + assert_receive %Message{event: "phx_close"} + end + end +end diff --git a/test/integration/rt_channel/wal_bloat_test.exs b/test/integration/rt_channel/wal_bloat_test.exs new file mode 100644 index 000000000..a75942287 --- /dev/null +++ b/test/integration/rt_channel/wal_bloat_test.exs @@ -0,0 +1,184 @@ +defmodule Realtime.Integration.RtChannel.WalBloatTest do + use RealtimeWeb.ConnCase, + async: false, + parameterize: [ + %{serializer: Phoenix.Socket.V1.JSONSerializer}, + %{serializer: RealtimeWeb.Socket.V2Serializer} + ] + + import Generators + + alias Phoenix.Socket.Message + alias Postgrex + alias Realtime.Database + alias Realtime.Integration.WebsocketClient + alias Realtime.Tenants.Connect + alias Realtime.Tenants.ReplicationConnection + + @moduletag :capture_log + + setup [:checkout_tenant_and_connect] + + describe "WAL bloat handling" do + setup %{tenant: tenant} do + topic = random_string() + {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) + + %{rows: [[max_wal_size]]} = Postgrex.query!(db_conn, "SHOW max_wal_size", []) + %{rows: [[wal_keep_size]]} = Postgrex.query!(db_conn, "SHOW wal_keep_size", []) + %{rows: [[max_slot_wal_keep_size]]} = Postgrex.query!(db_conn, "SHOW max_slot_wal_keep_size", []) + + assert max_wal_size == "32MB" + assert wal_keep_size == "32MB" + assert max_slot_wal_keep_size == "32MB" + + Postgrex.query!(db_conn, "CREATE TABLE IF NOT EXISTS wal_test (id INT, data TEXT)", []) + + Postgrex.query!( + db_conn, + """ + CREATE OR REPLACE FUNCTION wal_test_trigger_func() RETURNS TRIGGER AS $$ + BEGIN + PERFORM realtime.send(json_build_object ('value', 'test' :: text)::jsonb, 'test', '#{topic}', false); + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + """, + [] + ) + + Postgrex.query!(db_conn, "DROP TRIGGER IF EXISTS wal_test_trigger ON wal_test", []) + + Postgrex.query!( + db_conn, + """ + CREATE TRIGGER wal_test_trigger + AFTER INSERT OR UPDATE OR DELETE ON wal_test + FOR EACH ROW + EXECUTE FUNCTION wal_test_trigger_func() + """, + [] + ) + + GenServer.stop(db_conn) + + on_exit(fn -> + {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) + + Postgrex.query!(db_conn, "DROP TABLE IF EXISTS wal_test CASCADE", []) + GenServer.stop(db_conn) + end) + + %{topic: topic} + end + + @tag timeout: :timer.minutes(3) + test "track PID changes during WAL bloat creation", %{tenant: tenant, topic: topic, serializer: serializer} do + {socket, _} = get_connection(tenant, serializer, role: "authenticated") + full_topic = "realtime:#{topic}" + + WebsocketClient.join(socket, full_topic, %{config: %{broadcast: %{self: true}, private: false}}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + assert Connect.ready?(tenant.external_id) + + {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + original_connect_pid = Connect.whereis(tenant.external_id) + # Replication now starts asynchronously, so wait for the slot to be active before + # reading the replication pid (it would otherwise race and return nil). + await_replication_slot_active(db_conn, 30, 500) + original_db_pid = active_replication_slot_pid!(db_conn) + original_replication_pid = ReplicationConnection.whereis(tenant.external_id) + + replication_ref = Process.monitor(original_replication_pid) + + generate_wal_bloat(tenant) + terminate_bloat_connections(db_conn) + + assert_receive {:DOWN, ^replication_ref, :process, ^original_replication_pid, _}, 60_000 + + assert Connect.ready?(tenant.external_id) + {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + new_db_pid = await_replication_slot_active(db_conn, 60, 1000) + + assert new_db_pid != original_db_pid + assert ^original_connect_pid = Connect.whereis(tenant.external_id) + assert original_replication_pid != ReplicationConnection.whereis(tenant.external_id) + + payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} + WebsocketClient.send_event(socket, full_topic, "broadcast", payload) + assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^full_topic}, 500 + + Postgrex.query!(db_conn, "INSERT INTO wal_test VALUES (1, 'test')", []) + + assert_receive %Message{ + event: "broadcast", + payload: %{ + "event" => "test", + "payload" => %{"value" => "test"}, + "type" => "broadcast" + }, + join_ref: nil, + ref: nil, + topic: ^full_topic + }, + 5000 + end + end + + defp active_replication_slot_pid!(db_conn) do + %{rows: [[pid]]} = + Postgrex.query!( + db_conn, + "SELECT active_pid FROM pg_replication_slots WHERE active_pid IS NOT NULL AND slot_name = 'supabase_realtime_messages_replication_slot_'", + [] + ) + + pid + end + + defp await_replication_slot_active(db_conn, retries, interval_ms) do + Enum.reduce_while(1..retries, nil, fn _, _ -> + case Postgrex.query!( + db_conn, + "SELECT active_pid FROM pg_replication_slots WHERE active_pid IS NOT NULL AND slot_name = 'supabase_realtime_messages_replication_slot_'", + [] + ) do + %{rows: [[pid]]} -> + {:halt, pid} + + _ -> + Process.sleep(interval_ms) + {:cont, nil} + end + end) + |> then(fn + nil -> flunk("Replication slot did not become active within #{retries}s") + pid -> pid + end) + end + + defp generate_wal_bloat(tenant) do + 1..5 + |> Enum.map(fn _ -> + Task.async(fn -> + {:ok, conn} = Database.connect(tenant, "realtime_bloat", :stop) + + Postgrex.transaction(conn, fn tx -> + Postgrex.query(tx, "INSERT INTO wal_test SELECT generate_series(1, 100000), repeat('x', 2000)", []) + {:error, "test"} + end) + + Process.exit(conn, :normal) + end) + end) + |> Task.await_many(20_000) + end + + defp terminate_bloat_connections(db_conn) do + Postgrex.query!( + db_conn, + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE application_name = 'realtime_bloat'", + [] + ) + end +end diff --git a/test/integration/rt_channel_test.exs b/test/integration/rt_channel_test.exs deleted file mode 100644 index 806a5ad7e..000000000 --- a/test/integration/rt_channel_test.exs +++ /dev/null @@ -1,2414 +0,0 @@ -defmodule Realtime.Integration.RtChannelTest do - # async: false due to the fact that multiple operations against the same tenant and usage of mocks - # Also using dev_tenant due to distributed test - alias Realtime.Api - use RealtimeWeb.ConnCase, async: false - use Mimic - import ExUnit.CaptureLog - import Generators - - setup :set_mimic_global - - require Logger - - alias Extensions.PostgresCdcRls - - alias Phoenix.Socket.Message - alias Phoenix.Socket.V1 - - alias Postgrex - - alias Realtime.Api.Tenant - alias Realtime.Database - alias Realtime.Integration.WebsocketClient - alias Realtime.RateCounter - alias Realtime.Tenants - alias Realtime.Tenants.Authorization - alias Realtime.Tenants.Connect - - alias RealtimeWeb.RealtimeChannel.Tracker - alias RealtimeWeb.SocketDisconnect - - @moduletag :capture_log - @port 4003 - @serializer V1.JSONSerializer - - Application.put_env(:phoenix, TestEndpoint, - https: false, - http: [port: @port], - debug_errors: false, - server: true, - pubsub_server: __MODULE__, - secret_key_base: String.duplicate("a", 64) - ) - - setup_all do - capture_log(fn -> start_supervised!(TestEndpoint) end) - start_supervised!({Phoenix.PubSub, name: __MODULE__}) - :ok - end - - setup [:mode] - - describe "postgres changes" do - setup %{tenant: tenant} do - {:ok, conn} = Database.connect(tenant, "realtime_test") - - Database.transaction(conn, fn db_conn -> - queries = [ - "drop table if exists public.test", - "drop publication if exists supabase_realtime_test", - "create sequence if not exists test_id_seq;", - """ - create table if not exists "public"."test" ( - "id" int4 not null default nextval('test_id_seq'::regclass), - "details" text, - primary key ("id")); - """, - "grant all on table public.test to anon;", - "grant all on table public.test to postgres;", - "grant all on table public.test to authenticated;", - "create publication supabase_realtime_test for all tables" - ] - - Enum.each(queries, &Postgrex.query!(db_conn, &1, [])) - end) - - :ok - end - - test "error subscribing", %{tenant: tenant} do - {:ok, conn} = Database.connect(tenant, "realtime_test") - - # Let's drop the publication to cause an error - Database.transaction(conn, fn db_conn -> - Postgrex.query!(db_conn, "drop publication if exists supabase_realtime_test") - end) - - {socket, _} = get_connection(tenant) - topic = "realtime:any" - config = %{postgres_changes: [%{event: "INSERT", schema: "public"}]} - - log = - capture_log(fn -> - WebsocketClient.join(socket, topic, %{config: config}) - - assert_receive %Message{ - event: "system", - payload: %{ - "channel" => "any", - "extension" => "postgres_changes", - "message" => - "{:error, \"Unable to subscribe to changes with given parameters. Please check Realtime is enabled for the given connect parameters: [event: INSERT, schema: public]\"}", - "status" => "error" - }, - ref: nil, - topic: ^topic - }, - 8000 - end) - - assert log =~ "RealtimeDisabledForConfiguration" - assert log =~ "Unable to subscribe to changes with given parameters" - end - - test "handle insert", %{tenant: tenant} do - {socket, _} = get_connection(tenant) - topic = "realtime:any" - config = %{postgres_changes: [%{event: "INSERT", schema: "public"}]} - - WebsocketClient.join(socket, topic, %{config: config}) - sub_id = :erlang.phash2(%{"event" => "INSERT", "schema" => "public"}) - - assert_receive %Message{ - event: "phx_reply", - payload: %{ - "response" => %{ - "postgres_changes" => [ - %{"event" => "INSERT", "id" => ^sub_id, "schema" => "public"} - ] - }, - "status" => "ok" - }, - topic: ^topic - }, - 200 - - assert_receive %Phoenix.Socket.Message{event: "presence_state", payload: %{}, topic: ^topic}, 500 - - assert_receive %Message{ - event: "system", - payload: %{ - "channel" => "any", - "extension" => "postgres_changes", - "message" => "Subscribed to PostgreSQL", - "status" => "ok" - }, - ref: nil, - topic: ^topic - }, - 8000 - - {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) - %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) - - assert_receive %Message{ - event: "postgres_changes", - payload: %{ - "data" => %{ - "columns" => [ - %{"name" => "id", "type" => "int4"}, - %{"name" => "details", "type" => "text"} - ], - "commit_timestamp" => _ts, - "errors" => nil, - "record" => %{"details" => "test", "id" => ^id}, - "schema" => "public", - "table" => "test", - "type" => "INSERT" - }, - "ids" => [^sub_id] - }, - ref: nil, - topic: "realtime:any" - }, - 500 - end - - test "handle update", %{tenant: tenant} do - {socket, _} = get_connection(tenant) - topic = "realtime:any" - config = %{postgres_changes: [%{event: "UPDATE", schema: "public"}]} - - WebsocketClient.join(socket, topic, %{config: config}) - sub_id = :erlang.phash2(%{"event" => "UPDATE", "schema" => "public"}) - - assert_receive %Message{ - event: "phx_reply", - payload: %{ - "response" => %{ - "postgres_changes" => [ - %{"event" => "UPDATE", "id" => ^sub_id, "schema" => "public"} - ] - }, - "status" => "ok" - }, - ref: "1", - topic: ^topic - }, - 200 - - assert_receive %Phoenix.Socket.Message{event: "presence_state", payload: %{}, topic: ^topic}, 500 - - assert_receive %Message{ - event: "system", - payload: %{ - "channel" => "any", - "extension" => "postgres_changes", - "message" => "Subscribed to PostgreSQL", - "status" => "ok" - }, - ref: nil, - topic: ^topic - }, - 8000 - - {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) - %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) - - Postgrex.query!(conn, "update test set details = 'test' where id = #{id}", []) - - assert_receive %Message{ - event: "postgres_changes", - payload: %{ - "data" => %{ - "columns" => [ - %{"name" => "id", "type" => "int4"}, - %{"name" => "details", "type" => "text"} - ], - "commit_timestamp" => _ts, - "errors" => nil, - "old_record" => %{"id" => ^id}, - "record" => %{"details" => "test", "id" => ^id}, - "schema" => "public", - "table" => "test", - "type" => "UPDATE" - }, - "ids" => [^sub_id] - }, - ref: nil, - topic: "realtime:any" - }, - 500 - end - - test "handle delete", %{tenant: tenant} do - {socket, _} = get_connection(tenant) - topic = "realtime:any" - config = %{postgres_changes: [%{event: "DELETE", schema: "public"}]} - - WebsocketClient.join(socket, topic, %{config: config}) - sub_id = :erlang.phash2(%{"event" => "DELETE", "schema" => "public"}) - - assert_receive %Message{ - event: "phx_reply", - payload: %{ - "response" => %{ - "postgres_changes" => [ - %{"event" => "DELETE", "id" => ^sub_id, "schema" => "public"} - ] - }, - "status" => "ok" - }, - ref: "1", - topic: ^topic - }, - 200 - - assert_receive %Phoenix.Socket.Message{event: "presence_state", payload: %{}, topic: ^topic}, 500 - - assert_receive %Message{ - event: "system", - payload: %{ - "channel" => "any", - "extension" => "postgres_changes", - "message" => "Subscribed to PostgreSQL", - "status" => "ok" - }, - ref: nil, - topic: ^topic - }, - 8000 - - {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) - %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) - Postgrex.query!(conn, "delete from test where id = #{id}", []) - - assert_receive %Message{ - event: "postgres_changes", - payload: %{ - "data" => %{ - "columns" => [ - %{"name" => "id", "type" => "int4"}, - %{"name" => "details", "type" => "text"} - ], - "commit_timestamp" => _ts, - "errors" => nil, - "old_record" => %{"id" => ^id}, - "schema" => "public", - "table" => "test", - "type" => "DELETE" - }, - "ids" => [^sub_id] - }, - ref: nil, - topic: "realtime:any" - }, - 500 - end - - test "handle wildcard", %{tenant: tenant} do - {socket, _} = get_connection(tenant) - topic = "realtime:any" - config = %{postgres_changes: [%{event: "*", schema: "public"}]} - - WebsocketClient.join(socket, topic, %{config: config}) - sub_id = :erlang.phash2(%{"event" => "*", "schema" => "public"}) - - assert_receive %Message{ - event: "phx_reply", - payload: %{ - "response" => %{ - "postgres_changes" => [ - %{"event" => "*", "id" => ^sub_id, "schema" => "public"} - ] - }, - "status" => "ok" - }, - ref: "1", - topic: ^topic - }, - 200 - - assert_receive %Phoenix.Socket.Message{event: "presence_state", payload: %{}, topic: ^topic}, 500 - - assert_receive %Message{ - event: "system", - payload: %{ - "channel" => "any", - "extension" => "postgres_changes", - "message" => "Subscribed to PostgreSQL", - "status" => "ok" - }, - ref: nil, - topic: ^topic - }, - 8000 - - {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) - %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) - - assert_receive %Message{ - event: "postgres_changes", - payload: %{ - "data" => %{ - "columns" => [ - %{"name" => "id", "type" => "int4"}, - %{"name" => "details", "type" => "text"} - ], - "commit_timestamp" => _ts, - "errors" => nil, - "record" => %{"id" => ^id}, - "schema" => "public", - "table" => "test", - "type" => "INSERT" - }, - "ids" => [^sub_id] - }, - ref: nil, - topic: "realtime:any" - }, - 500 - - Postgrex.query!(conn, "update test set details = 'test' where id = #{id}", []) - - assert_receive %Message{ - event: "postgres_changes", - payload: %{ - "data" => %{ - "columns" => [ - %{"name" => "id", "type" => "int4"}, - %{"name" => "details", "type" => "text"} - ], - "commit_timestamp" => _ts, - "errors" => nil, - "old_record" => %{"id" => ^id}, - "record" => %{"details" => "test", "id" => ^id}, - "schema" => "public", - "table" => "test", - "type" => "UPDATE" - }, - "ids" => [^sub_id] - }, - ref: nil, - topic: "realtime:any" - }, - 500 - - Postgrex.query!(conn, "delete from test where id = #{id}", []) - - assert_receive %Message{ - event: "postgres_changes", - payload: %{ - "data" => %{ - "columns" => [ - %{"name" => "id", "type" => "int4"}, - %{"name" => "details", "type" => "text"} - ], - "commit_timestamp" => _ts, - "errors" => nil, - "old_record" => %{"id" => ^id}, - "schema" => "public", - "table" => "test", - "type" => "DELETE" - }, - "ids" => [^sub_id] - }, - ref: nil, - topic: "realtime:any" - }, - 500 - end - - test "handle nil postgres changes params as empty param changes", %{tenant: tenant} do - {socket, _} = get_connection(tenant) - topic = "realtime:any" - config = %{postgres_changes: [nil]} - - WebsocketClient.join(socket, topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 200 - assert_receive %Phoenix.Socket.Message{event: "presence_state", payload: %{}, topic: ^topic}, 500 - - refute_receive %Message{ - event: "system", - payload: %{ - "channel" => "any", - "extension" => "postgres_changes", - "message" => "Subscribed to PostgreSQL", - "status" => "ok" - }, - ref: nil, - topic: ^topic - }, - 1000 - end - end - - describe "handle broadcast extension" do - setup [:rls_context] - - test "public broadcast", %{tenant: tenant} do - {socket, _} = get_connection(tenant) - config = %{broadcast: %{self: true}, private: false} - topic = "realtime:any" - WebsocketClient.join(socket, topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{event: "presence_state"} - - payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} - WebsocketClient.send_event(socket, topic, "broadcast", payload) - - assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 - end - - test "broadcast to another tenant does not get mixed up", %{tenant: tenant} do - {socket, _} = get_connection(tenant) - config = %{broadcast: %{self: false}, private: false} - topic = "realtime:any" - WebsocketClient.join(socket, topic, %{config: config}) - - other_tenant = Containers.checkout_tenant(run_migrations: true) - - {other_socket, _} = get_connection(other_tenant) - WebsocketClient.join(other_socket, topic, %{config: config}) - - # Both sockets joined - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{event: "presence_state"} - assert_receive %Message{event: "presence_state"} - - payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} - WebsocketClient.send_event(socket, topic, "broadcast", payload) - - # No message received - refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] - test "private broadcast with valid channel with permissions sends message", %{tenant: tenant, topic: topic} do - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: true} - topic = "realtime:#{topic}" - WebsocketClient.join(socket, topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{event: "presence_state"} - - payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} - WebsocketClient.send_event(socket, topic, "broadcast", payload) - - assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic} - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence], - mode: :distributed - test "private broadcast with valid channel with permissions sends message using a remote node (phoenix adapter)", %{ - tenant: tenant, - topic: topic - } do - {:ok, token} = - generate_token(tenant, %{exp: System.system_time(:second) + 1000, role: "authenticated", sub: random_string()}) - - {:ok, remote_socket} = WebsocketClient.connect(self(), uri(tenant, 4012), @serializer, [{"x-api-key", token}]) - {:ok, socket} = WebsocketClient.connect(self(), uri(tenant), @serializer, [{"x-api-key", token}]) - - config = %{broadcast: %{self: false}, private: true} - topic = "realtime:#{topic}" - - WebsocketClient.join(remote_socket, topic, %{config: config}) - WebsocketClient.join(socket, topic, %{config: config}) - - # Send through one socket and receive through the other (self: false) - payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} - WebsocketClient.send_event(socket, topic, "broadcast", payload) - - assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence], - mode: :distributed - test "private broadcast with valid channel with permissions sends message using a remote node", %{ - tenant: tenant, - topic: topic - } do - {:ok, token} = - generate_token(tenant, %{exp: System.system_time(:second) + 1000, role: "authenticated", sub: random_string()}) - - {:ok, remote_socket} = WebsocketClient.connect(self(), uri(tenant, 4012), @serializer, [{"x-api-key", token}]) - {:ok, socket} = WebsocketClient.connect(self(), uri(tenant), @serializer, [{"x-api-key", token}]) - - config = %{broadcast: %{self: false}, private: true} - topic = "realtime:#{topic}" - - WebsocketClient.join(remote_socket, topic, %{config: config}) - WebsocketClient.join(socket, topic, %{config: config}) - - # Send through one socket and receive through the other (self: false) - payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} - WebsocketClient.send_event(socket, topic, "broadcast", payload) - assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence], - topic: "topic" - test "private broadcast with valid channel a colon character sends message and won't intercept in public channels", - %{topic: topic, tenant: tenant} do - {anon_socket, _} = get_connection(tenant, "anon") - {socket, _} = get_connection(tenant, "authenticated") - valid_topic = "realtime:#{topic}" - malicious_topic = "realtime:private:#{topic}" - - WebsocketClient.join(socket, valid_topic, %{config: %{broadcast: %{self: true}, private: true}}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^valid_topic}, 300 - assert_receive %Message{event: "presence_state"} - - WebsocketClient.join(anon_socket, malicious_topic, %{config: %{broadcast: %{self: true}, private: false}}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^malicious_topic}, 300 - assert_receive %Message{event: "presence_state"} - - payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} - WebsocketClient.send_event(socket, valid_topic, "broadcast", payload) - - assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^valid_topic}, 500 - refute_receive %Message{event: "broadcast"} - end - - @tag policies: [:authenticated_read_broadcast_and_presence] - test "private broadcast with valid channel no write permissions won't send message but will receive message", %{ - tenant: tenant, - topic: topic - } do - config = %{broadcast: %{self: true}, private: true} - topic = "realtime:#{topic}" - - {service_role_socket, _} = get_connection(tenant, "service_role") - WebsocketClient.join(service_role_socket, topic, %{config: config}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{event: "presence_state"} - - {socket, _} = get_connection(tenant, "authenticated") - WebsocketClient.join(socket, topic, %{config: config}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{event: "presence_state"} - - payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} - - WebsocketClient.send_event(socket, topic, "broadcast", payload) - refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 - - WebsocketClient.send_event(service_role_socket, topic, "broadcast", payload) - assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 - assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 - end - - @tag policies: [] - test "private broadcast with valid channel and no read permissions won't join", %{tenant: tenant, topic: topic} do - config = %{private: true} - expected = "Unauthorized: You do not have permissions to read from this Channel topic: #{topic}" - - topic = "realtime:#{topic}" - {socket, _} = get_connection(tenant, "authenticated") - - log = - capture_log(fn -> - WebsocketClient.join(socket, topic, %{config: config}) - - assert_receive %Message{ - topic: ^topic, - event: "phx_reply", - payload: %{ - "response" => %{ - "reason" => ^expected - }, - "status" => "error" - } - }, - 300 - - refute_receive %Message{event: "phx_reply", topic: ^topic}, 300 - refute_receive %Message{event: "presence_state"}, 300 - end) - - assert log =~ expected - end - - @tag policies: [:authenticated_read_broadcast_and_presence] - test "handles lack of connection to database error on private channels", %{tenant: tenant, topic: topic} do - topic = "realtime:#{topic}" - {socket, _} = get_connection(tenant, "authenticated") - WebsocketClient.join(socket, topic, %{config: %{broadcast: %{self: true}, private: true}}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{event: "presence_state"} - - {service_role_socket, _} = get_connection(tenant, "service_role") - WebsocketClient.join(service_role_socket, topic, %{config: %{broadcast: %{self: false}, private: true}}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{event: "presence_state"} - - log = - capture_log(fn -> - :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end) - payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} - WebsocketClient.send_event(service_role_socket, topic, "broadcast", payload) - # Waiting more than 5 seconds as this is the amount of time we will wait for the Connection to be ready - refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 6000 - end) - - assert log =~ "UnableToHandleBroadcast" - end - - @tag policies: [] - test "lack of connection to database error does not impact public channels", %{tenant: tenant, topic: topic} do - topic = "realtime:#{topic}" - {socket, _} = get_connection(tenant, "authenticated") - WebsocketClient.join(socket, topic, %{config: %{broadcast: %{self: true}, private: false}}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{event: "presence_state"} - - {service_role_socket, _} = get_connection(tenant, "service_role") - WebsocketClient.join(service_role_socket, topic, %{config: %{broadcast: %{self: false}, private: false}}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{event: "presence_state"} - - log = - capture_log(fn -> - :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end) - payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} - WebsocketClient.send_event(service_role_socket, topic, "broadcast", payload) - assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500 - end) - - refute log =~ "UnableToHandleBroadcast" - end - end - - describe "handle presence extension" do - setup [:rls_context] - - test "public presence", %{tenant: tenant} do - {socket, _} = get_connection(tenant) - config = %{presence: %{key: "", enabled: true}, private: false} - topic = "realtime:any" - - WebsocketClient.join(socket, topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{event: "presence_state", payload: %{}, topic: ^topic}, 500 - - payload = %{ - type: "presence", - event: "TRACK", - payload: %{name: "realtime_presence_96", t: 1814.7000000029802} - } - - WebsocketClient.send_event(socket, topic, "presence", payload) - - assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic} - - join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd() - assert get_in(join_payload, ["name"]) == payload.payload.name - assert get_in(join_payload, ["t"]) == payload.payload.t - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] - test "private presence with read and write permissions will be able to track and receive presence changes", - %{tenant: tenant, topic: topic} do - {socket, _} = get_connection(tenant, "authenticated") - config = %{presence: %{key: "", enabled: true}, private: true} - topic = "realtime:#{topic}" - - WebsocketClient.join(socket, topic, %{config: config}) - assert_receive %Message{event: "presence_state", payload: %{}, topic: ^topic}, 500 - - payload = %{ - type: "presence", - event: "TRACK", - payload: %{name: "realtime_presence_96", t: 1814.7000000029802} - } - - WebsocketClient.send_event(socket, topic, "presence", payload) - refute_receive %Message{event: "phx_leave", topic: ^topic} - assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}, 500 - join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd() - assert get_in(join_payload, ["name"]) == payload.payload.name - assert get_in(join_payload, ["t"]) == payload.payload.t - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence], - mode: :distributed - test "private presence with read and write permissions will be able to track and receive presence changes using a remote node", - %{tenant: tenant, topic: topic} do - {socket, _} = get_connection(tenant, "authenticated") - config = %{presence: %{key: "", enabled: true}, private: true} - topic = "realtime:#{topic}" - - WebsocketClient.join(socket, topic, %{config: config}) - assert_receive %Message{event: "presence_state", payload: %{}, topic: ^topic}, 500 - - payload = %{ - type: "presence", - event: "TRACK", - payload: %{name: "realtime_presence_96", t: 1814.7000000029802} - } - - WebsocketClient.send_event(socket, topic, "presence", payload) - refute_receive %Message{event: "phx_leave", topic: ^topic} - assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}, 500 - join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd() - assert get_in(join_payload, ["name"]) == payload.payload.name - assert get_in(join_payload, ["t"]) == payload.payload.t - end - - @tag policies: [:authenticated_read_broadcast_and_presence] - test "private presence with read permissions will be able to receive presence changes but won't be able to track", - %{tenant: tenant, topic: topic} do - {socket, _} = get_connection(tenant, "authenticated") - {secondary_socket, _} = get_connection(tenant, "service_role") - config = fn key -> %{presence: %{key: key, enabled: true}, private: true} end - topic = "realtime:#{topic}" - - WebsocketClient.join(socket, topic, %{config: config.("authenticated")}) - - payload = %{ - type: "presence", - event: "TRACK", - payload: %{name: "realtime_presence_96", t: 1814.7000000029802} - } - - # This will be ignored - WebsocketClient.send_event(socket, topic, "presence", payload) - - assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state", payload: %{}, ref: nil, topic: ^topic} - refute_receive %Message{event: "presence_diff", payload: _, ref: _, topic: ^topic} - - payload = %{ - type: "presence", - event: "TRACK", - payload: %{name: "realtime_presence_97", t: 1814.7000000029802} - } - - # This will be tracked - WebsocketClient.join(secondary_socket, topic, %{config: config.("service_role")}) - WebsocketClient.send_event(secondary_socket, topic, "presence", payload) - - assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{topic: ^topic, event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}} - assert_receive %Message{event: "presence_state", payload: %{}, ref: nil, topic: ^topic} - - join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd() - assert get_in(join_payload, ["name"]) == payload.payload.name - assert get_in(join_payload, ["t"]) == payload.payload.t - - assert_receive %Message{topic: ^topic, event: "presence_diff"} = res - - assert join_payload = - res - |> Map.from_struct() - |> get_in([:payload, "joins", "service_role", "metas"]) - |> hd() - - assert get_in(join_payload, ["name"]) == payload.payload.name - assert get_in(join_payload, ["t"]) == payload.payload.t - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] - test "handles lack of connection to database error on private channels", %{tenant: tenant, topic: topic} do - topic = "realtime:#{topic}" - {socket, _} = get_connection(tenant, "authenticated") - WebsocketClient.join(socket, topic, %{config: %{private: true, presence: %{enabled: true}}}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{event: "presence_state"} - - log = - capture_log(fn -> - :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end) - payload = %{type: "presence", event: "TRACK", payload: %{name: "realtime_presence_96", t: 1814.7000000029802}} - WebsocketClient.send_event(socket, topic, "presence", payload) - - refute_receive %Message{event: "presence_diff"}, 500 - # Waiting more than 5 seconds as this is the amount of time we will wait for the Connection to be ready - refute_receive %Message{event: "phx_leave", topic: ^topic}, 6000 - end) - - assert log =~ "UnableToHandlePresence" - end - - @tag policies: [] - test "lack of connection to database error does not impact public channels", %{tenant: tenant, topic: topic} do - topic = "realtime:#{topic}" - {socket, _} = get_connection(tenant, "authenticated") - WebsocketClient.join(socket, topic, %{config: %{private: false, presence: %{enabled: true}}}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{event: "presence_state"} - - log = - capture_log(fn -> - :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end) - payload = %{type: "presence", event: "TRACK", payload: %{name: "realtime_presence_96", t: 1814.7000000029802}} - WebsocketClient.send_event(socket, topic, "presence", payload) - - assert_receive %Message{event: "presence_diff"}, 500 - refute_receive %Message{event: "phx_leave", topic: ^topic} - end) - - refute log =~ "UnableToHandlePresence" - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] - - test "presence enabled if param enabled is set in configuration for private channels", %{ - tenant: tenant, - topic: topic - } do - {socket, _} = get_connection(tenant, "authenticated") - topic = "realtime:#{topic}" - - WebsocketClient.join(socket, topic, %{config: %{private: true, presence: %{enabled: true}}}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] - - test "presence disabled if param 'enabled' is set to false in configuration for private channels", %{ - tenant: tenant, - topic: topic - } do - {socket, _} = get_connection(tenant, "authenticated") - topic = "realtime:#{topic}" - - WebsocketClient.join(socket, topic, %{config: %{private: true, presence: %{enabled: false}}}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - refute_receive %Message{event: "presence_state"}, 500 - end - - test "presence enabled if param enabled is set in configuration for public channels", %{ - tenant: tenant, - topic: topic - } do - {socket, _} = get_connection(tenant, "authenticated") - topic = "realtime:#{topic}" - - WebsocketClient.join(socket, topic, %{config: %{private: false, presence: %{enabled: true}}}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - end - - test "presence disabled if param 'enabled' is set to false in configuration for public channels", %{ - tenant: tenant, - topic: topic - } do - {socket, _} = get_connection(tenant, "authenticated") - topic = "realtime:#{topic}" - - WebsocketClient.join(socket, topic, %{config: %{private: false, presence: %{enabled: false}}}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - refute_receive %Message{event: "presence_state"}, 500 - end - end - - describe "token handling" do - setup [:rls_context] - - @tag policies: [ - :authenticated_read_broadcast_and_presence, - :authenticated_write_broadcast_and_presence - ] - test "badly formatted jwt token", %{tenant: tenant} do - log = - capture_log(fn -> - WebsocketClient.connect(self(), uri(tenant), @serializer, [{"x-api-key", "bad_token"}]) - end) - - assert log =~ "MalformedJWT: The token provided is not a valid JWT" - end - - test "invalid JWT with expired token", %{tenant: tenant} do - log = - capture_log(fn -> - get_connection(tenant, "authenticated", %{:exp => System.system_time(:second) - 1000}, %{log_level: :info}) - end) - - assert log =~ "InvalidJWTToken: Token has expired" - end - - test "token required the role key", %{tenant: tenant} do - {:ok, token} = token_no_role(tenant) - - assert {:error, %{status_code: 403}} = - WebsocketClient.connect(self(), uri(tenant), @serializer, [{"x-api-key", token}]) - end - - test "handles connection with valid api-header but ignorable access_token payload", %{tenant: tenant, topic: topic} do - realtime_topic = "realtime:#{topic}" - - log = - capture_log(fn -> - {:ok, token} = - generate_token(tenant, %{ - exp: System.system_time(:second) + 1000, - role: "authenticated", - sub: random_string() - }) - - {:ok, socket} = WebsocketClient.connect(self(), uri(tenant), @serializer, [{"x-api-key", token}]) - - WebsocketClient.join(socket, realtime_topic, %{ - config: %{broadcast: %{self: true}, private: false}, - access_token: "sb_#{random_string()}" - }) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - end) - - refute log =~ "MalformedJWT: The token provided is not a valid JWT" - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] - test "on new access_token and channel is private policies are reevaluated for read policy", - %{tenant: tenant, topic: topic} do - {socket, access_token} = get_connection(tenant, "authenticated") - - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{ - config: %{broadcast: %{self: true}, private: true}, - access_token: access_token - }) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - {:ok, new_token} = token_valid(tenant, "anon") - - WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => new_token}) - - error_message = "You do not have permissions to read from this Channel topic: #{topic}" - - assert_receive %Message{ - event: "system", - payload: %{"channel" => ^topic, "extension" => "system", "message" => ^error_message, "status" => "error"}, - topic: ^realtime_topic - } - - assert_receive %Message{event: "phx_close", topic: ^realtime_topic} - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] - test "on new access_token and channel is private policies are reevaluated for write policy", %{ - topic: topic, - tenant: tenant - } do - {socket, access_token} = get_connection(tenant, "authenticated") - realtime_topic = "realtime:#{topic}" - config = %{broadcast: %{self: true}, private: true} - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - # Checks first send which will set write policy to true - payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} - WebsocketClient.send_event(socket, realtime_topic, "broadcast", payload) - - assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^realtime_topic}, 500 - - # RLS policies changed to only allow read - {:ok, db_conn} = Database.connect(tenant, "realtime_test") - clean_table(db_conn, "realtime", "messages") - create_rls_policies(db_conn, [:authenticated_read_broadcast_and_presence], %{topic: topic}) - - # Set new token to recheck policies - {:ok, new_token} = - generate_token(tenant, %{exp: System.system_time(:second) + 1000, role: "authenticated", sub: random_string()}) - - WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => new_token}) - - # Send message to be ignored - payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} - WebsocketClient.send_event(socket, realtime_topic, "broadcast", payload) - - refute_receive %Message{ - event: "broadcast", - payload: ^payload, - topic: ^realtime_topic - }, - 1500 - end - - test "on new access_token and channel is public policies are not reevaluated", %{tenant: tenant, topic: topic} do - {socket, access_token} = get_connection(tenant, "authenticated") - {:ok, new_token} = token_valid(tenant, "anon") - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => new_token}) - - refute_receive %Message{} - end - - test "on empty string access_token the socket sends an error message", %{tenant: tenant, topic: topic} do - {socket, access_token} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => ""}) - - assert_receive %Message{ - topic: ^realtime_topic, - event: "system", - payload: %{ - "extension" => "system", - "message" => msg, - "status" => "error" - } - } - - assert_receive %Message{event: "phx_close"} - assert msg =~ "The token provided is not a valid JWT" - end - - test "on expired access_token the socket sends an error message", %{tenant: tenant, topic: topic} do - sub = random_string() - - {socket, access_token} = get_connection(tenant, "authenticated", %{sub: sub}) - - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) - 1000, sub: sub}) - - log = - capture_log([log_level: :warning], fn -> - WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token}) - - assert_receive %Message{ - topic: ^realtime_topic, - event: "system", - payload: %{"extension" => "system", "message" => "Token has expired 1000 seconds ago", "status" => "error"} - } - end) - - assert log =~ "ChannelShutdown: Token has expired 1000 seconds ago" - end - - test "ChannelShutdown include sub if available in jwt claims", %{tenant: tenant, topic: topic} do - exp = System.system_time(:second) + 10_000 - - {socket, access_token} = get_connection(tenant, "authenticated", %{exp: exp}, %{log_level: :warning}) - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - sub = random_string() - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) - 1000, sub: sub}) - - log = - capture_log([level: :warning], fn -> - WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token}) - - assert_receive %Message{event: "system"}, 1000 - end) - - assert log =~ "ChannelShutdown" - assert log =~ "sub=#{sub}" - end - - test "missing claims close connection", %{tenant: tenant, topic: topic} do - {socket, access_token} = get_connection(tenant, "authenticated") - - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) + 2000}) - - # Update token to be a near expiring token - WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token}) - - assert_receive %Message{ - event: "system", - payload: %{ - "extension" => "system", - "message" => "Fields `role` and `exp` are required in JWT", - "status" => "error" - } - }, - 500 - - assert_receive %Message{event: "phx_close"} - end - - test "checks token periodically", %{tenant: tenant, topic: topic} do - {socket, access_token} = get_connection(tenant, "authenticated") - - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) + 2, role: "authenticated"}) - - # Update token to be a near expiring token - WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token}) - - # Awaits to see if connection closes automatically - assert_receive %Message{ - event: "system", - payload: %{"extension" => "system", "message" => msg, "status" => "error"} - }, - 3000 - - assert_receive %Message{event: "phx_close"} - - assert msg =~ "Token has expired" - end - - test "token expires in between joins", %{tenant: tenant, topic: topic} do - {socket, access_token} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - {:ok, access_token} = generate_token(tenant, %{:exp => System.system_time(:second) + 1, role: "authenticated"}) - - # token expires in between joins so it needs to be handled by the channel and not the socket - Process.sleep(1000) - realtime_topic = "realtime:#{topic}" - - log = - capture_log(fn -> - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - - assert_receive %Message{ - event: "phx_reply", - payload: %{ - "status" => "error", - "response" => %{"reason" => "InvalidJWTToken: Token has expired 0 seconds ago"} - }, - topic: ^realtime_topic - }, - 500 - end) - - assert_receive %Message{event: "phx_close"} - assert log =~ "#{tenant.external_id}" - end - - test "token loses claims in between joins", %{tenant: tenant, topic: topic} do - {socket, access_token} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - {:ok, access_token} = generate_token(tenant, %{:exp => System.system_time(:second) + 10}) - - # token breaks claims in between joins so it needs to be handled by the channel and not the socket - realtime_topic = "realtime:#{topic}" - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - - assert_receive %Message{ - event: "phx_reply", - payload: %{ - "status" => "error", - "response" => %{ - "reason" => "InvalidJWTToken: Fields `role` and `exp` are required in JWT" - } - }, - topic: ^realtime_topic - }, - 500 - - assert_receive %Message{event: "phx_close"} - end - - test "token is badly formatted in between joins", %{tenant: tenant, topic: topic} do - {socket, access_token} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - # token becomes a string in between joins so it needs to be handled by the channel and not the socket - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: "potato"}) - - assert_receive %Message{ - event: "phx_reply", - payload: %{ - "status" => "error", - "response" => %{ - "reason" => "MalformedJWT: The token provided is not a valid JWT" - } - }, - topic: ^realtime_topic - }, - 500 - - assert_receive %Message{event: "phx_close"} - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] - test "handles RPC error on token refreshed", %{tenant: tenant, topic: topic} do - Authorization - |> expect(:get_read_authorizations, fn conn, db_conn, context -> - call_original(Authorization, :get_read_authorizations, [conn, db_conn, context]) - end) - |> expect(:get_read_authorizations, fn _, _, _ -> {:error, "RPC Error"} end) - - {socket, access_token} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: true} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - - assert_receive %Phoenix.Socket.Message{event: "phx_reply"}, 500 - assert_receive %Phoenix.Socket.Message{event: "presence_state"}, 500 - - # Update token to force update - {:ok, access_token} = - generate_token(tenant, %{:exp => System.system_time(:second) + 1000, role: "authenticated"}) - - log = - capture_log([log_level: :warning], fn -> - WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => access_token}) - - assert_receive %Phoenix.Socket.Message{ - event: "system", - payload: %{ - "status" => "error", - "extension" => "system", - "message" => "Realtime was unable to connect to the project database" - }, - topic: ^realtime_topic - }, - 500 - - assert_receive %Phoenix.Socket.Message{event: "phx_close", topic: ^realtime_topic} - end) - - assert log =~ "Realtime was unable to connect to the project database" - end - - test "on sb prefixed access_token the socket ignores the message and respects JWT expiry time", %{ - tenant: tenant, - topic: topic - } do - sub = random_string() - - {socket, access_token} = - get_connection(tenant, "authenticated", %{sub: sub, exp: System.system_time(:second) + 5}) - - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - WebsocketClient.send_event(socket, realtime_topic, "access_token", %{ - "access_token" => "sb_publishable_-fake_key" - }) - - # Check if the new token does not trigger a shutdown - refute_receive %Message{event: "system", topic: ^realtime_topic}, 100 - - # Await to check if channel respects token expiry time - assert_receive %Message{ - event: "system", - payload: %{"extension" => "system", "message" => msg, "status" => "error"}, - topic: ^realtime_topic - }, - 5000 - - assert_receive %Message{event: "phx_close", topic: ^realtime_topic} - msg =~ "Token has expired" - end - end - - describe "handle broadcast changes" do - setup [:rls_context, :setup_trigger] - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] - test "broadcast insert event changes on insert in table with trigger", %{ - tenant: tenant, - topic: topic, - db_conn: db_conn, - table_name: table_name - } do - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: true} - topic = "realtime:#{topic}" - - WebsocketClient.join(socket, topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - value = random_string() - Postgrex.query!(db_conn, "INSERT INTO #{table_name} (details) VALUES ($1)", [value]) - - record = %{"details" => value, "id" => 1} - - assert_receive %Message{ - event: "broadcast", - payload: %{ - "event" => "INSERT", - "payload" => %{ - "old_record" => nil, - "operation" => "INSERT", - "record" => ^record, - "schema" => "public", - "table" => ^table_name - }, - "type" => "broadcast" - }, - topic: ^topic - }, - 1000 - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence], - requires_data: true - test "broadcast update event changes on update in table with trigger", %{ - tenant: tenant, - topic: topic, - db_conn: db_conn, - table_name: table_name - } do - value = random_string() - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: true} - topic = "realtime:#{topic}" - - WebsocketClient.join(socket, topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - new_value = random_string() - - Postgrex.query!(db_conn, "INSERT INTO #{table_name} (details) VALUES ($1)", [value]) - Postgrex.query!(db_conn, "UPDATE #{table_name} SET details = $1 WHERE details = $2", [new_value, value]) - - old_record = %{"details" => value, "id" => 1} - record = %{"details" => new_value, "id" => 1} - - assert_receive %Message{ - event: "broadcast", - payload: %{ - "event" => "UPDATE", - "payload" => %{ - "old_record" => ^old_record, - "operation" => "UPDATE", - "record" => ^record, - "schema" => "public", - "table" => ^table_name - }, - "type" => "broadcast" - }, - topic: ^topic - }, - 1000 - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] - test "broadcast delete event changes on delete in table with trigger", %{ - tenant: tenant, - topic: topic, - db_conn: db_conn, - table_name: table_name - } do - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: true} - topic = "realtime:#{topic}" - - WebsocketClient.join(socket, topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - value = random_string() - - Postgrex.query!(db_conn, "INSERT INTO #{table_name} (details) VALUES ($1)", [value]) - Postgrex.query!(db_conn, "DELETE FROM #{table_name} WHERE details = $1", [value]) - - record = %{"details" => value, "id" => 1} - - assert_receive %Message{ - event: "broadcast", - payload: %{ - "event" => "DELETE", - "payload" => %{ - "old_record" => ^record, - "operation" => "DELETE", - "record" => nil, - "schema" => "public", - "table" => ^table_name - }, - "type" => "broadcast" - }, - topic: ^topic - }, - 1000 - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] - test "broadcast event when function 'send' is called with private topic", %{ - tenant: tenant, - topic: topic, - db_conn: db_conn - } do - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: true} - full_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, full_topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - value = random_string() - event = random_string() - - Postgrex.query!( - db_conn, - "SELECT realtime.send (json_build_object ('value', $1 :: text)::jsonb, $2 :: text, $3 :: text, TRUE::bool);", - [value, event, topic] - ) - - assert_receive %Message{ - event: "broadcast", - payload: %{ - "event" => ^event, - "payload" => %{"value" => ^value}, - "type" => "broadcast" - }, - topic: ^full_topic, - join_ref: nil, - ref: nil - }, - 1000 - end - - test "broadcast event when function 'send' is called with public topic", %{ - tenant: tenant, - topic: topic, - db_conn: db_conn - } do - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - full_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, full_topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - value = random_string() - event = random_string() - - Postgrex.query!( - db_conn, - "SELECT realtime.send (json_build_object ('value', $1 :: text)::jsonb, $2 :: text, $3 :: text, FALSE::bool);", - [value, event, topic] - ) - - assert_receive %Message{ - event: "broadcast", - payload: %{ - "event" => ^event, - "payload" => %{"value" => ^value}, - "type" => "broadcast" - }, - topic: ^full_topic - }, - 1000 - end - end - - describe "only private channels" do - setup [:rls_context] - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] - test "user with only private channels enabled will not be able to join public channels", %{ - tenant: tenant, - topic: topic - } do - change_tenant_configuration(tenant, :private_only, true) - on_exit(fn -> change_tenant_configuration(tenant, :private_only, false) end) - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - topic = "realtime:#{topic}" - - WebsocketClient.join(socket, topic, %{config: config}) - - assert_receive %Message{ - event: "phx_reply", - payload: %{ - "response" => %{ - "reason" => "PrivateOnly: This project only allows private channels" - }, - "status" => "error" - } - }, - 500 - end - - @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] - test "user with only private channels enabled will be able to join private channels", %{ - tenant: tenant, - topic: topic - } do - change_tenant_configuration(tenant, :private_only, true) - on_exit(fn -> change_tenant_configuration(tenant, :private_only, false) end) - - Process.sleep(100) - - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: true} - topic = "realtime:#{topic}" - WebsocketClient.join(socket, topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - end - end - - describe "socket disconnect" do - setup [:rls_context] - - test "tenant already suspended", %{topic: _topic} do - tenant = Containers.checkout_tenant(run_migrations: true) - - log = - capture_log(fn -> - {:ok, _} = Realtime.Api.update_tenant(tenant, %{suspend: true}) - {:error, %Mint.WebSocket.UpgradeFailureError{}} = get_connection(tenant, "anon") - refute_receive _any - end) - - assert log =~ "RealtimeDisabledForTenant" - end - - test "on jwks the socket closes and sends a system message", %{tenant: tenant, topic: topic} do - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - tenant = Tenants.get_tenant_by_external_id(tenant.external_id) - Realtime.Api.update_tenant(tenant, %{jwt_jwks: %{keys: ["potato"]}}) - - assert_process_down(socket) - end - - test "on jwt_secret the socket closes and sends a system message", %{tenant: tenant, topic: topic} do - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - tenant = Tenants.get_tenant_by_external_id(tenant.external_id) - Realtime.Api.update_tenant(tenant, %{jwt_secret: "potato"}) - - assert_process_down(socket) - end - - test "on private_only the socket closes and sends a system message", %{tenant: tenant, topic: topic} do - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - tenant = Tenants.get_tenant_by_external_id(tenant.external_id) - Realtime.Api.update_tenant(tenant, %{private_only: true}) - - assert_process_down(socket) - end - - test "on other param changes the socket won't close and no message is sent", %{tenant: tenant, topic: topic} do - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert_receive %Message{event: "presence_state"}, 500 - - tenant = Tenants.get_tenant_by_external_id(tenant.external_id) - Realtime.Api.update_tenant(tenant, %{max_concurrent_users: 100}) - - refute_receive %Message{ - topic: ^realtime_topic, - event: "system", - payload: %{ - "extension" => "system", - "message" => "Server requested disconnect", - "status" => "ok" - } - }, - 500 - - Process.sleep(500) - assert :ok = WebsocketClient.send_heartbeat(socket) - end - - test "invalid JWT with expired token", %{tenant: tenant} do - log = - capture_log(fn -> - get_connection(tenant, "authenticated", %{:exp => System.system_time(:second) - 1000}, %{log_level: :info}) - end) - - assert log =~ "InvalidJWTToken: Token has expired" - end - - test "check registry of SocketDisconnect and on distribution called, kill socket", %{tenant: tenant} do - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - - for _ <- 1..10 do - topic = "realtime:#{random_string()}" - WebsocketClient.join(socket, topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 500 - assert_receive %Message{event: "presence_state", topic: ^topic}, 500 - end - - assert :ok = WebsocketClient.send_heartbeat(socket) - - SocketDisconnect.distributed_disconnect(tenant) - - assert_process_down(socket) - end - end - - describe "rate limits" do - setup [:rls_context] - - test "max_concurrent_users limit respected", %{tenant: tenant} do - %{max_concurrent_users: max_concurrent_users} = Tenants.get_tenant_by_external_id(tenant.external_id) - change_tenant_configuration(tenant, :max_concurrent_users, 1) - - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{random_string()}" - WebsocketClient.join(socket, realtime_topic, %{config: config}) - WebsocketClient.join(socket, realtime_topic, %{config: config}) - - assert_receive %Message{ - event: "phx_reply", - payload: %{ - "response" => %{ - "reason" => "ConnectionRateLimitReached: Too many connected users" - }, - "status" => "error" - } - }, - 500 - - assert_receive %Message{event: "phx_close"} - - change_tenant_configuration(tenant, :max_concurrent_users, max_concurrent_users) - end - - test "max_events_per_second limit respected", %{tenant: tenant} do - %{max_events_per_second: max_events_per_second} = Tenants.get_tenant_by_external_id(tenant.external_id) - on_exit(fn -> change_tenant_configuration(tenant, :max_events_per_second, max_events_per_second) end) - RateCounter.stop(tenant.external_id) - - log = - capture_log(fn -> - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false, presence: %{enabled: false}} - realtime_topic = "realtime:#{random_string()}" - - WebsocketClient.join(socket, realtime_topic, %{config: config}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500 - - for _ <- 1..1000, Process.alive?(socket) do - WebsocketClient.send_event(socket, realtime_topic, "broadcast", %{}) - Process.sleep(10) - end - - # Wait for the rate counter to run logger function - Process.sleep(1500) - assert_receive %Message{event: "phx_close"} - end) - - assert log =~ "MessagePerSecondRateLimitReached" - end - - test "max_channels_per_client limit respected", %{tenant: tenant} do - %{max_events_per_second: max_concurrent_users} = Tenants.get_tenant_by_external_id(tenant.external_id) - change_tenant_configuration(tenant, :max_channels_per_client, 1) - - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - realtime_topic_1 = "realtime:#{random_string()}" - realtime_topic_2 = "realtime:#{random_string()}" - - WebsocketClient.join(socket, realtime_topic_1, %{config: config}) - WebsocketClient.join(socket, realtime_topic_2, %{config: config}) - - assert_receive %Message{ - event: "phx_reply", - payload: %{"response" => %{"postgres_changes" => []}, "status" => "ok"}, - topic: ^realtime_topic_1 - }, - 500 - - assert_receive %Message{event: "presence_state", topic: ^realtime_topic_1}, 500 - - assert_receive %Message{ - event: "phx_reply", - payload: %{ - "status" => "error", - "response" => %{ - "reason" => "ChannelRateLimitReached: Too many channels" - } - }, - topic: ^realtime_topic_2 - }, - 500 - - refute_receive %Message{event: "phx_reply", topic: ^realtime_topic_2}, 500 - refute_receive %Message{event: "presence_state", topic: ^realtime_topic_2}, 500 - - change_tenant_configuration(tenant, :max_channels_per_client, max_concurrent_users) - end - - test "max_joins_per_second limit respected", %{tenant: tenant} do - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:#{random_string()}" - - log = - capture_log(fn -> - # Burst of joins that won't be blocked as RateCounter tick won't run - for _ <- 1..300 do - WebsocketClient.join(socket, realtime_topic, %{config: config}) - end - - # Wait for RateCounter tick - Process.sleep(1000) - # These ones will be blocked - for _ <- 1..300 do - WebsocketClient.join(socket, realtime_topic, %{config: config}) - end - - assert_receive %Message{ - event: "phx_reply", - payload: %{ - "response" => %{ - "reason" => "ClientJoinRateLimitReached: Too many joins per second" - }, - "status" => "error" - } - }, - 2000 - end) - - assert log =~ - "project=#{tenant.external_id} external_id=#{tenant.external_id} [critical] ClientJoinRateLimitReached: Too many joins per second" - - # Only one log message should be emitted - # Splitting by the error message returns the error message and the rest of the log only - assert length(String.split(log, "ClientJoinRateLimitReached")) == 2 - end - end - - describe "authorization handling" do - setup [:rls_context] - - @tag policies: [:read_matching_user_role, :write_matching_user_role], role: "anon" - test "role policies are respected when accessing the channel", %{tenant: tenant} do - {socket, _} = get_connection(tenant, "anon") - config = %{broadcast: %{self: true}, private: true, presence: %{enabled: false}} - topic = random_string() - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500 - - {socket, _} = get_connection(tenant, "potato") - topic = random_string() - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config}) - refute_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500 - end - - @tag policies: [:authenticated_read_matching_user_sub, :authenticated_write_matching_user_sub], - sub: Ecto.UUID.generate() - test "sub policies are respected when accessing the channel", %{tenant: tenant, sub: sub} do - {socket, _} = get_connection(tenant, "authenticated", %{sub: sub}) - config = %{broadcast: %{self: true}, private: true, presence: %{enabled: false}} - topic = random_string() - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500 - - {socket, _} = get_connection(tenant, "authenticated", %{sub: Ecto.UUID.generate()}) - topic = random_string() - realtime_topic = "realtime:#{topic}" - - WebsocketClient.join(socket, realtime_topic, %{config: config}) - refute_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500 - end - - @tag role: "authenticated", - policies: [:broken_read_presence, :broken_write_presence] - - test "handle failing rls policy", %{tenant: tenant} do - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: true} - topic = random_string() - realtime_topic = "realtime:#{topic}" - - log = - capture_log(fn -> - WebsocketClient.join(socket, realtime_topic, %{config: config}) - - msg = "Unauthorized: You do not have permissions to read from this Channel topic: #{topic}" - - assert_receive %Message{ - event: "phx_reply", - payload: %{ - "response" => %{ - "reason" => ^msg - }, - "status" => "error" - } - }, - 500 - - refute_receive %Message{event: "phx_reply"} - refute_receive %Message{event: "presence_state"} - end) - - assert log =~ "RlsPolicyError" - end - end - - test "handle empty topic by closing the socket", %{tenant: tenant} do - {socket, _} = get_connection(tenant, "authenticated") - config = %{broadcast: %{self: true}, private: false} - realtime_topic = "realtime:" - - WebsocketClient.join(socket, realtime_topic, %{config: config}) - - assert_receive %Message{ - event: "phx_reply", - payload: %{ - "response" => %{ - "reason" => "TopicNameRequired: You must provide a topic name" - }, - "status" => "error" - } - }, - 500 - - refute_receive %Message{event: "phx_reply"} - refute_receive %Message{event: "presence_state"} - end - - def handle_telemetry(event, %{sum: sum}, metadata, _) do - tenant = metadata[:tenant] - [key] = Enum.take(event, -1) - - Agent.update(TestCounter, fn state -> - state = Map.put_new(state, tenant, %{joins: 0, events: 0, db_events: 0, presence_events: 0}) - update_in(state, [metadata[:tenant], key], fn v -> (v || 0) + sum end) - end) - end - - defp get_count(event, tenant) do - [key] = Enum.take(event, -1) - - Agent.get(TestCounter, fn state -> get_in(state, [tenant, key]) || 0 end) - end - - describe "billable events" do - setup %{tenant: tenant} do - events = [ - [:realtime, :rate_counter, :channel, :joins], - [:realtime, :rate_counter, :channel, :events], - [:realtime, :rate_counter, :channel, :db_events], - [:realtime, :rate_counter, :channel, :presence_events] - ] - - {:ok, _} = - start_supervised(%{ - id: 1, - start: {Agent, :start_link, [fn -> %{} end, [name: TestCounter]]} - }) - - RateCounter.stop(tenant.external_id) - on_exit(fn -> :telemetry.detach(__MODULE__) end) - :telemetry.attach_many(__MODULE__, events, &__MODULE__.handle_telemetry/4, []) - - {:ok, conn} = Database.connect(tenant, "realtime_test") - - # Setup for postgres changes - Database.transaction(conn, fn db_conn -> - queries = [ - "drop table if exists public.test", - "drop publication if exists supabase_realtime_test", - "create sequence if not exists test_id_seq;", - """ - create table if not exists "public"."test" ( - "id" int4 not null default nextval('test_id_seq'::regclass), - "details" text, - primary key ("id")); - """, - "grant all on table public.test to anon;", - "grant all on table public.test to postgres;", - "grant all on table public.test to authenticated;", - "create publication supabase_realtime_test for all tables" - ] - - Enum.each(queries, &Postgrex.query!(db_conn, &1, [])) - end) - - :ok - end - - test "join events", %{tenant: tenant} do - external_id = tenant.external_id - {socket, _} = get_connection(tenant) - config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "public"}]} - topic = "realtime:any" - - WebsocketClient.join(socket, topic, %{config: config}) - - # Join events - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{topic: ^topic, event: "presence_state"} - assert_receive %Message{topic: ^topic, event: "system"}, 5000 - - # Wait for RateCounter to run - Process.sleep(2000) - - # Expected billed - # 1 joins due to two sockets - # 1 presence events due to two sockets - # 0 db events as no postgres changes used - # 0 events broadcast is not used - assert 1 = get_count([:realtime, :rate_counter, :channel, :joins], external_id) - assert 1 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id) - assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id) - assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id) - end - - test "broadcast events", %{tenant: tenant} do - external_id = tenant.external_id - {socket, _} = get_connection(tenant) - config = %{broadcast: %{self: true}} - topic = "realtime:any" - - WebsocketClient.join(socket, topic, %{config: config}) - - # Join events - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{topic: ^topic, event: "presence_state"} - - # Add second client so we can test the "multiplication" of billable events - {socket, _} = get_connection(tenant) - WebsocketClient.join(socket, topic, %{config: config}) - - # Join events - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{topic: ^topic, event: "presence_state"} - - # Broadcast event - payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} - - for _ <- 1..5 do - WebsocketClient.send_event(socket, topic, "broadcast", payload) - assert_receive %Message{topic: ^topic, event: "broadcast", payload: ^payload} - end - - # Wait for RateCounter to run - Process.sleep(2000) - - # Expected billed - # 2 joins due to two sockets - # 2 presence events due to two sockets - # 0 db events as no postgres changes used - # 15 events as 5 events sent, 5 events received on client 1 and 5 events received on client 2 - assert 2 = get_count([:realtime, :rate_counter, :channel, :joins], external_id) - assert 2 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id) - assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id) - assert 15 = get_count([:realtime, :rate_counter, :channel, :events], external_id) - end - - test "presence events", %{tenant: tenant} do - external_id = tenant.external_id - {socket, _} = get_connection(tenant) - config = %{broadcast: %{self: true}, presence: %{enabled: true}} - topic = "realtime:any" - - WebsocketClient.join(socket, topic, %{config: config}) - - # Join events - assert_receive %Message{event: "phx_reply", topic: ^topic}, 1000 - assert_receive %Message{topic: ^topic, event: "presence_state"}, 1000 - - payload = %{ - type: "presence", - event: "TRACK", - payload: %{name: "realtime_presence_1", t: 1814.7000000029802} - } - - WebsocketClient.send_event(socket, topic, "presence", payload) - assert_receive %Message{event: "presence_diff", payload: %{"joins" => _, "leaves" => %{}}, topic: ^topic} - - # Presence events - {socket, _} = get_connection(tenant, "authenticated") - WebsocketClient.join(socket, topic, %{config: config}) - - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{topic: ^topic, event: "presence_state"} - - payload = %{ - type: "presence", - event: "TRACK", - payload: %{name: "realtime_presence_2", t: 1814.7000000029802} - } - - WebsocketClient.send_event(socket, topic, "presence", payload) - assert_receive %Message{event: "presence_diff", payload: %{"joins" => _, "leaves" => %{}}, topic: ^topic} - assert_receive %Message{event: "presence_diff", payload: %{"joins" => _, "leaves" => %{}}, topic: ^topic} - - # Wait for RateCounter to run - Process.sleep(2000) - - # Expected billed - # 2 joins due to two sockets - # 7 presence events - # 0 db events as no postgres changes used - # 0 events as no broadcast used - assert 2 = get_count([:realtime, :rate_counter, :channel, :joins], external_id) - assert 7 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id) - assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id) - assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id) - end - - test "postgres changes events", %{tenant: tenant} do - external_id = tenant.external_id - {socket, _} = get_connection(tenant) - config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "public"}]} - topic = "realtime:any" - - WebsocketClient.join(socket, topic, %{config: config}) - - # Join events - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{topic: ^topic, event: "presence_state"}, 500 - assert_receive %Message{topic: ^topic, event: "system"}, 5000 - - # Add second user to test the "multiplication" of billable events - {socket, _} = get_connection(tenant) - WebsocketClient.join(socket, topic, %{config: config}) - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{topic: ^topic, event: "presence_state"}, 500 - assert_receive %Message{topic: ^topic, event: "system"}, 5000 - - tenant = Tenants.get_tenant_by_external_id(tenant.external_id) - {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) - - # Postgres Change events - for _ <- 1..5, do: Postgrex.query!(conn, "insert into test (details) values ('test')", []) - - for _ <- 1..5 do - assert_receive %Message{ - topic: ^topic, - event: "postgres_changes", - payload: %{"data" => %{"schema" => "public", "table" => "test", "type" => "INSERT"}} - }, - 5000 - end - - # Wait for RateCounter to run - Process.sleep(2000) - - # Expected billed - # 2 joins due to two sockets - # 2 presence events due to two sockets - # 10 db events due to 5 inserts events sent to client 1 and 5 inserts events sent to client 2 - # 0 events as no broadcast used - assert 2 = get_count([:realtime, :rate_counter, :channel, :joins], external_id) - assert 2 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id) - assert 10 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id) - assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id) - end - - test "postgres changes error events", %{tenant: tenant} do - external_id = tenant.external_id - {socket, _} = get_connection(tenant) - config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "none"}]} - topic = "realtime:any" - - WebsocketClient.join(socket, topic, %{config: config}) - - # Join events - assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 - assert_receive %Message{topic: ^topic, event: "presence_state"}, 500 - assert_receive %Message{topic: ^topic, event: "system"}, 5000 - - # Wait for RateCounter to run - Process.sleep(2000) - - # Expected billed - # 1 joins due to one socket - # 1 presence events due to one socket - # 0 db events - # 0 events as no broadcast used - assert 1 = get_count([:realtime, :rate_counter, :channel, :joins], external_id) - assert 1 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id) - assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id) - assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id) - end - end - - test "tracks and untracks properly channels", %{tenant: tenant} do - assert [] = Tracker.list_pids() - - {socket, _} = get_connection(tenant) - config = %{broadcast: %{self: true}, private: false, presence: %{enabled: false}} - - topics = - for _ <- 1..10 do - topic = "realtime:#{random_string()}" - :ok = WebsocketClient.join(socket, topic, %{config: config}) - assert_receive %Message{topic: ^topic, event: "phx_reply"}, 500 - topic - end - - assert [{_pid, count}] = Tracker.list_pids() - assert count == length(topics) - - for topic <- topics do - :ok = WebsocketClient.leave(socket, topic, %{}) - assert_receive %Message{topic: ^topic, event: "phx_close"}, 500 - end - - # wait to trigger tracker - assert_process_down(socket, 5000) - assert [] = Tracker.list_pids() - end - - test "failed connections are present in tracker with counter counter lower than 0 so they are actioned on by tracker", - %{tenant: tenant} do - assert [] = Tracker.list_pids() - - {socket, _} = get_connection(tenant) - config = %{broadcast: %{self: true}, private: true, presence: %{enabled: false}} - - for _ <- 1..10 do - topic = "realtime:#{random_string()}" - :ok = WebsocketClient.join(socket, topic, %{config: config}) - assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "error"}}, 500 - end - - assert [{_pid, count}] = Tracker.list_pids() - assert count == 0 - end - - test "failed connections but one succeeds properly tracks", - %{tenant: tenant} do - assert [] = Tracker.list_pids() - - {socket, _} = get_connection(tenant) - topic = "realtime:#{random_string()}" - - :ok = - WebsocketClient.join(socket, topic, %{ - config: %{broadcast: %{self: true}, private: false, presence: %{enabled: false}} - }) - - assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert [{_pid, count}] = Tracker.list_pids() - assert count == 1 - - for _ <- 1..10 do - topic = "realtime:#{random_string()}" - - :ok = - WebsocketClient.join(socket, topic, %{ - config: %{broadcast: %{self: true}, private: true, presence: %{enabled: false}} - }) - - assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "error"}}, 500 - end - - topic = "realtime:#{random_string()}" - - :ok = - WebsocketClient.join(socket, topic, %{ - config: %{broadcast: %{self: true}, private: false, presence: %{enabled: false}} - }) - - assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500 - assert [{_pid, count}] = Tracker.list_pids() - assert count == 2 - end - - defp mode(%{mode: :distributed}) do - tenant = Api.get_tenant_by_external_id("dev_tenant") - - RateCounter.stop(tenant.external_id) - :ets.delete_all_objects(Tracker.table_name()) - - Connect.shutdown(tenant.external_id) - # Sleeping so that syn can forget about this Connect process - Process.sleep(100) - - on_exit(fn -> - Connect.shutdown(tenant.external_id) - # Sleeping so that syn can forget about this Connect process - Process.sleep(100) - end) - - on_exit(fn -> Connect.shutdown(tenant.external_id) end) - {:ok, node} = Clustered.start() - region = Tenants.region(tenant) - {:ok, db_conn} = :erpc.call(node, Connect, :connect, ["dev_tenant", region]) - assert Connect.ready?(tenant.external_id) - - assert node(db_conn) == node - %{db_conn: db_conn, node: node, tenant: tenant} - end - - defp mode(_) do - tenant = Containers.checkout_tenant(run_migrations: true) - RateCounter.stop(tenant.external_id) - - :ets.delete_all_objects(Tracker.table_name()) - Realtime.Tenants.Connect.shutdown(tenant.external_id) - # Sleeping so that syn can forget about this Connect process - Process.sleep(100) - {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) - assert Connect.ready?(tenant.external_id) - %{db_conn: db_conn, tenant: tenant} - end - - defp rls_context(%{tenant: tenant} = context) do - {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) - clean_table(db_conn, "realtime", "messages") - topic = Map.get(context, :topic, random_string()) - policies = Map.get(context, :policies, nil) - role = Map.get(context, :role, nil) - sub = Map.get(context, :sub, nil) - - if policies, do: create_rls_policies(db_conn, policies, %{topic: topic, role: role, sub: sub}) - - %{topic: topic, role: role, sub: sub} - end - - defp setup_trigger(%{tenant: tenant, topic: topic}) do - {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) - random_name = String.downcase("test_#{random_string()}") - query = "CREATE TABLE #{random_name} (id serial primary key, details text)" - Postgrex.query!(db_conn, query, []) - - query = """ - CREATE OR REPLACE FUNCTION broadcast_changes_for_table_#{random_name}_trigger () - RETURNS TRIGGER - AS $$ - DECLARE - topic text; - BEGIN - topic = '#{topic}'; - PERFORM - realtime.broadcast_changes (topic, TG_OP, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA, NEW, OLD, TG_LEVEL); - RETURN NULL; - END; - $$ - LANGUAGE plpgsql; - """ - - Postgrex.query!(db_conn, query, []) - - query = """ - CREATE TRIGGER broadcast_changes_for_#{random_name}_table - AFTER INSERT OR UPDATE OR DELETE ON #{random_name} - FOR EACH ROW - EXECUTE FUNCTION broadcast_changes_for_table_#{random_name}_trigger (); - """ - - Postgrex.query!(db_conn, query, []) - - on_exit(fn -> - {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) - query = "DROP TABLE #{random_name} CASCADE" - Postgrex.query!(db_conn, query, []) - end) - - %{table_name: random_name} - end - - defp change_tenant_configuration(%Tenant{external_id: external_id}, limit, value) do - external_id - |> Realtime.Tenants.get_tenant_by_external_id() - |> Realtime.Api.Tenant.changeset(%{limit => value}) - |> Realtime.Repo.update!() - - Realtime.Tenants.Cache.invalidate_tenant_cache(external_id) - end - - defp assert_process_down(pid, timeout \\ 100) do - ref = Process.monitor(pid) - assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, timeout - end -end diff --git a/test/integration/tests.ts b/test/integration/tests.ts new file mode 100644 index 000000000..036255f17 --- /dev/null +++ b/test/integration/tests.ts @@ -0,0 +1,204 @@ +import { RealtimeClient } from "npm:@supabase/supabase-js@latest"; +import { sleep } from "https://deno.land/x/sleep/mod.ts"; +import { describe, it } from "jsr:@std/testing/bdd"; +import { assertEquals } from "jsr:@std/assert"; +import { deadline } from "jsr:@std/async/deadline"; + +const withDeadline = Promise>(fn: Fn, ms: number): Fn => + ((...args) => deadline(fn(...args), ms)) as Fn; + +const url = "http://realtime-dev.localhost:4100/socket"; +const serviceRoleKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjIwNzU3NzYzODIsInJlZiI6IjEyNy4wLjAuMSIsInJvbGUiOiJzZXJ2aWNlX3JvbGUiLCJpYXQiOjE3NjA3NzYzODJ9.nupH8pnrOTgK9Xaq8-D4Ry-yQ-PnlXEagTVywQUJVIE" +const apiKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjIwNzU2NjE3MjEsInJlZiI6IjEyNy4wLjAuMSIsInJvbGUiOiJhdXRoZW50aWNhdGVkIiwiaWF0IjoxNzYwNjYxNzIxfQ.PxpBoelC9vWQ2OVhmwKBUDEIKgX7MpgSdsnmXw7UdYk"; + +const realtimeV1 = { vsn: '1.0.0', params: { apikey: apiKey } , heartbeatIntervalMs: 5000, timeout: 5000 }; +const realtimeV2 = { vsn: '2.0.0', params: { apikey: apiKey } , heartbeatIntervalMs: 5000, timeout: 5000 }; +const realtimeServiceRole = { vsn: '2.0.0', logger: console.log, params: { apikey: serviceRoleKey } , heartbeatIntervalMs: 5000, timeout: 5000 }; + +let clientV1: RealtimeClient | null; +let clientV2: RealtimeClient | null; + +describe("broadcast extension", { sanitizeOps: false, sanitizeResources: false }, () => { + it("users with different versions can receive self broadcast", withDeadline(async () => { + clientV1 = new RealtimeClient(url, realtimeV1) + clientV2 = new RealtimeClient(url, realtimeV2) + let resultV1 = null; + let resultV2 = null; + let event = crypto.randomUUID(); + let topic = "topic:" + crypto.randomUUID(); + let expectedPayload = { message: crypto.randomUUID() }; + const config = { config: { broadcast: { ack: true, self: true } } }; + + const channelV1 = clientV1 + .channel(topic, config) + .on("broadcast", { event }, ({ payload }) => (resultV1 = payload)) + .subscribe(); + + const channelV2 = clientV2 + .channel(topic, config) + .on("broadcast", { event }, ({ payload }) => (resultV2 = payload)) + .subscribe(); + + while (channelV1.state != "joined" || channelV2.state != "joined") await sleep(0.2); + + // Send from V1 client - both should receive + await channelV1.send({ + type: "broadcast", + event, + payload: expectedPayload, + }); + + while (resultV1 == null || resultV2 == null) await sleep(0.2); + + assertEquals(resultV1, expectedPayload); + assertEquals(resultV2, expectedPayload); + + // Reset results for second test + resultV1 = null; + resultV2 = null; + let expectedPayload2 = { message: crypto.randomUUID() }; + + // Send from V2 client - both should receive + await channelV2.send({ + type: "broadcast", + event, + payload: expectedPayload2, + }); + + while (resultV1 == null || resultV2 == null) await sleep(0.2); + + assertEquals(resultV1, expectedPayload2); + assertEquals(resultV2, expectedPayload2); + + await channelV1.unsubscribe(); + await channelV2.unsubscribe(); + + await stopClient(clientV1); + await stopClient(clientV2); + clientV1 = null; + clientV2 = null; + }, 5000)); + + it("v2 can send/receive binary payload", withDeadline(async () => { + clientV2 = new RealtimeClient(url, realtimeV2) + let result = null; + let event = crypto.randomUUID(); + let topic = "topic:" + crypto.randomUUID(); + const expectedPayload = new ArrayBuffer(2); + const uint8 = new Uint8Array(expectedPayload); // View the buffer as unsigned 8-bit integers + uint8[0] = 125; + uint8[1] = 255; + + const config = { config: { broadcast: { ack: true, self: true } } }; + + const channelV2 = clientV2 + .channel(topic, config) + .on("broadcast", { event }, ({ payload }) => (result = payload)) + .subscribe(); + + while (channelV2.state != "joined") await sleep(0.2); + + await channelV2.send({ + type: "broadcast", + event, + payload: expectedPayload, + }); + + while (result == null) await sleep(0.2); + + assertEquals(result, expectedPayload); + + await channelV2.unsubscribe(); + + await stopClient(clientV2); + clientV2 = null; + }, 5000)); + + it("users with different versions can receive broadcasts from endpoint", withDeadline(async () => { + clientV1 = new RealtimeClient(url, realtimeV1) + clientV2 = new RealtimeClient(url, realtimeV2) + let resultV1 = null; + let resultV2 = null; + let event = crypto.randomUUID(); + let topic = "topic:" + crypto.randomUUID(); + let expectedPayload = { message: crypto.randomUUID() }; + const config = { config: { broadcast: { ack: true, self: true } } }; + + const channelV1 = clientV1 + .channel(topic, config) + .on("broadcast", { event }, ({ payload }) => (resultV1 = payload)) + .subscribe(); + + const channelV2 = clientV2 + .channel(topic, config) + .on("broadcast", { event }, ({ payload }) => (resultV2 = payload)) + .subscribe(); + + while (channelV1.state != "joined" || channelV2.state != "joined") await sleep(0.2); + + // Send from unsubscribed channel - both should receive + new RealtimeClient(url, realtimeServiceRole).channel(topic, config).httpSend(event, expectedPayload); + + while (resultV1 == null || resultV2 == null) await sleep(0.2); + + assertEquals(resultV1, expectedPayload); + assertEquals(resultV2, expectedPayload); + + await channelV1.unsubscribe(); + await channelV2.unsubscribe(); + + await stopClient(clientV1); + await stopClient(clientV2); + clientV1 = null; + clientV2 = null; + }, 5000)); +}); + +// describe("presence extension", () => { +// it("user is able to receive presence updates", async () => { +// let result: any = []; +// let error = null; +// let topic = "topic:" + crypto.randomUUID(); +// let keyV1 = "key V1"; +// let keyV2 = "key V2"; +// +// const configV1 = { config: { presence: { keyV1 } } }; +// const configV2 = { config: { presence: { keyV1 } } }; +// +// const channelV1 = clientV1 +// .channel(topic, configV1) +// .on("presence", { event: "join" }, ({ key, newPresences }) => +// result.push({ key, newPresences }) +// ) +// .subscribe(); +// +// const channelV2 = clientV2 +// .channel(topic, configV2) +// .on("presence", { event: "join" }, ({ key, newPresences }) => +// result.push({ key, newPresences }) +// ) +// .subscribe(); +// +// while (channelV1.state != "joined" || channelV2.state != "joined") await sleep(0.2); +// +// const resV1 = await channelV1.track({ key: keyV1 }); +// const resV2 = await channelV2.track({ key: keyV2 }); +// +// if (resV1 == "timed out" || resV2 == "timed out") error = resV1 || resV2; +// +// sleep(2.2); +// +// // FIXME write assertions +// console.log(result) +// let presences = result[0].newPresences[0]; +// assertEquals(result[0].key, keyV1); +// assertEquals(presences.message, message); +// assertEquals(error, null); +// }); +// }); + +async function stopClient(client: RealtimeClient | null) { + if (client) { + await client.removeAllChannels(); + } +} diff --git a/test/integration/tracker_test.exs b/test/integration/tracker_test.exs new file mode 100644 index 000000000..3f232d4bd --- /dev/null +++ b/test/integration/tracker_test.exs @@ -0,0 +1,96 @@ +defmodule Integration.TrackerTest do + # Changing the Tracker ETS table + use RealtimeWeb.ConnCase, async: false + + alias RealtimeWeb.RealtimeChannel.Tracker + alias Phoenix.Socket.Message + alias Realtime.Tenants.Connect + alias Realtime.Integration.WebsocketClient + + setup do + tenant = Containers.checkout_tenant(run_migrations: true) + :ets.delete_all_objects(Tracker.table_name()) + + {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + assert Connect.ready?(tenant.external_id) + %{db_conn: db_conn, tenant: tenant} + end + + test "tracks and untracks properly channels", %{tenant: tenant} do + {socket, _} = get_connection(tenant) + config = %{broadcast: %{self: true}, private: false, presence: %{enabled: false}} + + topics = + for _ <- 1..10 do + topic = "realtime:#{random_string()}" + :ok = WebsocketClient.join(socket, topic, %{config: config}) + assert_receive %Message{topic: ^topic, event: "phx_reply"}, 500 + topic + end + + for topic <- topics do + :ok = WebsocketClient.leave(socket, topic, %{}) + assert_receive %Message{topic: ^topic, event: "phx_close"}, 500 + end + + start_supervised!({Tracker, check_interval_in_ms: 100}) + # wait to trigger tracker + assert_process_down(socket, 1000) + end + + test "failed connections are present in tracker with counter lower than 0 so they are actioned on by tracker", %{ + tenant: tenant + } do + assert [] = Tracker.list_pids() + + {socket, _} = get_connection(tenant) + config = %{broadcast: %{self: true}, private: true, presence: %{enabled: false}} + + for _ <- 1..10 do + topic = "realtime:#{random_string()}" + :ok = WebsocketClient.join(socket, topic, %{config: config}) + assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "error"}}, 500 + end + + assert [{_pid, count}] = Tracker.list_pids() + assert count == 0 + end + + test "failed connections but one succeeds properly tracks", %{tenant: tenant} do + assert [] = Tracker.list_pids() + + {socket, _} = get_connection(tenant) + topic = "realtime:#{random_string()}" + + :ok = + WebsocketClient.join(socket, topic, %{ + config: %{broadcast: %{self: true}, private: false, presence: %{enabled: false}} + }) + + assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500 + assert [{_pid, count}] = Tracker.list_pids() + assert count == 1 + + for _ <- 1..10 do + topic = "realtime:#{random_string()}" + + :ok = + WebsocketClient.join(socket, topic, %{ + config: %{broadcast: %{self: true}, private: true, presence: %{enabled: false}} + }) + + assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "error"}}, 500 + end + + topic = "realtime:#{random_string()}" + + :ok = + WebsocketClient.join(socket, topic, %{ + config: %{broadcast: %{self: true}, private: false, presence: %{enabled: false}} + }) + + assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500 + assert [{_pid, count}] = Tracker.list_pids() + assert count == 2 + end +end diff --git a/test/realtime/adapters/postgres/protocol_test.exs b/test/realtime/adapters/postgres/protocol_test.exs index 778a96244..3f4a17abc 100644 --- a/test/realtime/adapters/postgres/protocol_test.exs +++ b/test/realtime/adapters/postgres/protocol_test.exs @@ -1,6 +1,9 @@ defmodule Realtime.Adapters.Postgres.ProtocolTest do use ExUnit.Case, async: true + alias Realtime.Adapters.Postgres.Protocol + alias Realtime.Adapters.Postgres.Protocol.Write + alias Realtime.Adapters.Postgres.Protocol.KeepAlive test "defguard is_write/1" do require Protocol @@ -13,4 +16,70 @@ defmodule Realtime.Adapters.Postgres.ProtocolTest do assert Protocol.is_keep_alive("k") refute Protocol.is_keep_alive("w") end + + describe "parse/1" do + test "parses a write message" do + wal_start = 100 + wal_end = 200 + clock = 300 + message = "some wal data" + + binary = <> + + assert %Write{ + server_wal_start: ^wal_start, + server_wal_end: ^wal_end, + server_system_clock: ^clock, + message: ^message + } = Protocol.parse(binary) + end + + test "parses a keep alive message with reply now" do + wal_end = 500 + clock = 600 + + binary = <> + + assert %KeepAlive{wal_end: ^wal_end, clock: ^clock, reply: :now} = Protocol.parse(binary) + end + + test "parses a keep alive message with reply later" do + wal_end = 500 + clock = 600 + + binary = <> + + assert %KeepAlive{wal_end: ^wal_end, clock: ^clock, reply: :later} = Protocol.parse(binary) + end + end + + describe "standby_status/5" do + test "returns binary message with reply now" do + [message] = Protocol.standby_status(100, 200, 300, :now, 400) + + assert <> = message + end + + test "returns binary message with reply later" do + [message] = Protocol.standby_status(100, 200, 300, :later, 400) + + assert <> = message + end + + test "uses current_time when clock is nil" do + [message] = Protocol.standby_status(100, 200, 300, :now) + + assert <> = message + end + end + + test "hold/0 returns empty list" do + assert Protocol.hold() == [] + end + + test "current_time/0 returns a positive integer" do + time = Protocol.current_time() + assert is_integer(time) + assert time > 0 + end end diff --git a/test/realtime/api/extensions_test.exs b/test/realtime/api/extensions_test.exs new file mode 100644 index 000000000..f4ceb1d37 --- /dev/null +++ b/test/realtime/api/extensions_test.exs @@ -0,0 +1,177 @@ +defmodule Realtime.Api.ExtensionsTest do + use ExUnit.Case, async: true + + alias Realtime.Api.Extensions + + describe "changeset/2 with nil type" do + test "skips default settings merge" do + changeset = Extensions.changeset(%Extensions{}, %{"settings" => %{"foo" => "bar"}}) + assert changeset.changes[:settings] == %{"foo" => "bar"} + end + + test "validates required fields" do + changeset = Extensions.changeset(%Extensions{}, %{}) + refute changeset.valid? + assert {"can't be blank", _} = changeset.errors[:type] + assert {"can't be blank", _} = changeset.errors[:settings] + end + end + + describe "changeset/2 with type" do + test "merges default settings for postgres_cdc_rls" do + attrs = %{ + "type" => "postgres_cdc_rls", + "settings" => %{ + "region" => "us-east-1", + "db_host" => "localhost", + "db_name" => "postgres", + "db_user" => "user", + "db_port" => "5432", + "db_password" => "pass" + } + } + + changeset = Extensions.changeset(%Extensions{}, attrs) + settings = changeset.changes[:settings] + + assert settings["publication"] == "supabase_realtime" + assert settings["slot_name"] == "supabase_realtime_replication_slot" + assert settings["region"] == "us-east-1" + end + + test "encrypts optional runtime credentials when provided" do + attrs = %{ + "type" => "postgres_cdc_rls", + "settings" => %{ + "region" => "us-east-1", + "db_host" => "localhost", + "db_port" => "5432", + "db_name" => "postgres", + "db_user" => "supabase_admin", + "db_password" => "pass", + "db_user_realtime" => "supabase_realtime_admin", + "db_pass_realtime" => "realtime-pass" + } + } + + settings = Extensions.changeset(%Extensions{}, attrs).changes[:settings] + + assert Realtime.Crypto.decrypt!(settings["db_user_realtime"]) == "supabase_realtime_admin" + assert Realtime.Crypto.decrypt!(settings["db_pass_realtime"]) == "realtime-pass" + end + + test "omits runtime credentials when not provided" do + attrs = %{ + "type" => "postgres_cdc_rls", + "settings" => %{ + "region" => "us-east-1", + "db_host" => "localhost", + "db_port" => "5432", + "db_name" => "postgres", + "db_user" => "supabase_admin", + "db_password" => "pass" + } + } + + settings = Extensions.changeset(%Extensions{}, attrs).changes[:settings] + + refute Map.has_key?(settings, "db_user_realtime") + refute Map.has_key?(settings, "db_pass_realtime") + end + end + + describe "validate_required_settings/2" do + test "adds error when required field is nil" do + required = [{"db_host", &is_binary/1, false}] + + changeset = + %Extensions{} + |> Ecto.Changeset.cast(%{type: "test", settings: %{}}, [:type, :settings]) + |> Extensions.validate_required_settings(required) + + refute changeset.valid? + assert {"db_host can't be blank", []} = changeset.errors[:settings] + end + + test "adds error when checker function fails" do + required = [{"db_port", &is_binary/1, false}] + + changeset = + %Extensions{} + |> Ecto.Changeset.cast(%{type: "test", settings: %{"db_port" => 5432}}, [:type, :settings]) + |> Extensions.validate_required_settings(required) + + refute changeset.valid? + assert {"db_port is invalid", []} = changeset.errors[:settings] + end + + test "passes when all required fields are valid" do + required = [{"db_host", &is_binary/1, false}] + + changeset = + %Extensions{} + |> Ecto.Changeset.cast(%{type: "test", settings: %{"db_host" => "localhost"}}, [:type, :settings]) + |> Extensions.validate_required_settings(required) + + assert changeset.valid? + end + end + + describe "validate_optional_settings/2" do + test "passes when the optional field is absent" do + optional = [{"db_user_realtime", &is_binary/1, true}] + + changeset = + %Extensions{} + |> Ecto.Changeset.cast(%{type: "test", settings: %{"db_host" => "localhost"}}, [:type, :settings]) + |> Extensions.validate_optional_settings(optional) + + assert changeset.valid? + end + + test "adds error when a present optional field is invalid" do + optional = [{"db_user_realtime", &is_binary/1, true}] + + changeset = + %Extensions{} + |> Ecto.Changeset.cast(%{type: "test", settings: %{"db_user_realtime" => 123}}, [:type, :settings]) + |> Extensions.validate_optional_settings(optional) + + refute changeset.valid? + assert {"db_user_realtime is invalid", []} = changeset.errors[:settings] + end + end + + describe "encrypt_settings/2" do + test "encrypts fields flagged for encryption" do + changeset = + %Extensions{} + |> Ecto.Changeset.cast(%{type: "test", settings: %{"db_password" => "secret"}}, [:type, :settings]) + |> Extensions.encrypt_settings([{"db_password", &is_binary/1, true}]) + + settings = Ecto.Changeset.get_change(changeset, :settings) + assert settings["db_password"] != "secret" + assert Realtime.Crypto.decrypt!(settings["db_password"]) == "secret" + end + + test "leaves fields not flagged for encryption untouched" do + changeset = + %Extensions{} + |> Ecto.Changeset.cast(%{type: "test", settings: %{"region" => "us-east-1"}}, [:type, :settings]) + |> Extensions.encrypt_settings([{"region", &is_binary/1, false}]) + + settings = Ecto.Changeset.get_change(changeset, :settings) + assert settings["region"] == "us-east-1" + end + + test "skips flagged fields that are absent" do + changeset = + %Extensions{} + |> Ecto.Changeset.cast(%{type: "test", settings: %{"db_host" => "localhost"}}, [:type, :settings]) + |> Extensions.encrypt_settings([{"db_pass_realtime", &is_binary/1, true}]) + + settings = Ecto.Changeset.get_change(changeset, :settings) + refute Map.has_key?(settings, "db_pass_realtime") + end + end +end diff --git a/test/realtime/api_test.exs b/test/realtime/api_test.exs index 1c4a816b0..ce0d101a3 100644 --- a/test/realtime/api_test.exs +++ b/test/realtime/api_test.exs @@ -4,31 +4,41 @@ defmodule Realtime.ApiTest do use Mimic alias Realtime.Api - alias Realtime.Api.Extensions + alias Realtime.Api.Extensions, as: ApiExtensions + alias Realtime.Api.FeatureFlag alias Realtime.Api.Tenant alias Realtime.Crypto alias Realtime.GenCounter + alias Realtime.GenRpc + alias Realtime.Nodes alias Realtime.RateCounter alias Realtime.Tenants.Connect + alias Extensions.PostgresCdcRls @db_conf Application.compile_env(:realtime, Realtime.Repo) - setup do - tenant1 = Containers.checkout_tenant(run_migrations: true) - tenant2 = Containers.checkout_tenant(run_migrations: true) - Api.update_tenant(tenant1, %{max_concurrent_users: 10_000_000}) - Api.update_tenant(tenant2, %{max_concurrent_users: 20_000_000}) - - %{tenants: Api.list_tenants(), tenant: tenant1} + defp create_tenants(_) do + tenant1 = tenant_fixture(%{max_concurrent_users: 10_000_000}) + tenant2 = tenant_fixture(%{max_concurrent_users: 20_000_000}) + tenant3 = tenant_fixture(%{max_concurrent_users: 30_000_000}) + %{tenants: [tenant1, tenant2, tenant3]} end describe "list_tenants/0" do + setup [:create_tenants] + test "returns all tenants", %{tenants: tenants} do - assert Enum.sort(Api.list_tenants()) == Enum.sort(tenants) + assert Api.list_tenants() + + Enum.each(tenants, fn tenant -> + assert tenant in Api.list_tenants() + end) end end describe "list_tenants/1" do + setup [:create_tenants] + test "list_tenants/1 returns filtered tenants", %{tenants: tenants} do assert hd(Api.list_tenants(search: hd(tenants).external_id)) == hd(tenants) @@ -38,6 +48,8 @@ defmodule Realtime.ApiTest do end describe "get_tenant!/1" do + setup [:create_tenants] + test "returns the tenant with given id", %{tenants: [tenant | _]} do result = tenant.id |> Api.get_tenant!() |> Map.delete(:extensions) expected = tenant |> Map.delete(:extensions) @@ -51,6 +63,10 @@ defmodule Realtime.ApiTest do external_id = random_string() + expect(Realtime.Tenants.Cache, :global_cache_update, fn tenant -> + assert tenant.external_id == external_id + end) + valid_attrs = %{ external_id: external_id, name: external_id, @@ -85,109 +101,154 @@ defmodule Realtime.ApiTest do end test "invalid data returns error changeset" do + reject(&Realtime.Tenants.Cache.global_cache_update/1) assert {:error, %Ecto.Changeset{}} = Api.create_tenant(%{external_id: nil, jwt_secret: nil, name: nil}) end end - describe "get_tenant_by_external_id/1" do + describe "get_tenant_by_external_id/2" do + setup [:create_tenants] + test "fetch by external id", %{tenants: [tenant | _]} do - %Tenant{extensions: [%Extensions{} = extension]} = + %Tenant{extensions: [%ApiExtensions{} = extension]} = Api.get_tenant_by_external_id(tenant.external_id) assert Map.has_key?(extension.settings, "db_password") password = extension.settings["db_password"] assert ^password = "v1QVng3N+pZd/0AEObABwg==" end + + test "fetch by external id using replica", %{tenants: [tenant | _]} do + %Tenant{extensions: [%ApiExtensions{} = extension]} = + Api.get_tenant_by_external_id(tenant.external_id, use_replica?: true) + + assert Map.has_key?(extension.settings, "db_password") + password = extension.settings["db_password"] + assert ^password = "v1QVng3N+pZd/0AEObABwg==" + end + + test "fetch by external id using no replica", %{tenants: [tenant | _]} do + %Tenant{extensions: [%ApiExtensions{} = extension]} = + Api.get_tenant_by_external_id(tenant.external_id, use_replica?: false) + + assert Map.has_key?(extension.settings, "db_password") + password = extension.settings["db_password"] + assert ^password = "v1QVng3N+pZd/0AEObABwg==" + end end - describe "update_tenant/2" do - test "valid data updates the tenant", %{tenant: tenant} do + describe "update_tenant_by_external_id/2" do + setup [:create_tenants] + + test "valid data updates the tenant using external_id", %{tenants: [tenant | _]} do update_attrs = %{ external_id: tenant.external_id, jwt_secret: "some updated jwt_secret", name: "some updated name" } - assert {:ok, %Tenant{} = tenant} = Api.update_tenant(tenant, update_attrs) + assert {:ok, %Tenant{} = tenant} = Api.update_tenant_by_external_id(tenant.external_id, update_attrs) assert tenant.external_id == tenant.external_id assert tenant.jwt_secret == Crypto.encrypt!("some updated jwt_secret") assert tenant.name == "some updated name" end - test "invalid data returns error changeset", %{tenant: tenant} do - assert {:error, %Ecto.Changeset{}} = Api.update_tenant(tenant, %{external_id: nil, jwt_secret: nil, name: nil}) + test "invalid data returns error changeset", %{tenants: [tenant | _]} do + assert {:error, %Ecto.Changeset{}} = + Api.update_tenant_by_external_id(tenant.external_id, %{external_id: nil, jwt_secret: nil, name: nil}) end - test "valid data and jwks change will send disconnect event", %{tenant: tenant} do + test "valid data and jwks change will send disconnect event", %{tenants: [tenant | _]} do :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id) - assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{jwt_jwks: %{keys: ["test"]}}) - assert_receive :disconnect, 500 + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{jwt_jwks: %{keys: ["test"]}}) + + assert %Phoenix.Socket.Broadcast{ + payload: %{message: "Server requested disconnect", status: "ok", extension: "system"}, + event: "system", + topic: nil + } end - test "valid data and jwt_secret change will send disconnect event", %{tenant: tenant} do + test "valid data and jwt_secret change will send disconnect event", %{tenants: [tenant | _]} do :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id) - assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{jwt_secret: "potato"}) - assert_receive :disconnect, 500 + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{jwt_secret: "potato"}) + + assert %Phoenix.Socket.Broadcast{ + payload: %{message: "Server requested disconnect", status: "ok", extension: "system"}, + event: "system", + topic: nil + } end - test "valid data and suspend change will send disconnect event", %{tenant: tenant} do + test "valid data and suspend change will send disconnect event", %{tenants: [tenant | _]} do :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id) - assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{suspend: true}) - assert_receive :disconnect, 500 + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{suspend: true}) + + assert %Phoenix.Socket.Broadcast{ + payload: %{message: "Server requested disconnect", status: "ok", extension: "system"}, + event: "system", + topic: nil + } end - test "valid data but not updating jwt_secret or jwt_jwks won't send event", %{tenant: tenant} do + test "valid data but not updating jwt_secret or jwt_jwks won't send event", %{tenants: [tenant | _]} do :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id) - assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{max_events_per_second: 100}) - refute_receive :disconnect, 500 + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{max_events_per_second: 100}) + refute_receive _any end - test "valid data and jwt_secret change will restart the database connection", %{tenant: tenant} do - {:ok, old_pid} = Connect.lookup_or_start_connection(tenant.external_id) + test "valid data and jwt_secret change will restart the database connection", %{tenants: [tenant | _]} do + expect(Connect, :shutdown, fn external_id -> + assert external_id == tenant.external_id + :ok + end) - Process.monitor(old_pid) - assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{jwt_secret: "potato"}) - assert_receive {:DOWN, _, :process, ^old_pid, :shutdown}, 500 - refute Process.alive?(old_pid) - Process.sleep(100) - assert {:ok, new_pid} = Connect.lookup_or_start_connection(tenant.external_id) - assert %Postgrex.Result{} = Postgrex.query!(new_pid, "SELECT 1", []) + expect(PostgresCdcRls, :handle_stop, fn external_id, timeout -> + assert external_id == tenant.external_id + assert timeout == 5_000 + :ok + end) + + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{jwt_secret: "potato"}) end - test "valid data and suspend change will restart the database connection", %{tenant: tenant} do - {:ok, old_pid} = Connect.lookup_or_start_connection(tenant.external_id) + test "valid data and suspend change will restart the database connection", %{tenants: [tenant | _]} do + expect(Connect, :shutdown, fn external_id -> + assert external_id == tenant.external_id + :ok + end) + + expect(PostgresCdcRls, :handle_stop, fn external_id, timeout -> + assert external_id == tenant.external_id + assert timeout == 5_000 + :ok + end) - Process.monitor(old_pid) - assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{suspend: true}) - assert_receive {:DOWN, _, :process, ^old_pid, :shutdown}, 500 - refute Process.alive?(old_pid) - Process.sleep(100) - assert {:error, :tenant_suspended} = Connect.lookup_or_start_connection(tenant.external_id) + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{suspend: true}) end - test "valid data and tenant data change will not restart the database connection", %{tenant: tenant} do - {:ok, old_pid} = Connect.lookup_or_start_connection(tenant.external_id) + test "valid data and tenant data change will not restart the database connection", %{tenants: [tenant | _]} do + reject(&Connect.shutdown/1) + reject(&PostgresCdcRls.handle_stop/2) - assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{max_concurrent_users: 100}) - refute_receive {:DOWN, _, :process, ^old_pid, :shutdown}, 500 - assert Process.alive?(old_pid) - assert {:ok, new_pid} = Connect.lookup_or_start_connection(tenant.external_id) - assert old_pid == new_pid - end + expect(Realtime.Tenants.Cache, :global_cache_update, fn tenant -> + assert tenant.max_concurrent_users == 101 + end) - test "valid data and extensions data change will restart the database connection", %{tenant: tenant} do - config = Realtime.Database.from_tenant(tenant, "realtime_test", :stop) + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{max_concurrent_users: 101}) + end + test "valid data and extensions data change will restart the database connection", %{tenants: [tenant | _]} do extensions = [ %{ "type" => "postgres_cdc_rls", "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => "supabase_realtime_admin", "db_password" => "postgres", - "db_port" => "#{config.port}", + "db_port" => "5432", "poll_interval" => 100, "poll_max_changes" => 100, "poll_max_record_bytes" => 1_048_576, @@ -198,32 +259,135 @@ defmodule Realtime.ApiTest do } ] - {:ok, old_pid} = Connect.lookup_or_start_connection(tenant.external_id) - Process.monitor(old_pid) - assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{extensions: extensions}) - assert_receive {:DOWN, _, :process, ^old_pid, :shutdown}, 500 - refute Process.alive?(old_pid) - Process.sleep(100) - assert {:ok, new_pid} = Connect.lookup_or_start_connection(tenant.external_id) - assert %Postgrex.Result{} = Postgrex.query!(new_pid, "SELECT 1", []) + expect(Connect, :shutdown, fn external_id -> + assert external_id == tenant.external_id + :ok + end) + + expect(PostgresCdcRls, :handle_stop, fn external_id, timeout -> + assert external_id == tenant.external_id + assert timeout == 5_000 + :ok + end) + + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions}) end - test "valid data and change to tenant data will refresh cache", %{tenant: tenant} do - assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{name: "new_name"}) - assert %Tenant{name: "new_name"} = Realtime.Tenants.Cache.get_tenant_by_external_id(tenant.external_id) + test "valid data and jwt_jwks change will restart the database connection", %{tenants: [tenant | _]} do + expect(Connect, :shutdown, fn external_id -> + assert external_id == tenant.external_id + :ok + end) + + expect(PostgresCdcRls, :handle_stop, fn external_id, timeout -> + assert external_id == tenant.external_id + assert timeout == 5_000 + :ok + end) + + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{jwt_jwks: %{keys: ["test"]}}) end - test "valid data and no changes to tenant will not refresh cache", %{tenant: tenant} do - reject(&Realtime.Tenants.Cache.get_tenant_by_external_id/1) - assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{name: tenant.name}) + test "valid data and jwt_secret change will restart DB connection even if handle_stop times out", %{ + tenants: [tenant | _] + } do + expect(Connect, :shutdown, fn external_id -> + assert external_id == tenant.external_id + :ok + end) + + expect(PostgresCdcRls, :handle_stop, fn _external_id, _timeout -> + # Simulate timeout exit like DynamicSupervisor.stop/3 does + exit(:timeout) + end) + + # Update should still succeed even if handle_stop times out + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{jwt_secret: "potato"}) end - end - describe "delete_tenant/1" do - test "deletes the tenant" do - tenant = tenant_fixture() - assert {:ok, %Tenant{}} = Api.delete_tenant(tenant) - assert_raise Ecto.NoResultsError, fn -> Api.get_tenant!(tenant.id) end + test "valid data and change to tenant data will refresh cache", %{tenants: [tenant | _]} do + expect(Realtime.Tenants.Cache, :global_cache_update, fn tenant -> + assert tenant.name == "new_name" + end) + + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{name: "new_name"}) + end + + test "valid data and no changes to tenant will not refresh cache", %{tenants: [tenant | _]} do + reject(&Realtime.Tenants.Cache.global_cache_update/1) + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{name: tenant.name}) + end + + test "change to max_events_per_second publishes update to respective rate counters", %{tenants: [tenant | _]} do + expect(RateCounter, :publish_update, fn key -> + assert key == Realtime.Tenants.events_per_second_key(tenant.external_id) + end) + + expect(RateCounter, :publish_update, fn key -> + assert key == Realtime.Tenants.db_events_per_second_key(tenant.external_id) + end) + + reject(&RateCounter.publish_update/1) + + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{max_events_per_second: 123}) + end + + test "change to max_joins_per_second publishes update to rate counters", %{tenants: [tenant | _]} do + expect(RateCounter, :publish_update, fn key -> + assert key == Realtime.Tenants.joins_per_second_key(tenant.external_id) + end) + + reject(&RateCounter.publish_update/1) + + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{max_joins_per_second: 123}) + end + + test "change to max_presence_events_per_second publishes update to rate counters", %{tenants: [tenant | _]} do + expect(RateCounter, :publish_update, fn key -> + assert key == Realtime.Tenants.presence_events_per_second_key(tenant.external_id) + end) + + reject(&RateCounter.publish_update/1) + + assert {:ok, %Tenant{}} = + Api.update_tenant_by_external_id(tenant.external_id, %{max_presence_events_per_second: 123}) + end + + test "change to extensions publishes update to rate counters", %{tenants: [tenant | _]} do + extensions = [ + %{ + "type" => "postgres_cdc_rls", + "settings" => %{ + "db_host" => "127.0.0.1", + "db_name" => "postgres", + "db_user" => "supabase_realtime_admin", + "db_password" => "postgres", + "db_port" => "1234", + "poll_interval" => 100, + "poll_max_changes" => 100, + "poll_max_record_bytes" => 1_048_576, + "region" => "us-east-1", + "publication" => "supabase_realtime_test", + "ssl_enforced" => false + } + } + ] + + expect(RateCounter, :publish_update, fn key -> + assert key == Realtime.Tenants.connect_errors_per_second_key(tenant.external_id) + end) + + expect(RateCounter, :publish_update, fn key -> + assert key == Realtime.Tenants.subscription_errors_per_second_key(tenant.external_id) + end) + + expect(RateCounter, :publish_update, fn key -> + assert key == Realtime.Tenants.authorization_errors_per_second_key(tenant.external_id) + end) + + reject(&RateCounter.publish_update/1) + + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions}) end end @@ -236,11 +400,9 @@ defmodule Realtime.ApiTest do end end - test "list_extensions/1 ", %{tenants: tenants} do - assert length(Api.list_extensions()) == length(tenants) - end - describe "preload_counters/1" do + setup [:create_tenants] + test "preloads counters for a given tenant ", %{tenants: [tenant | _]} do tenant = Repo.reload!(tenant) assert Api.preload_counters(nil) == nil @@ -256,6 +418,7 @@ defmodule Realtime.ApiTest do end describe "rename_settings_field/2" do + @tag skip: "** (Postgrex.Error) ERROR 0A000 (feature_not_supported) cached plan must not change result type" test "renames setting fields" do tenant = tenant_fixture() Api.rename_settings_field("poll_interval_ms", "poll_interval") @@ -340,4 +503,130 @@ defmodule Realtime.ApiTest do refute TestRequiresRestartingDbConnection.check(changeset) end end + + describe "update_migrations_ran/1" do + test "updates migrations_ran to the count of all migrations" do + tenant = tenant_fixture(%{migrations_ran: 0}) + + expect(Realtime.Tenants.Cache, :global_cache_update, fn tenant -> + assert tenant.migrations_ran == 1 + :ok + end) + + assert {:ok, tenant} = Api.update_migrations_ran(tenant.external_id, 1) + assert tenant.migrations_ran == 1 + end + + test "returns {:error, :tenant_not_found} when tenant does not exist" do + assert {:error, :tenant_not_found} = Api.update_migrations_ran("removed", 11) + end + end + + describe "list_feature_flags/0" do + test "returns all flags ordered by name" do + {:ok, _} = Api.upsert_feature_flag(%{name: "zebra_flag", enabled: false}) + {:ok, _} = Api.upsert_feature_flag(%{name: "alpha_flag", enabled: true}) + + names = Api.list_feature_flags() |> Enum.map(& &1.name) + assert "alpha_flag" in names + assert "zebra_flag" in names + assert Enum.find_index(names, &(&1 == "alpha_flag")) < Enum.find_index(names, &(&1 == "zebra_flag")) + end + end + + describe "get_feature_flag/1" do + test "returns the flag when it exists" do + {:ok, flag} = Api.upsert_feature_flag(%{name: "my_flag", enabled: true}) + assert %FeatureFlag{name: "my_flag"} = Api.get_feature_flag("my_flag") + assert Api.get_feature_flag("my_flag").id == flag.id + end + + test "returns nil when flag does not exist" do + refute Api.get_feature_flag("nonexistent") + end + end + + describe "upsert_feature_flag/1" do + test "inserts a new flag" do + assert {:ok, %FeatureFlag{name: "new_flag", enabled: false}} = + Api.upsert_feature_flag(%{name: "new_flag", enabled: false}) + end + + test "updates an existing flag" do + {:ok, _} = Api.upsert_feature_flag(%{name: "existing", enabled: false}) + + assert {:ok, %FeatureFlag{name: "existing", enabled: true}} = + Api.upsert_feature_flag(%{name: "existing", enabled: true}) + + assert Api.list_feature_flags() |> Enum.count(&(&1.name == "existing")) == 1 + end + + test "returns error changeset when name is missing" do + assert {:error, changeset} = Api.upsert_feature_flag(%{enabled: false}) + assert "can't be blank" in errors_on(changeset).name + end + end + + describe "delete_feature_flag/1" do + test "removes the flag" do + {:ok, flag} = Api.upsert_feature_flag(%{name: "to_delete", enabled: false}) + assert {:ok, _} = Api.delete_feature_flag(flag) + refute Api.get_feature_flag("to_delete") + end + end + + describe "non-master region routing" do + setup do + previous_region = Application.get_env(:realtime, :region) + previous_master_region = Application.get_env(:realtime, :master_region) + + Application.put_env(:realtime, :region, "ap-southeast-2") + Application.put_env(:realtime, :master_region, "us-east-1") + + on_exit(fn -> + Application.put_env(:realtime, :region, previous_region) + Application.put_env(:realtime, :master_region, previous_master_region) + end) + + fake_master = :"master@127.0.0.1" + Mimic.stub(Nodes, :node_from_region, fn "us-east-1", _key -> {:ok, fake_master} end) + + %{master_node: fake_master} + end + + test "upsert_feature_flag dispatches to master with empty opts", %{master_node: master_node} do + Mimic.expect(GenRpc, :call, fn ^master_node, Api, :upsert_feature_flag, args, opts -> + assert args == [%{name: "rpc_flag", enabled: true}] + assert opts == [] + {:ok, %FeatureFlag{name: "rpc_flag", enabled: true}} + end) + + assert {:ok, %FeatureFlag{name: "rpc_flag", enabled: true}} = + Api.upsert_feature_flag(%{name: "rpc_flag", enabled: true}) + end + + test "delete_feature_flag dispatches to master with empty opts", %{master_node: master_node} do + flag = %FeatureFlag{id: Ecto.UUID.generate(), name: "rpc_delete", enabled: false} + + Mimic.expect(GenRpc, :call, fn ^master_node, Api, :delete_feature_flag, args, opts -> + assert args == [flag] + assert opts == [] + {:ok, flag} + end) + + assert {:ok, ^flag} = Api.delete_feature_flag(flag) + end + + test "create_tenant dispatches to master with tenant_id opt", %{master_node: master_node} do + external_id = "rpc_tenant_#{System.unique_integer([:positive])}" + attrs = %{"external_id" => external_id, "name" => external_id} + + Mimic.expect(GenRpc, :call, fn ^master_node, Api, :create_tenant, _args, opts -> + assert opts == [tenant_id: external_id] + {:ok, %Tenant{external_id: external_id}} + end) + + assert {:ok, %Tenant{external_id: ^external_id}} = Api.create_tenant(attrs) + end + end end diff --git a/test/realtime/database_distributed_test.exs b/test/realtime/database_distributed_test.exs new file mode 100644 index 000000000..cb952c861 --- /dev/null +++ b/test/realtime/database_distributed_test.exs @@ -0,0 +1,96 @@ +defmodule Realtime.DatabaseDistributedTest do + # async: false due to usage of Clustered + use Realtime.DataCase, async: false + + import ExUnit.CaptureLog + + alias Realtime.Database + alias Realtime.Rpc + alias Realtime.Tenants.Connect + + doctest Realtime.Database + def handle_telemetry(event, metadata, content, pid: pid), do: send(pid, {event, metadata, content}) + + setup do + tenant = Containers.checkout_tenant() + :telemetry.attach(__MODULE__, [:realtime, :database, :transaction], &__MODULE__.handle_telemetry/4, pid: self()) + + on_exit(fn -> :telemetry.detach(__MODULE__) end) + + %{tenant: tenant} + end + + @aux_mod (quote do + defmodule DatabaseAux do + def checker(transaction_conn) do + Postgrex.query!(transaction_conn, "SELECT 1", []) + end + + def error(transaction_conn) do + Postgrex.query!(transaction_conn, "SELECT 1/0", []) + end + + def exception(_) do + raise RuntimeError, "💣" + end + end + end) + + Code.eval_quoted(@aux_mod) + + describe "transaction/1 in clustered mode" do + setup do + tenant = Containers.checkout_tenant_unboxed(run_migrations: true) + %{distributed_tenant: tenant} + end + + test "success call returns output", %{distributed_tenant: tenant} do + {:ok, node} = Clustered.start(@aux_mod) + {:ok, db_conn} = Rpc.call(node, Connect, :connect, [tenant.external_id, "us-east-1"]) + assert node(db_conn) == node + assert {:ok, %Postgrex.Result{rows: [[1]]}} = Database.transaction(db_conn, &DatabaseAux.checker/1) + end + + test "handles database errors", %{distributed_tenant: tenant} do + metadata = [external_id: "123", project: "123"] + {:ok, node} = Clustered.start(@aux_mod) + {:ok, db_conn} = Rpc.call(node, Connect, :connect, [tenant.external_id, "us-east-1"]) + assert node(db_conn) == node + + assert capture_log(fn -> + assert {:error, %Postgrex.Error{}} = Database.transaction(db_conn, &DatabaseAux.error/1, [], metadata) + # We have to wait for logs to be relayed to this node + Process.sleep(100) + end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:" + end + + test "handles exception", %{distributed_tenant: tenant} do + metadata = [external_id: "123", project: "123"] + {:ok, node} = Clustered.start(@aux_mod) + {:ok, db_conn} = Rpc.call(node, Connect, :connect, [tenant.external_id, "us-east-1"]) + assert node(db_conn) == node + + assert capture_log(fn -> + assert {:error, %RuntimeError{}} = Database.transaction(db_conn, &DatabaseAux.exception/1, [], metadata) + # We have to wait for logs to be relayed to this node + Process.sleep(100) + end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:" + end + + test "db process is not alive anymore" do + metadata = [external_id: "123", project: "123", tenant_id: "123"] + {:ok, node} = Clustered.start(@aux_mod) + + pid = Rpc.call(node, :erlang, :self, []) + assert node(pid) == node + + assert capture_log(fn -> + assert {:error, {:exit, {:noproc, {DBConnection.Holder, :checkout, [^pid, []]}}}} = + Database.transaction(pid, &DatabaseAux.checker/1, [], metadata) + + # We have to wait for logs to be relayed to this node + Process.sleep(100) + end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:" + end + end +end diff --git a/test/realtime/database_test.exs b/test/realtime/database_test.exs index f48de14b6..646ee82fd 100644 --- a/test/realtime/database_test.exs +++ b/test/realtime/database_test.exs @@ -1,16 +1,26 @@ defmodule Realtime.DatabaseTest do - # async: false due to usage of Clustered - use Realtime.DataCase, async: false + use Realtime.DataCase, async: true import ExUnit.CaptureLog alias Realtime.Database - alias Realtime.Rpc - alias Realtime.Tenants.Connect doctest Realtime.Database def handle_telemetry(event, metadata, content, pid: pid), do: send(pid, {event, metadata, content}) + defp encrypted_settings(extra \\ %{}) do + Map.merge( + %{ + "db_host" => Realtime.Crypto.encrypt!("127.0.0.1"), + "db_port" => Realtime.Crypto.encrypt!("5432"), + "db_name" => Realtime.Crypto.encrypt!("postgres"), + "db_user" => Realtime.Crypto.encrypt!("supabase_admin"), + "db_password" => Realtime.Crypto.encrypt!("super-pass") + }, + extra + ) + end + setup do tenant = Containers.checkout_tenant() :telemetry.attach(__MODULE__, [:realtime, :database, :transaction], &__MODULE__.handle_telemetry/4, pid: self()) @@ -27,7 +37,7 @@ defmodule Realtime.DatabaseTest do "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => "supabase_realtime_admin", "db_password" => "postgres", "region" => "us-east-1", "ssl_enforced" => false, @@ -42,32 +52,47 @@ defmodule Realtime.DatabaseTest do %{tenant: tenant} end + test "returns error when tenant is nil" do + assert {:error, :tenant_not_found} = Database.check_tenant_connection(nil) + end + test "connects to a tenant database", %{tenant: tenant} do - assert {:ok, _} = Database.check_tenant_connection(tenant) + assert {:ok, _conn, migrations_ran} = Database.check_tenant_connection(tenant) + assert is_integer(migrations_ran) + assert migrations_ran >= 0 + end + + test "returns 0 migrations when realtime.schema_migrations does not exist", %{tenant: tenant} do + # by default new containers do not have the schema_migrations table + assert {:ok, _conn, 0} = Database.check_tenant_connection(tenant) + end + + test "returns migration count when realtime.schema_migrations exists", %{tenant: tenant} do + {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) + + Postgrex.query!(conn, "CREATE TABLE IF NOT EXISTS realtime.schema_migrations (version bigint PRIMARY KEY)", []) + Postgrex.query!(conn, "INSERT INTO realtime.schema_migrations VALUES (1), (2), (3)", []) + + assert {:ok, check_conn, 3} = Database.check_tenant_connection(tenant) + GenServer.stop(check_conn) + GenServer.stop(conn) end # Connection limit for docker tenant db is 100 @tag db_pool: 50, - subs_pool_size: 21, - subcriber_pool_size: 33 + subs_pool_size: 73 test "restricts connection if tenant database cannot receive more connections based on tenant pool", %{tenant: tenant} do assert capture_log(fn -> assert {:error, :tenant_db_too_many_connections} = Database.check_tenant_connection(tenant) - end) =~ ~r/Only \d+ available connections\. At least 126 connections are required/ + end) =~ ~r/Only \d+ available connections\. At least 125 connections are required/ end end describe "replication_slot_teardown/1" do test "removes replication slots with the realtime prefix", %{tenant: tenant} do {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) - - Postgrex.query!( - conn, - "SELECT * FROM pg_create_logical_replication_slot('realtime_test_slot', 'pgoutput')", - [] - ) - + Postgrex.query!(conn, "SELECT * FROM pg_create_logical_replication_slot('realtime_test_slot', 'pgoutput')", []) Database.replication_slot_teardown(tenant) assert %{rows: []} = Postgrex.query!(conn, "SELECT slot_name FROM pg_replication_slots", []) end @@ -77,13 +102,7 @@ defmodule Realtime.DatabaseTest do test "removes replication slots with a given name and existing connection", %{tenant: tenant} do name = String.downcase("slot_#{random_string()}") {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) - - Postgrex.query!( - conn, - "SELECT * FROM pg_create_logical_replication_slot('#{name}', 'pgoutput')", - [] - ) - + Postgrex.query!(conn, "SELECT * FROM pg_create_logical_replication_slot('#{name}', 'pgoutput')", []) Database.replication_slot_teardown(conn, name) Process.sleep(1000) assert %{rows: []} = Postgrex.query!(conn, "SELECT slot_name FROM pg_replication_slots", []) @@ -92,13 +111,7 @@ defmodule Realtime.DatabaseTest do test "removes replication slots with a given name and a tenant", %{tenant: tenant} do name = String.downcase("slot_#{random_string()}") {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) - - Postgrex.query!( - conn, - "SELECT * FROM pg_create_logical_replication_slot('#{name}', 'pgoutput')", - [] - ) - + Postgrex.query!(conn, "SELECT * FROM pg_create_logical_replication_slot('#{name}', 'pgoutput')", []) Database.replication_slot_teardown(tenant, name) assert %{rows: []} = Postgrex.query!(conn, "SELECT slot_name FROM pg_replication_slots", []) end @@ -111,7 +124,7 @@ defmodule Realtime.DatabaseTest do "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => "supabase_realtime_admin", "db_password" => "postgres", "region" => "us-east-1", "ssl_enforced" => false, @@ -126,7 +139,7 @@ defmodule Realtime.DatabaseTest do end test "handles transaction errors", %{db_conn: db_conn} do - assert {:error, %DBConnection.ConnectionError{reason: :error}} = + assert {:error, %Postgrex.Error{postgres: %{code: :admin_shutdown}}} = Database.transaction(db_conn, fn conn -> Postgrex.query!(conn, "select pg_terminate_backend(pg_backend_pid())", []) end) @@ -164,6 +177,12 @@ defmodule Realtime.DatabaseTest do assert log =~ "project=123 external_id=123 [error] ErrorExecutingTransaction" end + test "handles exit signals in transactions", %{db_conn: db_conn} do + assert capture_log(fn -> + assert {:error, {:exit, _}} = Database.transaction(db_conn, fn _conn -> exit(:test_exit) end) + end) =~ "ErrorExecutingTransaction" + end + test "run call using RPC", %{db_conn: db_conn} do assert {:ok, %{rows: [[1]]}} = Realtime.Rpc.enhanced_call( @@ -215,120 +234,20 @@ defmodule Realtime.DatabaseTest do end end - @aux_mod (quote do - defmodule DatabaseAux do - def checker(transaction_conn) do - Postgrex.query!(transaction_conn, "SELECT 1", []) - end - - def error(transaction_conn) do - Postgrex.query!(transaction_conn, "SELECT 1/0", []) - end - - def exception(_) do - raise RuntimeError, "💣" - end - end - end) - - Code.eval_quoted(@aux_mod) - - describe "transaction/1 in clustered mode" do - setup do - Connect.shutdown("dev_tenant") - # Waiting for :syn to "unregister" if the Connect process was up - Process.sleep(100) - :ok - end - - test "success call returns output" do - {:ok, node} = Clustered.start(@aux_mod) - {:ok, db_conn} = Rpc.call(node, Connect, :connect, ["dev_tenant", "us-east-1"]) - assert node(db_conn) == node - assert {:ok, %Postgrex.Result{rows: [[1]]}} = Database.transaction(db_conn, &DatabaseAux.checker/1) - end - - test "handles database errors" do - metadata = [external_id: "123", project: "123"] - {:ok, node} = Clustered.start(@aux_mod) - {:ok, db_conn} = Rpc.call(node, Connect, :connect, ["dev_tenant", "us-east-1"]) - assert node(db_conn) == node - - assert capture_log(fn -> - assert {:error, %Postgrex.Error{}} = Database.transaction(db_conn, &DatabaseAux.error/1, [], metadata) - # We have to wait for logs to be relayed to this node - Process.sleep(100) - end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:" - end - - test "handles exception" do - metadata = [external_id: "123", project: "123"] - {:ok, node} = Clustered.start(@aux_mod) - {:ok, db_conn} = Rpc.call(node, Connect, :connect, ["dev_tenant", "us-east-1"]) - assert node(db_conn) == node - - assert capture_log(fn -> - assert {:error, %RuntimeError{}} = Database.transaction(db_conn, &DatabaseAux.exception/1, [], metadata) - # We have to wait for logs to be relayed to this node - Process.sleep(100) - end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:" - end - - test "db process is not alive anymore" do - metadata = [external_id: "123", project: "123", tenant_id: "123"] - {:ok, node} = Clustered.start(@aux_mod) - # Grab a remote pid that will not exist. :erpc uses a new process to perform the call. - # Once it has returned the process is not alive anymore - - pid = Rpc.call(node, :erlang, :self, []) - assert node(pid) == node - - assert capture_log(fn -> - assert {:error, {:exit, {:noproc, {DBConnection.Holder, :checkout, [^pid, []]}}}} = - Database.transaction(pid, &DatabaseAux.checker/1, [], metadata) - - # We have to wait for logs to be relayed to this node - Process.sleep(100) - end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:" - end - end - describe "pool_size_by_application_name/2" do test "returns the number of connections per application name" do assert Database.pool_size_by_application_name("realtime_connect", %{}) == 1 assert Database.pool_size_by_application_name("realtime_connect", %{"db_pool" => 10}) == 10 assert Database.pool_size_by_application_name("realtime_potato", %{}) == 1 assert Database.pool_size_by_application_name("realtime_rls", %{"db_pool" => 10}) == 1 - - assert Database.pool_size_by_application_name("realtime_rls", %{"subs_pool_size" => 10}) == - 1 - - assert Database.pool_size_by_application_name("realtime_rls", %{"subcriber_pool_size" => 10}) == - 1 - - assert Database.pool_size_by_application_name("realtime_broadcast_changes", %{ - "db_pool" => 10 - }) == 1 - - assert Database.pool_size_by_application_name("realtime_broadcast_changes", %{ - "subs_pool_size" => 10 - }) == 1 - - assert Database.pool_size_by_application_name("realtime_broadcast_changes", %{ - "subcriber_pool_size" => 10 - }) == 1 - - assert Database.pool_size_by_application_name("realtime_migrations", %{ - "db_pool" => 10 - }) == 2 - - assert Database.pool_size_by_application_name("realtime_migrations", %{ - "subs_pool_size" => 10 - }) == 2 - - assert Database.pool_size_by_application_name("realtime_migrations", %{ - "subcriber_pool_size" => 10 - }) == 2 + assert Database.pool_size_by_application_name("realtime_rls", %{"subs_pool_size" => 10}) == 1 + assert Database.pool_size_by_application_name("realtime_rls", %{"subcriber_pool_size" => 10}) == 1 + assert Database.pool_size_by_application_name("realtime_broadcast_changes", %{"db_pool" => 10}) == 1 + assert Database.pool_size_by_application_name("realtime_broadcast_changes", %{"subs_pool_size" => 10}) == 1 + assert Database.pool_size_by_application_name("realtime_broadcast_changes", %{"subcriber_pool_size" => 10}) == 1 + assert Database.pool_size_by_application_name("realtime_migrations", %{"db_pool" => 10}) == 2 + assert Database.pool_size_by_application_name("realtime_migrations", %{"subs_pool_size" => 10}) == 2 + assert Database.pool_size_by_application_name("realtime_migrations", %{"subcriber_pool_size" => 10}) == 2 end end @@ -347,10 +266,7 @@ defmodule Realtime.DatabaseTest do # Using ipv6.google.com assert Realtime.Database.detect_ip_version("ipv6.google.com") == {:ok, :inet6} - - # Using 2001:0db8:85a3:0000:0000:8a2e:0370:7334 - assert Realtime.Database.detect_ip_version("2001:0db8:85a3:0000:0000:8a2e:0370:7334") == - {:ok, :inet6} + assert Realtime.Database.detect_ip_version("2001:0db8:85a3:0000:0000:8a2e:0370:7334") == {:ok, :inet6} # Using 127.0.0.1 assert Realtime.Database.detect_ip_version("127.0.0.1") == {:ok, :inet} @@ -360,14 +276,28 @@ defmodule Realtime.DatabaseTest do end end + describe "from_tenant/3" do + test "uses default backoff when not provided", %{tenant: tenant} do + {:ok, settings} = Database.from_tenant(tenant, "realtime_test") + assert settings.backoff_type == :rand_exp + end + end + describe "from_settings/3" do + test "uses default backoff when not provided", %{tenant: tenant} do + settings = Realtime.PostgresCdc.filter_settings("postgres_cdc_rls", tenant.extensions) + {:ok, result} = Database.from_settings(settings, "realtime_connect") + assert result.backoff_type == :rand_exp + end + test "returns struct with correct setup", %{tenant: tenant} do application_name = "realtime_connect" backoff = :stop {:ok, ip_version} = Database.detect_ip_version("127.0.0.1") socket_options = [ip_version] settings = Realtime.PostgresCdc.filter_settings("postgres_cdc_rls", tenant.extensions) - settings = Database.from_settings(settings, application_name, backoff) + username = System.get_env("DB_USER_REALTIME", "supabase_realtime_admin") + {:ok, settings} = Database.from_settings(settings, application_name, backoff) port = settings.port assert %Realtime.Database{ @@ -377,7 +307,7 @@ defmodule Realtime.DatabaseTest do hostname: "127.0.0.1", port: ^port, database: "postgres", - username: "supabase_admin", + username: ^username, password: "postgres", pool_size: 1, queue_target: 5000, @@ -386,29 +316,124 @@ defmodule Realtime.DatabaseTest do } = settings end + test "defaults ssl to true when ssl_enforced is not set" do + assert Database.default_ssl_param(%{}) + assert Database.default_ssl_param(%{"other" => "value"}) + end + test "handles SSL properties", %{tenant: tenant} do application_name = "realtime_connect" backoff = :stop settings = Realtime.PostgresCdc.filter_settings("postgres_cdc_rls", tenant.extensions) settings = Map.put(settings, "ssl_enforced", true) - settings = Database.from_settings(settings, application_name, backoff) + {:ok, settings} = Database.from_settings(settings, application_name, backoff) assert settings.ssl == [verify: :verify_none] settings = Realtime.PostgresCdc.filter_settings("postgres_cdc_rls", tenant.extensions) settings = Map.put(settings, "ssl_enforced", false) - settings = Database.from_settings(settings, application_name, backoff) - assert settings.ssl == false + {:ok, settings} = Database.from_settings(settings, application_name, backoff) + refute settings.ssl + end + + test "runtime connections use db_user_realtime when present" do + settings = + encrypted_settings(%{ + "db_user_realtime" => Realtime.Crypto.encrypt!("supabase_realtime_admin"), + "db_pass_realtime" => Realtime.Crypto.encrypt!("realtime-pass") + }) + + assert {:ok, %{username: "supabase_realtime_admin", password: "realtime-pass"}} = + Database.from_settings(settings, "realtime_connect", :stop) + end + + test "runtime connections fall back to db_user when db_user_realtime is absent" do + assert {:ok, %{username: "supabase_admin", password: "super-pass"}} = + Database.from_settings(encrypted_settings(), "realtime_connect", :stop) + end + + test "realtime_migrations always uses db_user even when db_user_realtime is set" do + settings = + encrypted_settings(%{ + "db_user_realtime" => Realtime.Crypto.encrypt!("supabase_realtime_admin"), + "db_pass_realtime" => Realtime.Crypto.encrypt!("realtime-pass") + }) + + assert {:ok, %{username: "supabase_admin", password: "super-pass"}} = + Database.from_settings(settings, "realtime_migrations", :stop) end end - defp update_extension(tenant, extension) do - db_port = Realtime.Crypto.decrypt!(hd(tenant.extensions).settings["db_port"]) + describe "check_replication_slot_lag/2" do + setup %{tenant: tenant} do + {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) + suffix = System.unique_integer([:positive]) + slot_name = "test_lag_#{suffix}" + table_name = "lag_test_#{suffix}" + + Postgrex.query!(db_conn, "SELECT pg_create_logical_replication_slot($1, 'pgoutput')", [slot_name]) + Postgrex.query!(db_conn, "CREATE TABLE IF NOT EXISTS #{table_name} (id INT, data TEXT)", []) + + on_exit(fn -> + case Database.connect(tenant, "realtime_test_cleanup", :stop) do + {:ok, conn} -> + Postgrex.query(conn, "SELECT pg_drop_replication_slot($1)", [slot_name]) + Postgrex.query(conn, "DROP TABLE IF EXISTS #{table_name} CASCADE", []) + GenServer.stop(conn) + + _ -> + :ok + end + end) + + %{db_conn: db_conn, slot_name: slot_name, table_name: table_name} + end - extensions = [ - put_in(extension, ["settings", "db_port"], db_port) - ] + test "returns :ok when slot lag is below threshold", %{db_conn: db_conn, slot_name: slot_name} do + assert :ok == Database.check_replication_slot_lag(db_conn, slot_name) + end + + test "returns :ok when slot lag is non-zero but below threshold", %{ + db_conn: db_conn, + slot_name: slot_name, + table_name: table_name + } do + # Generate ~40% of the 32MB max_slot_wal_keep_size (test container value) by inserting + # ~50k rows of 200 bytes each — produces roughly 12-13MB of WAL, safely under the 16MB + # (50%) shutdown threshold. The slot is inactive so restart_lsn stays pinned. + Postgrex.query!( + db_conn, + "INSERT INTO #{table_name} SELECT generate_series(1, 50000), repeat('x', 200)", + [] + ) - Realtime.Api.update_tenant(tenant, %{extensions: extensions}) + assert :ok == Database.check_replication_slot_lag(db_conn, slot_name) + end + + test "returns {:error, :lag_too_high} when slot is far behind", %{ + db_conn: db_conn, + slot_name: slot_name, + table_name: table_name + } do + # Generate >16MB of WAL (50% of the 32MB max_slot_wal_keep_size in test containers). + # The slot is inactive so restart_lsn stays pinned at creation LSN. + Postgrex.query!( + db_conn, + "INSERT INTO #{table_name} SELECT generate_series(1, 100000), repeat('x', 200)", + [] + ) + + assert {:error, :lag_too_high} == Database.check_replication_slot_lag(db_conn, slot_name) + end + + test "returns :ok for unknown slot", %{db_conn: db_conn} do + assert :ok == Database.check_replication_slot_lag(db_conn, "nonexistent_slot_xyz") + end + end + + defp update_extension(tenant, extension) do + db_port = Realtime.Crypto.decrypt!(hd(tenant.extensions).settings["db_port"]) + extensions = [put_in(extension, ["settings", "db_port"], db_port)] + Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions}) end end diff --git a/test/realtime/env_test.exs b/test/realtime/env_test.exs new file mode 100644 index 000000000..d2008e3aa --- /dev/null +++ b/test/realtime/env_test.exs @@ -0,0 +1,165 @@ +defmodule Realtime.EnvTest do + use ExUnit.Case, async: true + + alias Realtime.Env + + setup %{describe: describe, test: test_name} do + env = "REALTIME_ENV_TEST_#{describe}_#{test_name}" + on_exit(fn -> System.delete_env(env) end) + %{env: env} + end + + describe "get_integer/2" do + test "returns the default when env is unset", %{env: env} do + assert Env.get_integer(env, 10) == 10 + end + + test "returns nil when env is unset and no default is provided", %{env: env} do + assert Env.get_integer(env) == nil + end + + test "parses integer env values", %{env: env} do + System.put_env(env, "42") + assert Env.get_integer(env, 0) == 42 + end + + test "parses negative integer env values", %{env: env} do + System.put_env(env, "-7") + assert Env.get_integer(env, 0) == -7 + end + + test "raises on invalid integer env values", %{env: env} do + System.put_env(env, "12ms") + + assert_raise ArgumentError, ~r/env #{env} expected a Integer, got "12ms"/, fn -> + Env.get_integer(env, 0) + end + end + + test "raises when the default is not an integer or nil", %{env: env} do + assert_raise ArgumentError, + ~r/expected either Integer or empty \(nil\) as default value for env #{env}, got "10"/, + fn -> + Env.get_integer(env, "10") + end + end + end + + describe "get_charlist/2" do + test "returns the default when env is unset", %{env: env} do + assert Env.get_charlist(env, ~c"abc") == ~c"abc" + end + + test "returns env values as charlists", %{env: env} do + System.put_env(env, "127.0.0.1") + assert Env.get_charlist(env, ~c"0.0.0.0") == ~c"127.0.0.1" + end + + test "returns an empty charlist when env is empty", %{env: env} do + System.put_env(env, "") + assert Env.get_charlist(env, ~c"fallback") == ~c"" + end + + test "raises when the default is not a charlist", %{env: env} do + assert_raise ArgumentError, + ~r/expected a charlist as default value for env #{env}, got "abc"/, + fn -> + Env.get_charlist(env, "abc") + end + end + end + + describe "get_boolean/2" do + test "returns the default when env is unset", %{env: env} do + assert Env.get_boolean(env, true) == true + assert Env.get_boolean(env, false) == false + end + + test "parses truthy env values", %{env: env} do + System.put_env(env, "true") + assert Env.get_boolean(env, false) == true + + System.put_env(env, "1") + assert Env.get_boolean(env, false) == true + end + + test "parses falsy env values", %{env: env} do + System.put_env(env, "false") + assert Env.get_boolean(env, true) == false + + System.put_env(env, "0") + assert Env.get_boolean(env, true) == false + end + + test "normalizes whitespace and case before parsing", %{env: env} do + System.put_env(env, " TRUE ") + assert Env.get_boolean(env, false) == true + + System.put_env(env, " False ") + assert Env.get_boolean(env, true) == false + end + + test "raises on invalid boolean env values", %{env: env} do + System.put_env(env, "yes") + + assert_raise ArgumentError, ~r/env #{env} expected a boolean or 0\/1 values, got "yes"/, fn -> + Env.get_boolean(env, false) + end + end + + test "raises when the default is not a boolean", %{env: env} do + assert_raise ArgumentError, + ~r/expected a boolean as default value for env #{env}, got "false"/, + fn -> + Env.get_boolean(env, "false") + end + end + end + + describe "get_binary/2" do + test "returns the env value when present", %{env: env} do + System.put_env(env, "configured") + assert Env.get_binary(env, "default") == "configured" + end + + test "returns the default binary when env is unset", %{env: env} do + assert Env.get_binary(env, "default") == "default" + end + + test "evaluates lazy defaults when env is unset", %{env: env} do + assert Env.get_binary(env, fn -> "computed" end) == "computed" + end + + test "does not evaluate lazy defaults when env is set", %{env: env} do + System.put_env(env, "configured") + assert Env.get_binary(env, fn -> flunk("default function should not be called") end) == "configured" + end + end + + describe "get_list/2" do + test "returns the default when env is unset", %{env: env} do + assert Env.get_list(env, ["a", "b"]) == ["a", "b"] + end + + test "splits comma-separated env values", %{env: env} do + System.put_env(env, "a,b,c") + assert Env.get_list(env, []) == ["a", "b", "c"] + end + + test "trims whitespace around list entries", %{env: env} do + System.put_env(env, " a, b ,c ") + assert Env.get_list(env, []) == ["a", "b", "c"] + end + + test "preserves empty entries when env is empty", %{env: env} do + System.put_env(env, "") + assert Env.get_list(env, ["fallback"]) == [""] + end + + test "raises with a function clause when the default is not a list", %{env: env} do + assert_raise FunctionClauseError, fn -> + Env.get_list(env, "not-a-list") + end + end + end +end diff --git a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs index 5f341c134..942a97bc8 100644 --- a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs +++ b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs @@ -1,7 +1,6 @@ defmodule Realtime.Extensions.CdcRlsTest do - # async: false due to usage of dev_tenant - # Also global mimic mock - use RealtimeWeb.ChannelCase, async: false + # async: false due to global mimic mock + use Realtime.DataCase, async: false use Mimic import ExUnit.CaptureLog @@ -9,9 +8,10 @@ defmodule Realtime.Extensions.CdcRlsTest do setup :set_mimic_global alias Extensions.PostgresCdcRls + alias Extensions.PostgresCdcRls.ReplicationPoller + alias Extensions.PostgresCdcRls.Subscriptions alias PostgresCdcRls.SubscriptionManager alias Postgrex - alias Realtime.Api alias Realtime.Api.Tenant alias Realtime.Database alias Realtime.PostgresCdc @@ -23,77 +23,39 @@ defmodule Realtime.Extensions.CdcRlsTest do describe "Postgres extensions" do setup do tenant = Containers.checkout_tenant(run_migrations: true) - - {:ok, conn} = Database.connect(tenant, "realtime_test") - - Database.transaction(conn, fn db_conn -> - queries = [ - "drop table if exists public.test", - "drop publication if exists supabase_realtime_test", - "create sequence if not exists test_id_seq;", - """ - create table if not exists "public"."test" ( - "id" int4 not null default nextval('test_id_seq'::regclass), - "details" text, - primary key ("id")); - """, - "grant all on table public.test to anon;", - "grant all on table public.test to postgres;", - "grant all on table public.test to authenticated;", - "create publication supabase_realtime_test for all tables" - ] - - Enum.each(queries, &Postgrex.query!(db_conn, &1, [])) - end) + {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) + Integrations.setup_postgres_changes(conn) + GenServer.stop(conn) %Tenant{extensions: extensions, external_id: external_id} = tenant postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions) - args = Map.put(postgres_extension, "id", external_id) - - pg_change_params = [ - %{ - id: UUID.uuid1(), - params: %{"event" => "*", "schema" => "public"}, - channel_pid: self(), - claims: %{ - "exp" => System.system_time(:second) + 100_000, - "iat" => 0, - "ref" => "127.0.0.1", - "role" => "anon" - } - } - ] + args = %{"id" => external_id, "region" => postgres_extension["region"]} - ids = - Enum.map(pg_change_params, fn %{id: id, params: params} -> - {UUID.string_to_binary!(id), :erlang.phash2(params)} - end) - - topic = "realtime:test" - serializer = Phoenix.Socket.V1.JSONSerializer - - subscription_metadata = {:subscriber_fastlane, self(), serializer, ids, topic, external_id, true} - metadata = [metadata: subscription_metadata] - :ok = PostgresCdc.subscribe(PostgresCdcRls, pg_change_params, external_id, metadata) + pg_change_params = pubsub_subscribe(external_id) + RealtimeWeb.Endpoint.subscribe(Realtime.Syn.PostgresCdc.syn_topic(tenant.external_id)) # First time it will return nil PostgresCdcRls.handle_connect(args) # Wait for it to start - Process.sleep(3000) + assert_receive %{event: "ready"}, 1000 + + on_exit(fn -> PostgresCdcRls.handle_stop(external_id, 10_000) end) {:ok, response} = PostgresCdcRls.handle_connect(args) # Now subscribe to the Postgres Changes - {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params) + {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params, external_id) - on_exit(fn -> PostgresCdcRls.handle_stop(external_id, 10_000) end) + RealtimeWeb.Endpoint.unsubscribe(Realtime.Syn.PostgresCdc.syn_topic(tenant.external_id)) %{tenant: tenant} end - @tag skip: "Flaky test. When logger handle_sasl_reports is enabled this test doesn't break" - test "Check supervisor crash and respawn", %{tenant: tenant} do + test "supervisor crash must not respawn", %{tenant: tenant} do + scope = Realtime.Syn.PostgresCdc.scope(tenant.external_id) + sup = Enum.reduce_while(1..30, nil, fn _, acc -> - :syn.lookup(Extensions.PostgresCdcRls, tenant.external_id) + scope + |> :syn.lookup(tenant.external_id) |> case do :undefined -> Process.sleep(500) @@ -107,27 +69,22 @@ defmodule Realtime.Extensions.CdcRlsTest do assert Process.alive?(sup) Process.monitor(sup) - RealtimeWeb.Endpoint.subscribe(PostgresCdcRls.syn_topic(tenant.external_id)) + RealtimeWeb.Endpoint.subscribe(Realtime.Syn.PostgresCdc.syn_topic(tenant.external_id)) Process.exit(sup, :kill) - assert_receive {:DOWN, _, :process, ^sup, _reason}, 5000 - - assert_receive %{event: "ready"}, 5000 + scope_down = Atom.to_string(scope) <> "_down" - {sup2, _} = :syn.lookup(Extensions.PostgresCdcRls, tenant.external_id) + assert_receive {:DOWN, _, :process, ^sup, _reason}, 5000 + assert_receive %{event: ^scope_down} + refute_receive %{event: "ready"}, 1000 - assert(sup != sup2) - assert Process.alive?(sup2) + :undefined = :syn.lookup(Realtime.Syn.PostgresCdc.scope(tenant.external_id), tenant.external_id) end test "Subscription manager updates oids", %{tenant: tenant} do {subscriber_manager_pid, conn} = Enum.reduce_while(1..25, nil, fn _, acc -> case PostgresCdcRls.get_manager_conn(tenant.external_id) do - nil -> - Process.sleep(200) - {:cont, acc} - {:error, :wait} -> Process.sleep(200) {:cont, acc} @@ -144,16 +101,91 @@ defmodule Realtime.Extensions.CdcRlsTest do %{oids: oids2} = :sys.get_state(subscriber_manager_pid) assert !Map.equal?(oids, oids2) - Postgrex.query!(conn, "create publication supabase_realtime_test for all tables", []) + # `for all tables` requires superuser + Postgrex.query!(conn, "create publication supabase_realtime_test for table public.test", []) send(subscriber_manager_pid, :check_oids) %{oids: oids3} = :sys.get_state(subscriber_manager_pid) assert !Map.equal?(oids2, oids3) end + test "Replication poller toggles slot when publication tables come and go", %{tenant: tenant} do + # setup/0 already received the "ready" event, which fires only after the poller's init/1 + # (and its Registry.register) has run. :sys.get_state below then blocks until the poller + # finishes handle_continue and has its slot prepared. + [{poller_pid, _}] = Registry.lookup(ReplicationPoller.Registry, tenant.external_id) + + # Use the SubscriptionManager pub connection to drive publication state from the test — + # the poller's own conn is owned by the poller process. + {:ok, _manager_pid, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + %{oids: initial_oids, slot_name: slot_name} = :sys.get_state(poller_pid) + refute initial_oids == %{} + + assert %Postgrex.Result{rows: [[1]]} = + Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name]) + + # Drop the publication: poller should drop its slot and clear oids. + Postgrex.query!(conn, "drop publication if exists supabase_realtime_test", []) + send(poller_pid, :check_oids) + %{oids: oids_after_drop, poll_ref: poll_ref_after_drop} = :sys.get_state(poller_pid) + assert oids_after_drop == %{} + assert poll_ref_after_drop == nil + + assert %Postgrex.Result{rows: [[0]]} = + Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name]) + + # Re-create the publication: poller should recreate the slot and repopulate oids. + # Use an explicit table (not FOR ALL TABLES, which requires superuser). + Postgrex.query!(conn, "create publication supabase_realtime_test for table public.test", []) + send(poller_pid, :check_oids) + %{oids: oids_after_create} = :sys.get_state(poller_pid) + refute oids_after_create == %{} + + assert %Postgrex.Result{rows: [[1]]} = + Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name]) + end + + test "Replication poller toggles slot when tables are removed from the publication", %{tenant: tenant} do + [{poller_pid, _}] = Registry.lookup(ReplicationPoller.Registry, tenant.external_id) + {:ok, _manager_pid, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id) + + %{oids: initial_oids, slot_name: slot_name} = :sys.get_state(poller_pid) + refute initial_oids == %{} + + assert %Postgrex.Result{rows: [[1]]} = + Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name]) + + # Publication still exists but has no tables (recreated without FOR ALL TABLES + # since you can't ALTER ... DROP TABLE on a FOR ALL TABLES publication). + Postgrex.query!(conn, "drop publication if exists supabase_realtime_test", []) + Postgrex.query!(conn, "create publication supabase_realtime_test", []) + + send(poller_pid, :check_oids) + %{oids: oids_after_empty, poll_ref: poll_ref_after_empty} = :sys.get_state(poller_pid) + assert oids_after_empty == %{} + assert poll_ref_after_empty == nil + + assert %Postgrex.Result{rows: [[0]]} = + Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name]) + + # Add a table back to the publication: poller should recreate the slot and repopulate oids. + Postgrex.query!(conn, "alter publication supabase_realtime_test add table public.test", []) + + send(poller_pid, :check_oids) + %{oids: oids_after_add} = :sys.get_state(poller_pid) + refute oids_after_add == %{} + + assert %Postgrex.Result{rows: [[1]]} = + Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name]) + end + test "Stop tenant supervisor", %{tenant: tenant} do sup = Enum.reduce_while(1..10, nil, fn _, acc -> - case :syn.lookup(Extensions.PostgresCdcRls, tenant.external_id) do + tenant.external_id + |> Realtime.Syn.PostgresCdc.scope() + |> :syn.lookup(tenant.external_id) + |> case do :undefined -> Process.sleep(500) {:cont, acc} @@ -169,16 +201,63 @@ defmodule Realtime.Extensions.CdcRlsTest do end end + describe "handle_after_connect/4" do + setup do + tenant = Containers.checkout_tenant(run_migrations: true) + %{tenant: tenant} + end + + test "rate counter raises exception returns error", %{tenant: tenant} do + %Tenant{extensions: extensions, external_id: external_id} = tenant + postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions) + + stub(RateCounter, :get, fn _args -> raise "unexpected RateCounter failure" end) + + import ExUnit.CaptureLog + + log = + capture_log(fn -> + assert {:error, "Too many database timeouts"} = + PostgresCdcRls.handle_after_connect({:manager_pid, self()}, postgres_extension, %{}, external_id) + end) + + assert log =~ "RateCounterError" + end + + test "subscription error rate limit", %{tenant: tenant} do + %Tenant{extensions: extensions, external_id: external_id} = tenant + postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions) + + stub(Subscriptions, :create, fn _conn, _publication, _subscription_list, _manager, _caller -> + {:error, %DBConnection.ConnectionError{}} + end) + + # Now try to subscribe to the Postgres Changes + for _x <- 1..6 do + assert {:error, "Too many database timeouts"} = + PostgresCdcRls.handle_after_connect({:manager_pid, self()}, postgres_extension, %{}, external_id) + end + + rate = Realtime.Tenants.subscription_errors_per_second_rate(external_id, 4) + + assert {:ok, %RateCounter{id: {:channel, :subscription_errors, ^external_id}, sum: 6, limit: %{triggered: true}}} = + RateCounterHelper.tick!(rate) + + # It won't even be called now + reject(&Subscriptions.create/5) + + assert {:error, "Too many database timeouts"} = + PostgresCdcRls.handle_after_connect({:manager_pid, self()}, postgres_extension, %{}, external_id) + end + end + describe "Region rebalancing" do setup do tenant = Containers.checkout_tenant(run_migrations: true) %Tenant{extensions: extensions, external_id: external_id} = tenant postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions) - args = - postgres_extension - |> Map.put("id", external_id) - |> Map.put(:check_region_interval, 100) + args = %{"id" => external_id, "region" => postgres_extension["region"], check_region_interval: 100} %{tenant_id: tenant.external_id, args: args} end @@ -208,98 +287,63 @@ defmodule Realtime.Extensions.CdcRlsTest do end describe "integration" do - setup do - tenant = Api.get_tenant_by_external_id("dev_tenant") - PostgresCdcRls.handle_stop(tenant.external_id, 10_000) - - {:ok, conn} = Database.connect(tenant, "realtime_test") - - Database.transaction(conn, fn db_conn -> - queries = [ - "drop table if exists public.test", - "drop publication if exists supabase_realtime_test", - "create sequence if not exists test_id_seq;", - """ - create table if not exists "public"."test" ( - "id" int4 not null default nextval('test_id_seq'::regclass), - "details" text, - primary key ("id")); - """, - "grant all on table public.test to anon;", - "grant all on table public.test to postgres;", - "grant all on table public.test to authenticated;", - "create publication supabase_realtime_test for all tables" - ] - - Enum.each(queries, &Postgrex.query!(db_conn, &1, [])) - end) + setup [:integration] - RateCounter.stop(tenant.external_id) - - %{tenant: tenant, conn: conn} - end - - test "subscribe inserts", %{tenant: tenant, conn: conn} do + test "subscribe inserts only", %{tenant: tenant, conn: conn} do on_exit(fn -> PostgresCdcRls.handle_stop(tenant.external_id, 10_000) end) %Tenant{extensions: extensions, external_id: external_id} = tenant postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions) - args = Map.put(postgres_extension, "id", external_id) - - pg_change_params = [ - %{ - id: UUID.uuid1(), - params: %{"event" => "*", "schema" => "public"}, - channel_pid: self(), - claims: %{ - "exp" => System.system_time(:second) + 100_000, - "iat" => 0, - "ref" => "127.0.0.1", - "role" => "anon" - } - } - ] - - ids = - Enum.map(pg_change_params, fn %{id: id, params: params} -> - {UUID.string_to_binary!(id), :erlang.phash2(params)} - end) + args = %{"id" => external_id, "region" => postgres_extension["region"]} - topic = "realtime:test" - serializer = Phoenix.Socket.V1.JSONSerializer - - subscription_metadata = {:subscriber_fastlane, self(), serializer, ids, topic, external_id, true} - metadata = [metadata: subscription_metadata] - :ok = PostgresCdc.subscribe(PostgresCdcRls, pg_change_params, external_id, metadata) + pg_change_params = pubsub_subscribe(external_id, "INSERT") # First time it will return nil PostgresCdcRls.handle_connect(args) # Wait for it to start - Process.sleep(3000) + assert_receive %{event: "ready"}, 3000 {:ok, response} = PostgresCdcRls.handle_connect(args) + assert_receive { + :telemetry, + [:realtime, :rpc], + %{latency: _}, + %{ + mechanism: :gen_rpc, + success: true + } + } + # Now subscribe to the Postgres Changes - {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params) - assert %Postgrex.Result{rows: [[1]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + Postgrex.query!(conn, "delete from realtime.subscription", []) + {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params, external_id) + + assert %Postgrex.Result{num_rows: n} = Postgrex.query!(conn, "select id from realtime.subscription", []) + assert n >= 1 + + Process.sleep(500) # Insert a record %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) + # Delete the record + %{num_rows: 1} = Postgrex.query!(conn, "delete from test", []) assert_receive {:socket_push, :text, data}, 5000 - - message = - data - |> IO.iodata_to_binary() - |> Jason.decode!() + # No DELETE should be received + refute_receive {:socket_push, :text, _data}, 1000 assert %{ "event" => "postgres_changes", "payload" => %{ "data" => %{ - "columns" => [%{"name" => "id", "type" => "int4"}, %{"name" => "details", "type" => "text"}], + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"}, + %{"name" => "binary_data", "type" => "bytea"} + ], "commit_timestamp" => _, "errors" => nil, - "record" => %{"details" => "test", "id" => ^id}, + "record" => %{"details" => "test", "id" => ^id, "binary_data" => nil}, "schema" => "public", "table" => "test", "type" => "INSERT" @@ -308,110 +352,280 @@ defmodule Realtime.Extensions.CdcRlsTest do }, "ref" => nil, "topic" => "realtime:test" - } = message + } = Jason.decode!(data) - # Wait for RateCounter to update - Process.sleep(2000) + rate = Realtime.Tenants.db_events_per_second_rate(tenant) + + assert {:ok, %RateCounter{id: {:channel, :db_events, ^external_id}, bucket: bucket}} = + RateCounterHelper.tick!(rate) + + assert Enum.sum(bucket) == 1 + + assert_receive { + :telemetry, + [:realtime, :tenants, :payload, :size], + %{size: _}, + %{tenant: ^external_id, message_type: :postgres_changes} + } + end + + test "db events rate limit works", %{tenant: tenant, conn: conn} do + on_exit(fn -> PostgresCdcRls.handle_stop(tenant.external_id, 10_000) end) + + %Tenant{extensions: extensions, external_id: external_id} = tenant + postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions) + args = %{"id" => external_id, "region" => postgres_extension["region"]} + + pg_change_params = pubsub_subscribe(external_id) + + # First time it will return nil + PostgresCdcRls.handle_connect(args) + # Wait for it to start + assert_receive %{event: "ready"}, 1000 + {:ok, response} = PostgresCdcRls.handle_connect(args) + + # Now subscribe to the Postgres Changes + Postgrex.query!(conn, "delete from realtime.subscription", []) + {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params, external_id) + assert %Postgrex.Result{rows: [[n]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + assert n >= 1 rate = Realtime.Tenants.db_events_per_second_rate(tenant) - assert {:ok, %RateCounter{id: {:channel, :db_events, "dev_tenant"}, bucket: bucket}} = RateCounter.get(rate) - assert 1 in bucket + log = + capture_log(fn -> + # increment artifically the counter to reach the limit + tenant.external_id + |> Realtime.Tenants.db_events_per_second_key() + |> Realtime.GenCounter.add(100_000_000) + + RateCounterHelper.tick!(rate) + end) + + assert log =~ "MessagePerSecondRateLimitReached: Too many postgres changes messages per second" + + # Insert a record + %{rows: [[_id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) + + refute_receive {:socket_push, :text, _}, 5000 + + assert {:ok, %RateCounter{id: {:channel, :db_events, ^external_id}, bucket: bucket, limit: %{triggered: true}}} = + RateCounterHelper.tick!(rate) + + # Nothing has changed + assert Enum.sum(bucket) == 100_000_000 end + end - @aux_mod (quote do - defmodule Subscriber do - # Start CDC remotely - def subscribe(tenant) do - %Tenant{extensions: extensions, external_id: external_id} = tenant - postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions) - args = Map.put(postgres_extension, "id", external_id) - - # Boot it - PostgresCdcRls.start(args) - # Wait for it to start - Process.sleep(3000) - {:ok, manager, conn} = PostgresCdcRls.get_manager_conn(external_id) - {:ok, {manager, conn}} - end + @aux_mod (quote do + defmodule Subscriber do + # Start CDC remotely + def subscribe(tenant) do + %Tenant{extensions: extensions, external_id: external_id} = tenant + postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions) + args = %{"id" => external_id, "region" => postgres_extension["region"]} + + RealtimeWeb.Endpoint.subscribe(Realtime.Syn.PostgresCdc.syn_topic(tenant.external_id)) + # First time it will return nil + PostgresCdcRls.start(args) + # Wait for it to start + assert_receive %{event: "ready"}, 3000 + {:ok, manager, conn} = PostgresCdcRls.get_manager_conn(external_id) + {:ok, {manager, conn}} end - end) + end + end) + describe "distributed integration" do + setup [:distributed_integration] - test "subscribe inserts distributed mode", %{tenant: tenant, conn: conn} do + setup(%{tenant: tenant}) do {:ok, node} = Clustered.start(@aux_mod) {:ok, response} = :erpc.call(node, Subscriber, :subscribe, [tenant]) + on_exit(fn -> + try do + PostgresCdcRls.handle_stop(tenant.external_id, 5_000) + catch + _, _ -> :ok + end + end) + + %{node: node, response: response} + end + + test "subscribe distributed mode", %{tenant: tenant, conn: conn, node: node, response: response} do %Tenant{extensions: extensions, external_id: external_id} = tenant postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions) - pg_change_params = [ + pg_change_params = pubsub_subscribe(external_id) + + Postgrex.query!(conn, "delete from realtime.subscription", []) + {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params, external_id) + assert %Postgrex.Result{rows: [[n]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + assert n >= 1 + + # Wait for subscription to be executing + Process.sleep(200) + + # Insert a record + %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) + # Delete the record + %{num_rows: 1} = Postgrex.query!(conn, "delete from test", []) + + assert_receive {:socket_push, :text, data1}, 5000 + assert_receive {:socket_push, :text, data2}, 5000 + + events = Enum.map([data1, data2], &Jason.decode!/1) + + assert Enum.any?(events, fn event -> + match?( + %{ + "event" => "postgres_changes", + "payload" => %{ + "data" => %{ + "errors" => nil, + "record" => %{"details" => "test", "id" => ^id, "binary_data" => nil}, + "schema" => "public", + "table" => "test", + "type" => "INSERT" + } + }, + "ref" => nil, + "topic" => "realtime:test" + }, + event + ) + end) + + assert Enum.any?(events, fn event -> + match?( + %{ + "event" => "postgres_changes", + "payload" => %{ + "data" => %{ + "errors" => nil, + "type" => "DELETE", + "old_record" => %{"id" => ^id}, + "schema" => "public", + "table" => "test" + } + }, + "ref" => nil, + "topic" => "realtime:test" + }, + event + ) + end) + + assert_receive { + :telemetry, + [:realtime, :rpc], + %{latency: _}, %{ - id: UUID.uuid1(), - params: %{"event" => "*", "schema" => "public"}, - channel_pid: self(), - claims: %{ - "exp" => System.system_time(:second) + 100_000, - "iat" => 0, - "ref" => "127.0.0.1", - "role" => "anon" - } + mechanism: :gen_rpc, + origin_node: _, + success: true, + target_node: ^node } - ] + } + end - ids = - Enum.map(pg_change_params, fn %{id: id, params: params} -> - {UUID.string_to_binary!(id), :erlang.phash2(params)} - end) + test "subscription error rate limit", %{tenant: tenant, node: node} do + %Tenant{extensions: extensions, external_id: external_id} = tenant + postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions) - # Subscribe to the topic as a websocket client - topic = "realtime:test" - serializer = Phoenix.Socket.V1.JSONSerializer + pg_change_params = pubsub_subscribe(external_id) - subscription_metadata = {:subscriber_fastlane, self(), serializer, ids, topic, external_id, true} - metadata = [metadata: subscription_metadata] - :ok = PostgresCdc.subscribe(PostgresCdcRls, pg_change_params, external_id, metadata) + # Grab a process that is not alive to cause subscriptions to error out + pid = :erpc.call(node, :erlang, :self, []) - # Now subscribe to the Postgres Changes - {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params) - assert %Postgrex.Result{rows: [[1]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + # Now subscribe to the Postgres Changes multiple times to reach the rate limit + for _ <- 1..6 do + assert {:error, "Too many database timeouts"} = + PostgresCdcRls.handle_after_connect({pid, pid}, postgres_extension, pg_change_params, external_id) + end - # Insert a record - %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) + rate = Realtime.Tenants.subscription_errors_per_second_rate(external_id, 4) - assert_receive {:socket_push, :text, data}, 5000 + assert {:ok, %RateCounter{id: {:channel, :subscription_errors, ^external_id}, limit: %{triggered: true}}} = + RateCounterHelper.tick!(rate) - message = - data - |> IO.iodata_to_binary() - |> Jason.decode!() + # It won't even be called now + reject(&Realtime.GenRpc.call/5) - assert %{ - "event" => "postgres_changes", - "payload" => %{ - "data" => %{ - "columns" => [%{"name" => "id", "type" => "int4"}, %{"name" => "details", "type" => "text"}], - "commit_timestamp" => _, - "errors" => nil, - "record" => %{"details" => "test", "id" => ^id}, - "schema" => "public", - "table" => "test", - "type" => "INSERT" - }, - "ids" => _ - }, - "ref" => nil, - "topic" => "realtime:test" - } = message + assert {:error, "Too many database timeouts"} = + PostgresCdcRls.handle_after_connect({pid, pid}, postgres_extension, pg_change_params, external_id) + end + end - # Wait for RateCounter to update - Process.sleep(2000) + defp integration(_) do + tenant = Containers.checkout_tenant(run_migrations: true) + {:ok, conn} = Database.connect(tenant, "realtime_test") + Integrations.setup_postgres_changes(conn) - rate = Realtime.Tenants.db_events_per_second_rate(tenant) + on_exit(fn -> RateCounterHelper.stop(tenant.external_id) end) + on_exit(fn -> :telemetry.detach(__MODULE__) end) - assert {:ok, %RateCounter{id: {:channel, :db_events, "dev_tenant"}, bucket: bucket}} = RateCounter.get(rate) - assert 1 in bucket + :telemetry.attach_many( + __MODULE__, + [[:realtime, :tenants, :payload, :size], [:realtime, :rpc]], + &__MODULE__.handle_telemetry/4, + pid: self() + ) - :erpc.call(node, PostgresCdcRls, :handle_stop, [tenant.external_id, 10_000]) - end + RealtimeWeb.Endpoint.subscribe(Realtime.Syn.PostgresCdc.syn_topic(tenant.external_id)) + + %{tenant: tenant, conn: conn} + end + + defp distributed_integration(_) do + tenant = Containers.checkout_tenant_unboxed(run_migrations: true) + {:ok, conn} = Database.connect(tenant, "realtime_test") + Integrations.setup_postgres_changes(conn) + + on_exit(fn -> RateCounterHelper.stop(tenant.external_id) end) + on_exit(fn -> :telemetry.detach(__MODULE__) end) + + :telemetry.attach_many( + __MODULE__, + [[:realtime, :tenants, :payload, :size], [:realtime, :rpc]], + &__MODULE__.handle_telemetry/4, + pid: self() + ) + + RealtimeWeb.Endpoint.subscribe(Realtime.Syn.PostgresCdc.syn_topic(tenant.external_id)) + + %{tenant: tenant, conn: conn} + end + + defp pubsub_subscribe(external_id, event \\ "*") do + pg_change_params = [ + %{ + id: UUID.uuid1(), + params: %{"event" => event, "schema" => "public"}, + channel_pid: self(), + claims: %{ + "exp" => System.system_time(:second) + 100_000, + "iat" => 0, + "ref" => "127.0.0.1", + "role" => "anon" + } + } + ] + + topic = "realtime:test" + serializer = Phoenix.Socket.V1.JSONSerializer + + ids = + Enum.map(pg_change_params, fn %{id: id, params: params} -> + {UUID.string_to_binary!(id), :erlang.phash2(params)} + end) + + subscription_metadata = {:subscriber_fastlane, self(), serializer, ids, topic, true} + metadata = [metadata: subscription_metadata] + :ok = PostgresCdc.subscribe(PostgresCdcRls, pg_change_params, external_id, metadata) + pg_change_params end + + def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {:telemetry, event, measures, metadata}) end diff --git a/test/realtime/extensions/cdc_rls/replication_poller_test.exs b/test/realtime/extensions/cdc_rls/replication_poller_test.exs index 97d69af62..8ae7f3152 100644 --- a/test/realtime/extensions/cdc_rls/replication_poller_test.exs +++ b/test/realtime/extensions/cdc_rls/replication_poller_test.exs @@ -1,8 +1,13 @@ -defmodule ReplicationPollerTest do - use ExUnit.Case, async: false +defmodule Realtime.Extensions.PostgresCdcRls.ReplicationPollerTest do + # Tweaking application env + use Realtime.DataCase, async: false + use Mimic + + alias Extensions.PostgresCdcRls.MessageDispatcher alias Extensions.PostgresCdcRls.ReplicationPoller, as: Poller - import Poller, only: [generate_record: 1] + alias Extensions.PostgresCdcRls.Replications + alias Extensions.PostgresCdcRls.Subscriptions alias Realtime.Adapters.Changes.{ DeletedRecord, @@ -10,6 +15,572 @@ defmodule ReplicationPollerTest do UpdatedRecord } + alias Realtime.Database + alias Realtime.RateCounter + + alias RealtimeWeb.TenantBroadcaster + + import Poller, only: [generate_record: 1] + + setup :set_mimic_global + + @change_json ~s({"table":"test","type":"INSERT","record":{"id": 34, "details": "test"},"columns":[{"name": "id", "type": "int4"}, {"name": "details", "type": "text"}],"errors":null,"schema":"public","commit_timestamp":"2025-10-13T07:50:28.066Z"}) + + describe "poll" do + setup do + :telemetry.attach_many( + __MODULE__, + [ + [:realtime, :replication, :poller, :query, :stop], + [:realtime, :replication, :poller, :query, :exception], + [:realtime, :replication, :poller, :prepare, :exception], + [:realtime, :replication, :poller, :stop], + [:realtime, :replication, :poller, :exception], + [:realtime, :replication, :poller, :changes, :dispatch], + [:realtime, :replication, :poller, :changes, :skip] + ], + &__MODULE__.handle_telemetry/4, + pid: self() + ) + + on_exit(fn -> :telemetry.detach(__MODULE__) end) + + tenant = Containers.checkout_tenant(run_migrations: true) + + {:ok, tenant} = Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{"max_events_per_second" => 123}) + + subscribers_pids_table = :ets.new(__MODULE__, [:public, :bag]) + subscribers_nodes_table = :ets.new(__MODULE__, [:public, :set]) + + args = + hd(tenant.extensions).settings + |> Map.put("id", tenant.external_id) + |> Map.put("subscribers_pids_table", subscribers_pids_table) + |> Map.put("subscribers_nodes_table", subscribers_nodes_table) + + # unless specified it will return empty results + empty_results = {:ok, %Postgrex.Result{rows: [], num_rows: 0}} + stub(Replications, :list_changes, fn _, _, _, _, _ -> empty_results end) + + # Default to a publication with tables so the poller actually polls. + # Tests that need an empty publication override this stub explicitly. + stub(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{{"public", "test"} => [1234]}} end) + + %{args: args, tenant: tenant} + end + + test "handles prepare_replication failure and retries", %{args: args} do + tenant_id = args["id"] + + stub(Replications, :prepare_replication, fn _, _ -> {:ok, %Postgrex.Result{}} end) + expect(Replications, :prepare_replication, fn _, _ -> {:error, "prepare failed"} end) + + start_link_supervised!({Poller, args}) + + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 2000 + end + + test "gives up and stops when prepare_replication keeps failing", %{args: args} do + stub(Replications, :prepare_replication, fn _, _ -> {:error, "prepare failed"} end) + + pid = start_supervised!({Poller, args}, restart: :temporary) + ref = Process.monitor(pid) + + # Drive the retry count to the limit, then trigger one more failing prepare + :sys.replace_state(pid, fn state -> %{state | retry_count: 6} end) + send(pid, :retry) + + assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, :max_retries_reached}}, 1000 + end + + test "a fetch error on the prepare path goes through the retry machinery", %{args: args} do + # Start idle so no slot or poll loop is running. + expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end) + + pid = start_supervised!({Poller, args}, restart: :temporary) + ref = Process.monitor(pid) + + # A fetch error while preparing is treated like a prepare failure: at the retry + # limit, one more failing prepare stops the poller. + expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:error, :boom} end) + :sys.replace_state(pid, fn state -> %{state | retry_count: 6} end) + send(pid, :retry) + + assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, :max_retries_reached}}, 1000 + end + + test "terminates replication slot when retry count exceeds threshold", %{args: args} do + tenant_id = args["id"] + + slot_in_use_error = + {:error, + %Postgrex.Error{ + postgres: %{ + code: :object_in_use, + message: "replication slot is active for PID 12345" + } + }} + + stub(Replications, :get_pg_stat_activity_diff, fn _conn, _pid -> {:ok, 42} end) + stub(Replications, :list_changes, fn _, _, _, _, _ -> slot_in_use_error end) + expect(Replications, :terminate_backend, fn _conn, _slot -> {:ok, :terminated} end) + + pid = start_link_supervised!({Poller, args}) + + # Wait for the first poll + assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 1000 + + # Advance retry_count past threshold and send another poll + :sys.replace_state(pid, fn state -> %{state | retry_count: 4} end) + send(pid, :poll) + + assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 2000 + end + + test "gives up and stops after max retries", %{args: args} do + tenant_id = args["id"] + error = {:error, %Postgrex.Error{message: "boom"}} + stub(Replications, :list_changes, fn _, _, _, _, _ -> error end) + + pid = start_supervised!({Poller, args}, restart: :temporary) + ref = Process.monitor(pid) + + # Drive the retry count to the limit, then trigger one more failing poll + :sys.replace_state(pid, fn state -> %{state | retry_count: 6} end) + send(pid, :poll) + + assert_receive {:telemetry, [:realtime, :replication, :poller, :stop], %{duration: _}, + %{tenant: ^tenant_id, reason: {:shutdown, :max_retries_reached}}}, + 1000 + + assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, :max_retries_reached}}, 1000 + end + + test "handles no new changes", %{args: args, tenant: tenant} do + tenant_id = args["id"] + reject(&TenantBroadcaster.pubsub_direct_broadcast/6) + reject(&TenantBroadcaster.pubsub_broadcast/5) + start_link_supervised!({Poller, args}) + + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + rate = Realtime.Tenants.db_events_per_second_rate(tenant) + + assert {:ok, + %RateCounter{ + sum: sum, + limit: %{ + value: 123, + measurement: :avg, + triggered: false + } + }} = RateCounterHelper.tick!(rate) + + assert sum == 0 + end + + test "handles new changes with missing ets table", %{args: args, tenant: tenant} do + tenant_id = args["id"] + + :ets.delete(args["subscribers_nodes_table"]) + + results = + build_result([ + <<71, 36, 83, 212, 168, 9, 17, 240, 165, 186, 118, 202, 193, 157, 232, 187>>, + <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>> + ]) + + expect(Replications, :list_changes, fn _, _, _, _, _ -> results end) + reject(&TenantBroadcaster.pubsub_direct_broadcast/6) + + # Broadcast to the whole cluster due to missing node information + expect(TenantBroadcaster, :pubsub_broadcast, fn ^tenant_id, + "realtime:postgres:" <> ^tenant_id, + {"INSERT", change_json, _sub_ids}, + MessageDispatcher, + :postgres_changes -> + assert Jason.decode!(change_json) == Jason.decode!(@change_json) + :ok + end) + + start_link_supervised!({Poller, args}) + + # First poll with changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + # Second poll without changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + rate = Realtime.Tenants.db_events_per_second_rate(tenant) + assert {:ok, %RateCounter{sum: sum}} = RateCounterHelper.tick!(rate) + assert sum == 2 + end + + test "handles new changes with no subscription nodes", %{args: args, tenant: tenant} do + tenant_id = args["id"] + + results = + build_result([ + <<71, 36, 83, 212, 168, 9, 17, 240, 165, 186, 118, 202, 193, 157, 232, 187>>, + <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>> + ]) + + expect(Replications, :list_changes, fn _, _, _, _, _ -> results end) + reject(&TenantBroadcaster.pubsub_direct_broadcast/6) + + # Broadcast to the whole cluster due to missing node information + expect(TenantBroadcaster, :pubsub_broadcast, fn ^tenant_id, + "realtime:postgres:" <> ^tenant_id, + {"INSERT", change_json, _sub_ids}, + MessageDispatcher, + :postgres_changes -> + assert Jason.decode!(change_json) == Jason.decode!(@change_json) + :ok + end) + + start_link_supervised!({Poller, args}) + + # First poll with changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + # Second poll without changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + rate = Realtime.Tenants.db_events_per_second_rate(tenant) + assert {:ok, %RateCounter{sum: sum}} = RateCounterHelper.tick!(rate) + assert sum == 2 + end + + test "handles new changes with missing subscription nodes", %{args: args, tenant: tenant} do + tenant_id = args["id"] + + results = + build_result([ + sub1 = <<71, 36, 83, 212, 168, 9, 17, 240, 165, 186, 118, 202, 193, 157, 232, 187>>, + <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>> + ]) + + :ets.insert(args["subscribers_nodes_table"], {sub1, node()}) + + expect(Replications, :list_changes, fn _, _, _, _, _ -> results end) + reject(&TenantBroadcaster.pubsub_direct_broadcast/6) + + # Broadcast to the whole cluster due to missing node information + expect(TenantBroadcaster, :pubsub_broadcast, fn ^tenant_id, + "realtime:postgres:" <> ^tenant_id, + {"INSERT", change_json, _sub_ids}, + MessageDispatcher, + :postgres_changes -> + assert Jason.decode!(change_json) == Jason.decode!(@change_json) + :ok + end) + + start_link_supervised!({Poller, args}) + + # First poll with changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + # Second poll without changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + rate = Realtime.Tenants.db_events_per_second_rate(tenant) + assert {:ok, %RateCounter{sum: sum}} = RateCounterHelper.tick!(rate) + assert sum == 2 + end + + test "handles new changes with subscription nodes information", %{args: args, tenant: tenant} do + tenant_id = args["id"] + + results = + build_result([ + sub1 = <<71, 36, 83, 212, 168, 9, 17, 240, 165, 186, 118, 202, 193, 157, 232, 187>>, + sub2 = <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>>, + sub3 = <<49, 59, 209, 112, 173, 77, 17, 240, 191, 41, 118, 202, 193, 157, 232, 187>> + ]) + + # All subscriptions have node information + :ets.insert(args["subscribers_nodes_table"], {sub1, node()}) + :ets.insert(args["subscribers_nodes_table"], {sub2, :"someothernode@127.0.0.1"}) + :ets.insert(args["subscribers_nodes_table"], {sub3, node()}) + + expect(Replications, :list_changes, fn _, _, _, _, _ -> results end) + reject(&TenantBroadcaster.pubsub_broadcast/5) + + topic = "realtime:postgres:" <> tenant_id + + # # Broadcast to the exact nodes only + expect(TenantBroadcaster, :pubsub_direct_broadcast, 2, fn + _node, ^tenant_id, ^topic, {"INSERT", change_json, _sub_ids}, MessageDispatcher, :postgres_changes -> + assert Jason.decode!(change_json) == Jason.decode!(@change_json) + :ok + end) + + start_link_supervised!({Poller, args}) + + # First poll with changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + # Second poll without changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + calls = calls(TenantBroadcaster, :pubsub_direct_broadcast, 6) + + assert Enum.count(calls) == 2 + + node_subs = Enum.map(calls, fn [node, _, _, {"INSERT", _change_json, sub_ids}, _, _] -> {node, sub_ids} end) + + assert {node(), MapSet.new([sub1, sub3])} in node_subs + assert {:"someothernode@127.0.0.1", MapSet.new([sub2])} in node_subs + + rate = Realtime.Tenants.db_events_per_second_rate(tenant) + assert {:ok, %RateCounter{sum: sum}} = RateCounterHelper.tick!(rate) + assert sum == 3 + end + + test "does not poll WAL when publication has no tables", %{args: args} do + tenant_id = args["id"] + + expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end) + reject(&Replications.list_changes/5) + + start_link_supervised!({Poller, args}) + + refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200 + end + + test "drops replication slot and stops polling when tables vanish", %{args: args} do + tenant_id = args["id"] + + expect(Replications, :drop_replication_slot, fn _conn, _slot -> {:ok, :dropped} end) + + pid = start_link_supervised!({Poller, args}) + + # First poll happens with the default non-empty stub. + assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 500 + + expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end) + reject(&Replications.list_changes/5) + + send(pid, :check_oids) + # Force the GenServer to process :check_oids before we assert. + :sys.get_state(pid) + + refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200 + end + + test "cancels a pending retry when tables vanish so it can't recreate the slot", %{args: args} do + tenant_id = args["id"] + + expect(Replications, :drop_replication_slot, fn _conn, _slot -> {:ok, :dropped} end) + + pid = start_link_supervised!({Poller, args}) + + # First poll happens with the default non-empty stub. + assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 500 + + # Simulate a retry already scheduled from a prior list_changes/5 error. + :sys.replace_state(pid, fn state -> + %{state | retry_ref: Process.send_after(pid, :retry, 50), retry_count: 3} + end) + + expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end) + reject(&Replications.list_changes/5) + + send(pid, :check_oids) + + # retry_ref is cancelled/cleared and retry_count reset; no :retry fires. + assert %{retry_ref: nil, retry_count: 0} = :sys.get_state(pid) + refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200 + end + + test "resumes polling when tables appear via :check_oids", %{args: args} do + tenant_id = args["id"] + + expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end) + + pid = start_link_supervised!({Poller, args}) + + refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200 + + # Tables are added to the publication. Next :check_oids should trigger + # prepare_replication + an initial poll. + expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{{"public", "test"} => [1234]}} end) + send(pid, :check_oids) + + assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 1000 + end + + test "a successful prepare resets a prior failure streak", %{args: args} do + tenant_id = args["id"] + + # Start idle (empty publication) so no slot exists yet. + expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end) + + pid = start_link_supervised!({Poller, args}) + + refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200 + + # Simulate a leftover failure streak from earlier prepare/list_changes errors: + # a pending :retry and an inflated retry_count. + :sys.replace_state(pid, fn state -> + %{state | retry_ref: Process.send_after(pid, :retry, 60_000), retry_count: 5} + end) + + # Tables appear: :check_oids re-runs prepare_replication, which succeeds and + # must wipe the failure streak. + expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{{"public", "test"} => [1234]}} end) + send(pid, :check_oids) + + assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 1000 + + assert %{retry_ref: nil, retry_count: 0} = :sys.get_state(pid) + end + + test "shuts down when slot drop fails so the temp slot is released with the connection", + %{args: args} do + pid = start_supervised!({Poller, args}, restart: :temporary) + + assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, _}, 500 + + expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end) + expect(Replications, :drop_replication_slot, fn _, _ -> {:error, :boom} end) + + ref = Process.monitor(pid) + send(pid, :check_oids) + + assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, :drop_replication_slot_failed}}, 1000 + end + + test "refreshes oids without touching the slot when the publication stays non-empty", %{args: args} do + tenant_id = args["id"] + + # While the publication keeps having tables the slot must never be dropped + # or recreated; :check_oids only refreshes the oid map in place. + reject(&Replications.drop_replication_slot/2) + + pid = start_link_supervised!({Poller, args}) + + # First poll happens with the default non-empty stub. + assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 500 + + # Publication still has tables but the oid set changed (e.g. a table was added). + new_oids = %{{"public", "test"} => [1234], {"public", "other"} => [5678]} + expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, new_oids} end) + + send(pid, :check_oids) + + # oids map is refreshed in place and the periodic check stays armed. + state = :sys.get_state(pid) + assert state.oids == new_oids + assert is_reference(state.check_oid_ref) + end + + test "keeps oids and the slot when :check_oids fetch errors", %{args: args} do + tenant_id = args["id"] + + # A fetch error must never be mistaken for an emptied publication, so the slot + # is left intact and the oid map is preserved. + reject(&Replications.drop_replication_slot/2) + + pid = start_link_supervised!({Poller, args}) + + assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 500 + + old_oids = :sys.get_state(pid).oids + + expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:error, :boom} end) + send(pid, :check_oids) + + state = :sys.get_state(pid) + assert state.oids == old_oids + assert is_reference(state.check_oid_ref) + end + + test "arms the periodic :check_oids timer when polling starts", %{args: args} do + tenant_id = args["id"] + + pid = start_link_supervised!({Poller, args}) + + assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 500 + + assert is_reference(:sys.get_state(pid).check_oid_ref) + end + + test "arms the periodic :check_oids timer even when the publication is empty", %{args: args} do + tenant_id = args["id"] + + expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end) + reject(&Replications.list_changes/5) + + pid = start_link_supervised!({Poller, args}) + + refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200 + + # Even idle (no slot), the poller must keep checking for tables to appear. + assert is_reference(:sys.get_state(pid).check_oid_ref) + end + end + @columns [ %{"name" => "id", "type" => "int8"}, %{"name" => "details", "type" => "text"}, @@ -19,272 +590,388 @@ defmodule ReplicationPollerTest do @ts "2021-11-05T17:20:51.52406+00:00" @subscription_id "417e76fd-9bc5-4b3e-bd5d-a031389c4a6b" + @subscription_ids MapSet.new(["417e76fd-9bc5-4b3e-bd5d-a031389c4a6b"]) + + @old_record %{"id" => 12} + @record %{"details" => "test", "id" => 12, "user_id" => 1} describe "generate_record/1" do test "INSERT" do - record = [ - {"wal", - %{ - "columns" => @columns, - "commit_timestamp" => @ts, - "record" => %{"details" => "test", "id" => 12, "user_id" => 1}, - "schema" => "public", - "table" => "todos", - "type" => "INSERT" - }}, - {"is_rls_enabled", false}, - {"subscription_ids", [@subscription_id]}, - {"errors", []} + wal_record = [ + "INSERT", + "public", + "todos", + Jason.encode!(@columns), + Jason.encode!(@record), + nil, + @ts, + [@subscription_id], + [], + 1 ] - expected = %NewRecord{ - columns: @columns, - commit_timestamp: @ts, - schema: "public", - table: "todos", - type: "INSERT", - subscription_ids: MapSet.new([@subscription_id]), - record: %{"details" => "test", "id" => 12, "user_id" => 1}, - errors: nil - } + assert %NewRecord{ + columns: columns, + commit_timestamp: @ts, + schema: "public", + table: "todos", + type: "INSERT", + subscription_ids: @subscription_ids, + record: record, + errors: nil + } = generate_record(wal_record) - assert expected == generate_record(record) + # Encode then decode to get rid of the fragment + assert record |> Jason.encode!() |> Jason.decode!() == @record + assert columns |> Jason.encode!() |> Jason.decode!() == @columns end test "UPDATE" do - record = [ - {"wal", - %{ - "columns" => @columns, - "commit_timestamp" => @ts, - "old_record" => %{"id" => 12}, - "record" => %{"details" => "test1", "id" => 12, "user_id" => 1}, - "schema" => "public", - "table" => "todos", - "type" => "UPDATE" - }}, - {"is_rls_enabled", false}, - {"subscription_ids", [@subscription_id]}, - {"errors", []} + wal_record = [ + "UPDATE", + "public", + "todos", + Jason.encode!(@columns), + Jason.encode!(@record), + Jason.encode!(@old_record), + @ts, + [@subscription_id], + [], + 1 ] - expected = %UpdatedRecord{ - columns: @columns, - commit_timestamp: @ts, - schema: "public", - table: "todos", - type: "UPDATE", - subscription_ids: MapSet.new([@subscription_id]), - old_record: %{"id" => 12}, - record: %{"details" => "test1", "id" => 12, "user_id" => 1}, - errors: nil - } + assert %UpdatedRecord{ + columns: columns, + commit_timestamp: @ts, + schema: "public", + table: "todos", + type: "UPDATE", + subscription_ids: @subscription_ids, + record: record, + old_record: old_record, + errors: nil + } = generate_record(wal_record) - assert expected == generate_record(record) + # Encode then decode to get rid of the fragment + assert record |> Jason.encode!() |> Jason.decode!() == @record + assert old_record |> Jason.encode!() |> Jason.decode!() == @old_record + assert columns |> Jason.encode!() |> Jason.decode!() == @columns end test "DELETE" do - record = [ - {"wal", - %{ - "columns" => @columns, - "commit_timestamp" => @ts, - "old_record" => %{"id" => 15}, - "schema" => "public", - "table" => "todos", - "type" => "DELETE" - }}, - {"is_rls_enabled", false}, - {"subscription_ids", [@subscription_id]}, - {"errors", []} + wal_record = [ + "DELETE", + "public", + "todos", + Jason.encode!(@columns), + nil, + Jason.encode!(@old_record), + @ts, + [@subscription_id], + [], + 1 ] - expected = %DeletedRecord{ - columns: @columns, - commit_timestamp: @ts, - schema: "public", - table: "todos", - type: "DELETE", - subscription_ids: MapSet.new([@subscription_id]), - old_record: %{"id" => 15}, - errors: nil - } + assert %DeletedRecord{ + columns: columns, + commit_timestamp: @ts, + schema: "public", + table: "todos", + type: "DELETE", + subscription_ids: @subscription_ids, + old_record: old_record, + errors: nil + } = generate_record(wal_record) - assert expected == generate_record(record) + # Encode then decode to get rid of the fragment + assert old_record |> Jason.encode!() |> Jason.decode!() == @old_record + assert columns |> Jason.encode!() |> Jason.decode!() == @columns end test "INSERT, large payload error present" do - record = [ - {"wal", - %{ - "columns" => @columns, - "commit_timestamp" => @ts, - "record" => %{"details" => "test", "id" => 12, "user_id" => 1}, - "schema" => "public", - "table" => "todos", - "type" => "INSERT" - }}, - {"is_rls_enabled", false}, - {"subscription_ids", [@subscription_id]}, - {"errors", ["Error 413: Payload Too Large"]} + wal_record = [ + "INSERT", + "public", + "todos", + Jason.encode!(@columns), + Jason.encode!(@record), + nil, + @ts, + [@subscription_id], + ["Error 413: Payload Too Large"], + 1 ] - expected = %NewRecord{ - columns: @columns, - commit_timestamp: @ts, - schema: "public", - table: "todos", - type: "INSERT", - subscription_ids: MapSet.new([@subscription_id]), - record: %{"details" => "test", "id" => 12, "user_id" => 1}, - errors: ["Error 413: Payload Too Large"] - } + assert %NewRecord{ + columns: columns, + commit_timestamp: @ts, + schema: "public", + table: "todos", + type: "INSERT", + subscription_ids: @subscription_ids, + record: record, + errors: ["Error 413: Payload Too Large"] + } = generate_record(wal_record) - assert expected == generate_record(record) + # Encode then decode to get rid of the fragment + assert record |> Jason.encode!() |> Jason.decode!() == @record + assert columns |> Jason.encode!() |> Jason.decode!() == @columns end test "INSERT, other errors present" do - record = [ - {"wal", - %{ - "schema" => "public", - "table" => "todos", - "type" => "INSERT" - }}, - {"is_rls_enabled", false}, - {"subscription_ids", [@subscription_id]}, - {"errors", ["Error..."]} + wal_record = [ + "INSERT", + "public", + "todos", + Jason.encode!(@columns), + Jason.encode!(@record), + nil, + @ts, + [@subscription_id], + ["Error..."], + 1 ] - expected = %NewRecord{ - columns: [], - commit_timestamp: nil, - schema: "public", - table: "todos", - type: "INSERT", - subscription_ids: MapSet.new([@subscription_id]), - record: %{}, - errors: ["Error..."] - } + assert %NewRecord{ + columns: columns, + commit_timestamp: @ts, + schema: "public", + table: "todos", + type: "INSERT", + subscription_ids: @subscription_ids, + record: record, + errors: ["Error..."] + } = generate_record(wal_record) - assert expected == generate_record(record) + # Encode then decode to get rid of the fragment + assert record |> Jason.encode!() |> Jason.decode!() == @record + assert columns |> Jason.encode!() |> Jason.decode!() == @columns end test "UPDATE, large payload error present" do - record = [ - {"wal", - %{ - "columns" => @columns, - "commit_timestamp" => @ts, - "old_record" => %{"details" => "prev test", "id" => 12, "user_id" => 1}, - "record" => %{"details" => "test", "id" => 12, "user_id" => 1}, - "schema" => "public", - "table" => "todos", - "type" => "UPDATE" - }}, - {"is_rls_enabled", false}, - {"subscription_ids", [@subscription_id]}, - {"errors", ["Error 413: Payload Too Large"]} + wal_record = [ + "UPDATE", + "public", + "todos", + Jason.encode!(@columns), + Jason.encode!(@record), + Jason.encode!(@old_record), + @ts, + [@subscription_id], + ["Error 413: Payload Too Large"], + 1 ] - expected = %UpdatedRecord{ - columns: @columns, - commit_timestamp: @ts, - schema: "public", - table: "todos", - type: "UPDATE", - subscription_ids: MapSet.new([@subscription_id]), - old_record: %{"details" => "prev test", "id" => 12, "user_id" => 1}, - record: %{"details" => "test", "id" => 12, "user_id" => 1}, - errors: ["Error 413: Payload Too Large"] - } + assert %UpdatedRecord{ + columns: columns, + commit_timestamp: @ts, + schema: "public", + table: "todos", + type: "UPDATE", + subscription_ids: @subscription_ids, + record: record, + old_record: old_record, + errors: ["Error 413: Payload Too Large"] + } = generate_record(wal_record) - assert expected == generate_record(record) + # Encode then decode to get rid of the fragment + assert record |> Jason.encode!() |> Jason.decode!() == @record + assert old_record |> Jason.encode!() |> Jason.decode!() == @old_record + assert columns |> Jason.encode!() |> Jason.decode!() == @columns end test "UPDATE, other errors present" do - record = [ - {"wal", - %{ - "schema" => "public", - "table" => "todos", - "type" => "UPDATE" - }}, - {"is_rls_enabled", false}, - {"subscription_ids", [@subscription_id]}, - {"errors", ["Error..."]} + wal_record = [ + "UPDATE", + "public", + "todos", + Jason.encode!(@columns), + Jason.encode!(@record), + Jason.encode!(@old_record), + @ts, + [@subscription_id], + ["Error..."], + 1 ] - expected = %UpdatedRecord{ - columns: [], - commit_timestamp: nil, - schema: "public", - table: "todos", - type: "UPDATE", - subscription_ids: MapSet.new([@subscription_id]), - old_record: %{}, - record: %{}, - errors: ["Error..."] - } + assert %UpdatedRecord{ + columns: columns, + commit_timestamp: @ts, + schema: "public", + table: "todos", + type: "UPDATE", + subscription_ids: @subscription_ids, + record: record, + old_record: old_record, + errors: ["Error..."] + } = generate_record(wal_record) - assert expected == generate_record(record) + # Encode then decode to get rid of the fragment + assert record |> Jason.encode!() |> Jason.decode!() == @record + assert old_record |> Jason.encode!() |> Jason.decode!() == @old_record + assert columns |> Jason.encode!() |> Jason.decode!() == @columns end test "DELETE, large payload error present" do - record = [ - {"wal", - %{ - "columns" => @columns, - "commit_timestamp" => @ts, - "old_record" => %{"details" => "test", "id" => 12, "user_id" => 1}, - "schema" => "public", - "table" => "todos", - "type" => "DELETE" - }}, - {"is_rls_enabled", false}, - {"subscription_ids", [@subscription_id]}, - {"errors", ["Error 413: Payload Too Large"]} + wal_record = [ + "DELETE", + "public", + "todos", + Jason.encode!(@columns), + nil, + Jason.encode!(@old_record), + @ts, + [@subscription_id], + ["Error 413: Payload Too Large"], + 1 ] - expected = %DeletedRecord{ - columns: @columns, - commit_timestamp: @ts, - schema: "public", - table: "todos", - type: "DELETE", - subscription_ids: MapSet.new([@subscription_id]), - old_record: %{"details" => "test", "id" => 12, "user_id" => 1}, - errors: ["Error 413: Payload Too Large"] - } + assert %DeletedRecord{ + columns: columns, + commit_timestamp: @ts, + schema: "public", + table: "todos", + type: "DELETE", + subscription_ids: @subscription_ids, + old_record: old_record, + errors: ["Error 413: Payload Too Large"] + } = generate_record(wal_record) - assert expected == generate_record(record) + # Encode then decode to get rid of the fragment + assert old_record |> Jason.encode!() |> Jason.decode!() == @old_record + assert columns |> Jason.encode!() |> Jason.decode!() == @columns end test "DELETE, other errors present" do - record = [ - {"wal", - %{ - "schema" => "public", - "table" => "todos", - "type" => "DELETE" - }}, - {"is_rls_enabled", false}, - {"subscription_ids", [@subscription_id]}, - {"errors", ["Error..."]} + wal_record = [ + "DELETE", + "public", + "todos", + Jason.encode!(@columns), + nil, + Jason.encode!(@old_record), + @ts, + [@subscription_id], + ["Error..."], + 1 ] - expected = %DeletedRecord{ - columns: [], - commit_timestamp: nil, - schema: "public", - table: "todos", - type: "DELETE", - subscription_ids: MapSet.new([@subscription_id]), - old_record: %{}, - errors: ["Error..."] - } + assert %DeletedRecord{ + columns: columns, + commit_timestamp: @ts, + schema: "public", + table: "todos", + type: "DELETE", + subscription_ids: @subscription_ids, + old_record: old_record, + errors: ["Error..."] + } = generate_record(wal_record) - assert expected == generate_record(record) + # Encode then decode to get rid of the fragment + assert old_record |> Jason.encode!() |> Jason.decode!() == @old_record + assert columns |> Jason.encode!() |> Jason.decode!() == @columns + end + end + + describe "generate_record/1 JSON encoding" do + test "subscription_ids is excluded from JSON encoding for INSERT" do + wal_record = [ + "INSERT", + "public", + "todos", + Jason.encode!(@columns), + Jason.encode!(@record), + nil, + @ts, + [@subscription_id], + [], + 1 + ] + + record = generate_record(wal_record) + encoded = Jason.decode!(Jason.encode!(record)) + + refute Map.has_key?(encoded, "subscription_ids") + assert encoded["type"] == "INSERT" + assert encoded["schema"] == "public" + assert encoded["table"] == "todos" + end + + test "subscription_ids is excluded from JSON encoding for UPDATE" do + wal_record = [ + "UPDATE", + "public", + "todos", + Jason.encode!(@columns), + Jason.encode!(@record), + Jason.encode!(@old_record), + @ts, + [@subscription_id], + [], + 1 + ] + + record = generate_record(wal_record) + encoded = Jason.decode!(Jason.encode!(record)) + + refute Map.has_key?(encoded, "subscription_ids") + assert encoded["type"] == "UPDATE" + end + + test "subscription_ids is excluded from JSON encoding for DELETE" do + wal_record = [ + "DELETE", + "public", + "todos", + Jason.encode!(@columns), + nil, + Jason.encode!(@old_record), + @ts, + [@subscription_id], + [], + 1 + ] + + record = generate_record(wal_record) + encoded = Jason.decode!(Jason.encode!(record)) + + refute Map.has_key?(encoded, "subscription_ids") + assert encoded["type"] == "DELETE" + end + end + + describe "get_pg_stat_activity_diff/2" do + setup do + tenant = Containers.checkout_tenant(run_migrations: true) + {:ok, conn} = Database.connect(tenant, "realtime_rls", :stop) + %{conn: conn} + end + + test "returns error when pid is not in pg_stat_activity", %{conn: conn} do + assert {:error, :pid_not_found} = Replications.get_pg_stat_activity_diff(conn, 0) + end + end + + describe "error handling" do + setup do + tenant = Containers.checkout_tenant(run_migrations: true) + + args = + hd(tenant.extensions).settings + |> Map.put("id", tenant.external_id) + |> Map.put("subscribers_pids_table", :ets.new(__MODULE__, [:public, :bag])) + |> Map.put("subscribers_nodes_table", :ets.new(__MODULE__, [:public, :set])) + + %{args: args} + end + + test "stops cleanly when database connection fails", %{args: args} do + expect(Realtime.Database, :connect_db, fn _settings -> {:error, :econnrefused} end) + + pid = start_supervised!({Poller, args}, restart: :temporary) + ref = Process.monitor(pid) + + assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, :econnrefused}}, 1000 end end @@ -305,4 +992,42 @@ defmodule ReplicationPollerTest do assert Poller.slot_name_suffix() == "" end end + + def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {:telemetry, event, measures, metadata}) + + defp build_result(subscription_ids) do + {:ok, + %Postgrex.Result{ + command: :select, + columns: [ + "type", + "schema", + "table", + "columns", + "record", + "old_record", + "commit_timestamp", + "subscription_ids", + "errors", + "slot_changes_count" + ], + rows: [ + [ + "INSERT", + "public", + "test", + "[{\"name\": \"id\", \"type\": \"int4\"}, {\"name\": \"details\", \"type\": \"text\"}]", + "{\"id\": 34, \"details\": \"test\"}", + nil, + "2025-10-13T07:50:28.066Z", + subscription_ids, + [], + 1 + ] + ], + num_rows: 1, + connection_id: 123, + messages: [] + }} + end end diff --git a/test/realtime/extensions/cdc_rls/replications_test.exs b/test/realtime/extensions/cdc_rls/replications_test.exs new file mode 100644 index 000000000..0b96b9bf9 --- /dev/null +++ b/test/realtime/extensions/cdc_rls/replications_test.exs @@ -0,0 +1,205 @@ +defmodule Realtime.Extensions.PostgresCdcRls.ReplicationsTest do + use Realtime.DataCase, async: true + + alias Extensions.PostgresCdcRls.Replications + alias Extensions.PostgresCdcRls.Subscriptions + alias Realtime.Database + + setup do + tenant = Containers.checkout_tenant(run_migrations: true) + {:ok, conn} = Database.connect(tenant, "realtime_rls", :stop) + %{conn: conn} + end + + describe "terminate_backend/2" do + test "returns slot_not_found when slot does not exist", %{conn: conn} do + assert {:error, :slot_not_found} = + Replications.terminate_backend(conn, "nonexistent_slot_#{:rand.uniform(999_999)}") + end + + test "returns slot_not_found when slot exists but has no active backend", %{conn: conn} do + slot_name = "test_inactive_slot_#{:rand.uniform(999_999)}" + + Postgrex.query!(conn, "SELECT pg_create_logical_replication_slot($1, 'wal2json')", [slot_name]) + + try do + # No replication session is reading from it, so active_pid is nil + assert {:error, :slot_not_found} = Replications.terminate_backend(conn, slot_name) + after + Postgrex.query(conn, "SELECT pg_drop_replication_slot($1)", [slot_name]) + end + end + end + + describe "get_pg_stat_activity_diff/2" do + test "returns error when pid is not in pg_stat_activity", %{conn: conn} do + assert {:error, :pid_not_found} = Replications.get_pg_stat_activity_diff(conn, 0) + end + + test "returns diff when pid is found in pg_stat_activity", %{conn: conn} do + # Get the PID of the current connection from pg_stat_activity + %{rows: [[db_pid]]} = Postgrex.query!(conn, "SELECT pg_backend_pid()", []) + + # Update the application name so we can find this connection + Postgrex.query!(conn, "SET application_name = 'realtime_rls'", []) + + assert {:ok, diff} = Replications.get_pg_stat_activity_diff(conn, db_pid) + assert is_integer(diff) + end + end + + describe "list_changes/5" do + test "returns rows from the publication slot", %{conn: conn} do + slot_name = "test_list_slot_#{:rand.uniform(999_999)}" + publication = "supabase_realtime_test" + + Postgrex.query!(conn, "SELECT pg_create_logical_replication_slot($1, 'wal2json')", [slot_name]) + + try do + assert {:ok, %Postgrex.Result{columns: columns}} = + Replications.list_changes(conn, slot_name, publication, 100, 1_048_576) + + assert "type" in columns + after + Postgrex.query(conn, "SELECT pg_drop_replication_slot($1)", [slot_name]) + end + end + end + + describe "drop_replication_slot/2" do + test "returns slot_not_found when slot does not exist", %{conn: conn} do + assert {:error, :slot_not_found} = + Replications.drop_replication_slot(conn, "nonexistent_slot_#{:rand.uniform(999_999)}") + end + + test "drops an existing inactive slot", %{conn: conn} do + slot_name = "test_drop_slot_#{:rand.uniform(999_999)}" + Postgrex.query!(conn, "SELECT pg_create_logical_replication_slot($1, 'wal2json')", [slot_name]) + + assert {:ok, :dropped} = Replications.drop_replication_slot(conn, slot_name) + + %{rows: [[count]]} = + Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name]) + + assert count == 0 + end + end + + describe "prepare_replication/2" do + test "creates a replication slot when it does not exist", %{conn: conn} do + slot_name = "test_prep_slot_#{:rand.uniform(999_999)}" + assert {:ok, %Postgrex.Result{}} = Replications.prepare_replication(conn, slot_name) + end + + test "is idempotent when slot already exists", %{conn: conn} do + slot_name = "test_idempotent_slot_#{:rand.uniform(999_999)}" + assert {:ok, _} = Replications.prepare_replication(conn, slot_name) + assert {:ok, _} = Replications.prepare_replication(conn, slot_name) + end + end + + describe "list_changes for schemas and tables with special characters" do + setup %{conn: conn} do + {:ok, _} = Integrations.setup_postgres_changes(conn) + :ok + end + + defp run_list_changes(conn, schema, table) do + pub = "supabase_realtime_test" + slot = "lc_#{:rand.uniform(9_999_999)}" + + # quote identifiers + %{rows: [[quoted_schema, qualified]]} = + Postgrex.query!( + conn, + "SELECT format('%I', $1::text), format('%I.%I', $1::text, $2::text)", + [schema, table] + ) + + Postgrex.query!(conn, "CREATE SCHEMA IF NOT EXISTS #{quoted_schema}", []) + Postgrex.query!(conn, "DROP TABLE IF EXISTS #{qualified}", []) + Postgrex.query!(conn, "CREATE TABLE #{qualified} (name text PRIMARY KEY)", []) + Postgrex.query!(conn, "GRANT ALL ON TABLE #{qualified} TO anon", []) + Postgrex.query!(conn, "GRANT ALL ON TABLE #{qualified} TO authenticated", []) + Postgrex.query!(conn, "ALTER PUBLICATION #{pub} ADD TABLE #{qualified}", []) + + {:ok, _} = Replications.prepare_replication(conn, slot) + + {:ok, sub_params} = + Subscriptions.parse_subscription_params(%{"schema" => schema, "table" => table}) + + params_list = [ + %{claims: %{"role" => "anon"}, id: Ecto.UUID.generate(), subscription_params: sub_params} + ] + + assert {:ok, _} = Subscriptions.create(conn, pub, params_list, self(), self()) + + Postgrex.query!(conn, "INSERT INTO #{qualified} VALUES ('list_changes_test')", []) + + try do + Replications.list_changes(conn, slot, pub, 100, 1_048_576) + after + Postgrex.query(conn, "SELECT pg_drop_replication_slot($1)", [slot]) + Postgrex.query(conn, "DROP TABLE IF EXISTS #{qualified}", []) + + if schema != "public", + do: Postgrex.query(conn, "DROP SCHEMA IF EXISTS #{quoted_schema} CASCADE", []) + end + end + + defp insert_row_for({:ok, %Postgrex.Result{rows: rows}}, expected_table) do + Enum.find(rows, fn + ["INSERT", _schema, ^expected_table, _cols, record | _] -> + record == ~s|{"name": "list_changes_test"}| + + _ -> + false + end) + end + + test "space", %{conn: conn} do + result = run_list_changes(conn, "public", "my table") + assert insert_row_for(result, "my table") + end + + test "comma", %{conn: conn} do + result = run_list_changes(conn, "public", "my,table") + assert insert_row_for(result, "my,table") + end + + test "dot", %{conn: conn} do + result = run_list_changes(conn, "public", "my.table") + assert insert_row_for(result, "my.table") + end + + test "tab", %{conn: conn} do + result = run_list_changes(conn, "public", "tab\there") + assert insert_row_for(result, "tab\there") + end + + test "double-quote", %{conn: conn} do + result = run_list_changes(conn, "public", ~s|my"table|) + assert insert_row_for(result, ~s|my"table|) + end + + test "backslash", %{conn: conn} do + result = run_list_changes(conn, "public", "my\\table") + assert insert_row_for(result, "my\\table") + end + + test "emoji", %{conn: conn} do + result = run_list_changes(conn, "public", "[my_table] 🟠") + assert insert_row_for(result, "[my_table] 🟠") + end + + test "schema and table with spaces", %{conn: conn} do + result = run_list_changes(conn, "my schema", "my table") + assert insert_row_for(result, "my table") + end + + test "schema and table with special cases", %{conn: conn} do + result = run_list_changes(conn, ~s|test "schema|, ~s|test " with 'quotes'|) + assert insert_row_for(result, ~s|test " with 'quotes'|) + end + end +end diff --git a/test/realtime/extensions/cdc_rls/subscription_manager_distributed_test.exs b/test/realtime/extensions/cdc_rls/subscription_manager_distributed_test.exs new file mode 100644 index 000000000..d903c86b1 --- /dev/null +++ b/test/realtime/extensions/cdc_rls/subscription_manager_distributed_test.exs @@ -0,0 +1,69 @@ +defmodule Realtime.Extensions.CdcRls.SubscriptionManagerDistributedTest do + # Usage of Clustered + use ExUnit.Case, async: false + import ExUnit.CaptureLog + + alias Extensions.PostgresCdcRls.SubscriptionManager + + setup do + {:ok, peer, remote_node} = Clustered.start_disconnected() + true = Node.connect(remote_node) + {:ok, peer: peer, remote_node: remote_node} + end + + describe "not_alive_pids_dist/1" do + test "returns empty list for all alive PIDs", %{remote_node: remote_node} do + assert SubscriptionManager.not_alive_pids_dist(%{}) == [] + + pid1 = spawn(fn -> Process.sleep(5000) end) + pid2 = spawn(fn -> Process.sleep(5000) end) + pid3 = spawn(fn -> Process.sleep(5000) end) + pid4 = Node.spawn(remote_node, Process, :sleep, [5000]) + + assert SubscriptionManager.not_alive_pids_dist(%{ + node() => MapSet.new([pid1, pid2, pid3]), + remote_node => MapSet.new([pid4]) + }) == + [] + end + + test "returns list of dead PIDs", %{remote_node: remote_node} do + pid1 = spawn(fn -> Process.sleep(5000) end) + pid2 = spawn(fn -> Process.sleep(5000) end) + pid3 = spawn(fn -> Process.sleep(5000) end) + pid4 = Node.spawn(remote_node, Process, :sleep, [5000]) + pid5 = Node.spawn(remote_node, Process, :sleep, [5000]) + + Process.exit(pid2, :kill) + Process.exit(pid5, :kill) + + assert SubscriptionManager.not_alive_pids_dist(%{ + node() => MapSet.new([pid1, pid2, pid3]), + remote_node => MapSet.new([pid4, pid5]) + }) == [pid2, pid5] + end + + test "handles rpc error", %{remote_node: remote_node, peer: peer} do + pid1 = spawn(fn -> Process.sleep(5000) end) + pid2 = spawn(fn -> Process.sleep(5000) end) + pid3 = spawn(fn -> Process.sleep(5000) end) + pid4 = Node.spawn(remote_node, Process, :sleep, [5000]) + pid5 = Node.spawn(remote_node, Process, :sleep, [5000]) + + Process.exit(pid2, :kill) + + # Stop the other node + :peer.stop(peer) + + log = + capture_log(fn -> + assert SubscriptionManager.not_alive_pids_dist(%{ + node() => MapSet.new([pid1, pid2, pid3]), + remote_node => MapSet.new([pid4, pid5]) + }) == [pid2] + end) + + assert log =~ "UnableToCheckProcessesOnRemoteNode" + end + end +end diff --git a/test/realtime/extensions/cdc_rls/subscription_manager_test.exs b/test/realtime/extensions/cdc_rls/subscription_manager_test.exs new file mode 100644 index 000000000..36b3a2f52 --- /dev/null +++ b/test/realtime/extensions/cdc_rls/subscription_manager_test.exs @@ -0,0 +1,631 @@ +defmodule Realtime.Extensions.CdcRls.SubscriptionManagerTest do + # async: false due to global Mimic stubs + use Realtime.DataCase, async: false + use Mimic + + alias Extensions.PostgresCdcRls + alias Extensions.PostgresCdcRls.SubscriptionManager + alias Extensions.PostgresCdcRls.Subscriptions + alias Realtime.Database + alias Realtime.GenRpc + alias Realtime.Tenants.Rebalancer + + import ExUnit.CaptureLog + import UUID, only: [uuid1: 0, string_to_binary!: 1] + + setup do + tenant = Containers.checkout_tenant(run_migrations: true) + {:ok, db_conn} = Realtime.Database.connect(tenant, "realtime_test", :stop) + Integrations.setup_postgres_changes(db_conn) + GenServer.stop(db_conn) + Realtime.Tenants.Cache.update_cache(tenant) + + subscribers_pids_table = :ets.new(__MODULE__, [:public, :bag]) + subscribers_nodes_table = :ets.new(__MODULE__, [:public, :set]) + + args = %{ + "id" => tenant.external_id, + "subscribers_nodes_table" => subscribers_nodes_table, + "subscribers_pids_table" => subscribers_pids_table + } + + publication = "supabase_realtime_test" + + # register this process with syn as if this was the WorkersSupervisor + + scope = Realtime.Syn.PostgresCdc.scope(tenant.external_id) + :syn.register(scope, tenant.external_id, self(), %{region: "us-east-1", manager: nil, subs_pool: nil}) + + {:ok, pid} = SubscriptionManager.start_link(args) + # This serves so that we know that handle_continue has finished + :sys.get_state(pid) + %{args: args, pid: pid, publication: publication} + end + + describe "subscription" do + test "subscription", %{pid: pid, args: args, publication: publication} do + {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"]) + {uuid, bin_uuid, pg_change_params} = pg_change_params() + + subscriber = self() + + assert {:ok, [%Postgrex.Result{command: :insert}]} = + Subscriptions.create(conn, publication, [pg_change_params], pid, subscriber) + + # Wait for subscription manager to process the :subscribed message + :sys.get_state(pid) + + node = node() + + assert [{^subscriber, ^uuid, _ref, ^node}] = :ets.tab2list(args["subscribers_pids_table"]) + + assert :ets.tab2list(args["subscribers_nodes_table"]) == [{bin_uuid, node}] + end + + test "subscriber died", %{pid: pid, args: args, publication: publication} do + {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"]) + self = self() + + subscriber = + spawn(fn -> + receive do + :stop -> :ok + end + end) + + {uuid1, bin_uuid1, pg_change_params1} = pg_change_params() + {uuid2, bin_uuid2, pg_change_params2} = pg_change_params() + {uuid3, bin_uuid3, pg_change_params3} = pg_change_params() + + assert {:ok, _} = + Subscriptions.create(conn, publication, [pg_change_params1, pg_change_params2], pid, subscriber) + + assert {:ok, _} = Subscriptions.create(conn, publication, [pg_change_params3], pid, self()) + + # Wait for subscription manager to process the :subscribed message + :sys.get_state(pid) + + node = node() + + assert :ets.info(args["subscribers_pids_table"], :size) == 3 + + assert [{^subscriber, ^uuid1, _, ^node}, {^subscriber, ^uuid2, _, ^node}] = + :ets.lookup(args["subscribers_pids_table"], subscriber) + + assert [{^self, ^uuid3, _ref, ^node}] = :ets.lookup(args["subscribers_pids_table"], self) + + assert :ets.info(args["subscribers_nodes_table"], :size) == 3 + assert [{^bin_uuid1, ^node}] = :ets.lookup(args["subscribers_nodes_table"], bin_uuid1) + assert [{^bin_uuid2, ^node}] = :ets.lookup(args["subscribers_nodes_table"], bin_uuid2) + assert [{^bin_uuid3, ^node}] = :ets.lookup(args["subscribers_nodes_table"], bin_uuid3) + + send(subscriber, :stop) + # Wait for subscription manager to receive the :DOWN message + Process.sleep(200) + + # Only the subscription we have not stopped should remain + + assert [{^self, ^uuid3, _ref, ^node}] = :ets.tab2list(args["subscribers_pids_table"]) + assert [{^bin_uuid3, ^node}] = :ets.tab2list(args["subscribers_nodes_table"]) + end + end + + describe "subscription deletion" do + test "subscription is deleted when process goes away", %{pid: pid, args: args, publication: publication} do + {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"]) + {_uuid, _bin_uuid, pg_change_params} = pg_change_params() + + subscriber = + spawn(fn -> + receive do + :stop -> :ok + end + end) + + %Postgrex.Result{rows: [[baseline]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + + assert {:ok, [%Postgrex.Result{command: :insert}]} = + Subscriptions.create(conn, publication, [pg_change_params], pid, subscriber) + + # Wait for subscription manager to process the :subscribed message + :sys.get_state(pid) + + assert :ets.info(args["subscribers_pids_table"], :size) == 1 + assert :ets.info(args["subscribers_nodes_table"], :size) == 1 + + %Postgrex.Result{rows: [[after_create]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + assert after_create > baseline + + send(subscriber, :stop) + # Wait for subscription manager to receive the :DOWN message + Process.sleep(200) + + assert :ets.info(args["subscribers_pids_table"], :size) == 0 + assert :ets.info(args["subscribers_nodes_table"], :size) == 0 + + # Force check delete queue on manager + send(pid, :check_delete_queue) + :sys.get_state(pid) + + assert %Postgrex.Result{rows: [[^baseline]]} = + Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + end + end + + describe "warm restart (re-adopt)" do + test "keeps DB rows and re-monitors surviving subscribers", %{pid: pid, args: args, publication: publication} do + {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"]) + {uuid, bin_uuid, pg_change_params} = pg_change_params() + + subscriber = spawn(fn -> receive do: (:stop -> :ok) end) + + assert {:ok, _} = Subscriptions.create(conn, publication, [pg_change_params], pid, subscriber) + :sys.get_state(pid) + + [{^subscriber, ^uuid, old_ref, _node}] = :ets.lookup(args["subscribers_pids_table"], subscriber) + + # Warm restart: the ETS tables are owned by the test (acting as WorkerSupervisor), so they + # survive while only the manager restarts. + new_pid = restart_manager(pid, args) + {:ok, ^new_pid, conn2} = PostgresCdcRls.get_manager_conn(args["id"]) + + # DB rows are untouched + assert %{rows: [[count]]} = + Postgrex.query!(conn2, "select count(*) from realtime.subscription where subscription_id = $1::uuid", [ + bin_uuid + ]) + + assert count > 0 + + # The subscriber is re-adopted with a fresh monitor + assert [{^subscriber, ^uuid, new_ref, _node}] = :ets.lookup(args["subscribers_pids_table"], subscriber) + assert new_ref != old_ref + + # The fresh monitor actually works: killing the subscriber cleans it up + send(subscriber, :stop) + Process.monitor(subscriber) + assert_receive {:DOWN, _, :process, ^subscriber, _}, 100 + :sys.get_state(new_pid) + + assert :ets.lookup(args["subscribers_pids_table"], subscriber) == [] + assert :ets.lookup(args["subscribers_nodes_table"], bin_uuid) == [] + end + + test "does not touch the DB: orphan rows are left untouched", %{pid: pid, args: args, publication: publication} do + {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"]) + + # A live subscription (present in both ETS and the DB) + live = spawn_link(fn -> receive do: (:stop -> :ok) end) + {_uuid_a, bin_a, params_a} = pg_change_params() + assert {:ok, _} = Subscriptions.create(conn, publication, [params_a], pid, live) + :sys.get_state(pid) + + # A DB orphan: a real row whose ETS entries we drop (mimics a {:subscribed} dropped during + # downtime). The warm path must leave it alone — orphan cleanup is not on the restart path. + orphan = spawn_link(fn -> receive do: (:stop -> :ok) end) + {_uuid_b, bin_b, params_b} = pg_change_params() + assert {:ok, _} = Subscriptions.create(conn, publication, [params_b], pid, orphan) + :sys.get_state(pid) + :ets.delete(args["subscribers_pids_table"], orphan) + :ets.delete(args["subscribers_nodes_table"], bin_b) + + new_pid = restart_manager(pid, args) + {:ok, ^new_pid, conn2} = PostgresCdcRls.get_manager_conn(args["id"]) + + # Both the live subscription and the orphan rows still exist — no reconcile / wipe happened + assert %{rows: [[count_a]]} = + Postgrex.query!(conn2, "select count(*) from realtime.subscription where subscription_id = $1::uuid", [ + bin_a + ]) + + assert %{rows: [[count_b]]} = + Postgrex.query!(conn2, "select count(*) from realtime.subscription where subscription_id = $1::uuid", [ + bin_b + ]) + + assert count_a > 0 + assert count_b > 0 + end + + test "the new manager monitors exactly the surviving subscribers", %{ + pid: pid, + args: args, + publication: publication + } do + {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"]) + + sub1 = spawn(fn -> receive do: (:stop -> :ok) end) + sub2 = spawn_link(fn -> receive do: (:stop -> :ok) end) + {_u1, _b1, params1} = pg_change_params() + {_u2, _b2, params2} = pg_change_params() + assert {:ok, _} = Subscriptions.create(conn, publication, [params1], pid, sub1) + assert {:ok, _} = Subscriptions.create(conn, publication, [params2], pid, sub2) + :sys.get_state(pid) + + # The original manager monitors both subscribers + assert MapSet.subset?(MapSet.new([sub1, sub2]), monitored_pids(pid)) + + new_pid = restart_manager(pid, args) + + # After the warm restart the new manager monitors both surviving subscribers, and the old + # manager (now dead) no longer holds any monitors + assert MapSet.subset?(MapSet.new([sub1, sub2]), monitored_pids(new_pid)) + refute Process.alive?(pid) + + # The fresh monitors are wired to the new manager: when a subscriber dies, the new manager + # drops both its monitor and its ETS entry + send(sub1, :stop) + Process.monitor(sub1) + assert_receive {:DOWN, _, :process, ^sub1, _}, 100 + + :sys.get_state(new_pid) + monitored = monitored_pids(new_pid) + refute MapSet.member?(monitored, sub1) + assert MapSet.member?(monitored, sub2) + assert :ets.lookup(args["subscribers_pids_table"], sub1) == [] + end + end + + describe "cold start (empty ETS)" do + test "deletes all subscriptions from the DB", %{pid: pid, args: args, publication: publication} do + {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"]) + + subscriber = spawn_link(fn -> receive do: (:stop -> :ok) end) + {_uuid, _bin, params} = pg_change_params() + assert {:ok, _} = Subscriptions.create(conn, publication, [params], pid, subscriber) + :sys.get_state(pid) + + assert %{rows: [[seeded]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + assert seeded > 0 + + # Simulate a cold start: a fresh WorkerSupervisor hands the manager brand new, empty ETS + # tables. The DB rows from the previous run must be wiped. + GenServer.stop(pid) + empty_pids = :ets.new(__MODULE__, [:public, :bag]) + empty_nodes = :ets.new(__MODULE__, [:public, :set]) + + cold_args = %{ + args + | "subscribers_pids_table" => empty_pids, + "subscribers_nodes_table" => empty_nodes + } + + {:ok, new_pid} = SubscriptionManager.start_link(cold_args) + :sys.get_state(new_pid) + + {:ok, ^new_pid, conn2} = PostgresCdcRls.get_manager_conn(args["id"]) + assert %{rows: [[0]]} = Postgrex.query!(conn2, "select count(*) from realtime.subscription", []) + end + end + + describe "check no users" do + test "exit is sent to manager", %{pid: pid} do + :sys.replace_state(pid, fn state -> %{state | no_users_ts: 0} end) + + send(pid, :check_no_users) + + assert_receive {:system, {^pid, _}, {:terminate, :shutdown}} + end + end + + describe "message handling" do + setup :set_mimic_global + + test "re-subscribes all subscribers when publication oids change", %{pid: pid, args: args} do + # Force state to have different oids so the new_oids branch is triggered when + # fetch_publication_tables returns the real oids from the database + :sys.replace_state(pid, fn state -> %{state | oids: %{fake: :oids_that_dont_match}} end) + :ets.insert(args["subscribers_pids_table"], {self(), UUID.uuid1(), make_ref(), node()}) + + send(pid, :check_oids) + + assert_receive :postgres_subscribe, 1000 + :sys.get_state(pid) + # Ensure the state is updated before we check the ETS tables + assert :ets.tab2list(args["subscribers_pids_table"]) == [] + assert :ets.tab2list(args["subscribers_nodes_table"]) == [] + end + + test "keeps subscribers and oids when :check_oids fetch errors", %{pid: pid, args: args} do + old_oids = :sys.get_state(pid).oids + :ets.insert(args["subscribers_pids_table"], {self(), UUID.uuid1(), make_ref(), node()}) + + # A fetch error must not be mistaken for a publication change: subscribers stay + # put and no re-subscribe is triggered. + stub(Subscriptions, :fetch_publication_tables, fn _conn, _publication -> {:error, :boom} end) + + send(pid, :check_oids) + state = :sys.get_state(pid) + + refute_receive :postgres_subscribe, 200 + assert state.oids == old_oids + assert match?([{_, _, _, _}], :ets.tab2list(args["subscribers_pids_table"])) + end + + test "logs error when subscription deletion fails during check_delete_queue", %{ + pid: pid, + args: args, + publication: publication + } do + {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"]) + {_uuid, _bin_uuid, pg_change_params} = pg_change_params() + + subscriber = spawn(fn -> receive do: (:stop -> :ok) end) + Subscriptions.create(conn, publication, [pg_change_params], pid, subscriber) + :sys.get_state(pid) + + stub(Subscriptions, :delete_multi, fn _conn, _ids -> {:error, :delete_failed} end) + + send(subscriber, :stop) + Process.sleep(100) + + log = + capture_log(fn -> + send(pid, :check_delete_queue) + :sys.get_state(pid) + end) + + assert log =~ "SubscriptionDeletionFailed" + end + + test "schedules next region check when rebalancer returns ok", %{pid: pid} do + # In a single-node test environment, nodes are equal → Rebalancer returns :ok + current_nodes = MapSet.new(Node.list()) + send(pid, {:check_region, current_nodes}) + :sys.get_state(pid) + + assert Process.alive?(pid) + end + + test "calls handle_stop when wrong region detected", %{pid: pid} do + stub(Rebalancer, :check, fn _prev, _curr, _id -> {:error, :wrong_region} end) + stub(PostgresCdcRls, :handle_stop, fn _id, _timeout -> :ok end) + + send(pid, {:check_region, MapSet.new()}) + :sys.get_state(pid) + + assert Process.alive?(pid) + end + end + + test "handles empty delete queue without crashing", %{pid: pid} do + send(pid, :check_delete_queue) + state = :sys.get_state(pid) + assert :queue.is_empty(state.delete_queue.queue) + end + + test "handles unhandled messages without crashing", %{pid: pid} do + state_before = :sys.get_state(pid) + send(pid, :totally_unexpected_message) + state_after = :sys.get_state(pid) + assert state_before.id == state_after.id + end + + describe "error handling" do + setup :set_mimic_global + + test "stops cleanly when database connection fails", %{args: args} do + stub(Database, :connect_db, fn _settings -> {:error, :econnrefused} end) + + pid = start_supervised!({SubscriptionManager, args}, restart: :temporary) + ref = Process.monitor(pid) + + assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, :econnrefused}}, 1000 + end + end + + describe "phantom subscriber cleanup" do + test "check_active_pids queues dead pids for deletion", %{ + pid: pid, + args: args + } do + subscribers_pids_table = args["subscribers_pids_table"] + subscribers_nodes_table = args["subscribers_nodes_table"] + + dead_pid = spawn(fn -> :ok end) + ref = Process.monitor(dead_pid) + receive do: ({:DOWN, ^ref, :process, ^dead_pid, _} -> :ok) + u = uuid1() + bin_u = string_to_binary!(u) + + :ets.insert(subscribers_pids_table, {dead_pid, u, make_ref(), node()}) + :ets.insert(subscribers_nodes_table, {bin_u, node()}) + + send(pid, :check_active_pids) + state = :sys.get_state(pid) + + assert not :queue.is_empty(state.delete_queue.queue) + end + end + + describe "subscribers_by_node/1" do + test "groups subscriber pids by node" do + subscribers_pids_table = :ets.new(:table, [:public, :bag]) + + test_data = [ + {:pid1, "id1", :ref, :node1}, + {:pid1, "id1.2", :ref, :node1}, + {:pid2, "id2", :ref, :node2} + ] + + :ets.insert(subscribers_pids_table, test_data) + + assert SubscriptionManager.subscribers_by_node(subscribers_pids_table) == %{ + node1: MapSet.new([:pid1]), + node2: MapSet.new([:pid2]) + } + end + end + + describe "not_alive_pids/1" do + test "returns empty list for empty input" do + assert SubscriptionManager.not_alive_pids(MapSet.new()) == [] + end + + test "returns empty list for all alive PIDs" do + pid1 = spawn(fn -> Process.sleep(5000) end) + pid2 = spawn(fn -> Process.sleep(5000) end) + pid3 = spawn(fn -> Process.sleep(5000) end) + assert SubscriptionManager.not_alive_pids(MapSet.new([pid1, pid2, pid3])) == [] + end + + test "returns list of dead PIDs" do + pid1 = spawn(fn -> Process.sleep(5000) end) + pid2 = spawn(fn -> Process.sleep(5000) end) + pid3 = spawn(fn -> Process.sleep(5000) end) + Process.exit(pid2, :kill) + assert SubscriptionManager.not_alive_pids(MapSet.new([pid1, pid2, pid3])) == [pid2] + end + end + + describe "pop_not_alive_pids/4" do + test "one subscription per channel" do + subscribers_pids_table = :ets.new(:table, [:public, :bag]) + subscribers_nodes_table = :ets.new(:table, [:public, :set]) + + uuid1 = uuid1() + uuid2 = uuid1() + uuid3 = uuid1() + + pids_test_data = [ + {:pid1, uuid1, :ref, :node1}, + {:pid1, uuid2, :ref, :node1}, + {:pid2, uuid3, :ref, :node2} + ] + + :ets.insert(subscribers_pids_table, pids_test_data) + + nodes_test_data = [ + {string_to_binary!(uuid1), :node1}, + {string_to_binary!(uuid2), :node1}, + {string_to_binary!(uuid3), :node2} + ] + + :ets.insert(subscribers_nodes_table, nodes_test_data) + + not_alive = + Enum.sort( + SubscriptionManager.pop_not_alive_pids([:pid1], subscribers_pids_table, subscribers_nodes_table, "id") + ) + + expected = Enum.sort([string_to_binary!(uuid1), string_to_binary!(uuid2)]) + assert not_alive == expected + + assert :ets.tab2list(subscribers_pids_table) == [{:pid2, uuid3, :ref, :node2}] + assert :ets.tab2list(subscribers_nodes_table) == [{string_to_binary!(uuid3), :node2}] + end + + test "two subscriptions per channel" do + subscribers_pids_table = :ets.new(:table, [:public, :bag]) + subscribers_nodes_table = :ets.new(:table, [:public, :set]) + + uuid1 = uuid1() + uuid2 = uuid1() + + test_data = [ + {:pid1, uuid1, :ref, :node1}, + {:pid2, uuid2, :ref, :node2} + ] + + :ets.insert(subscribers_pids_table, test_data) + + nodes_test_data = [ + {string_to_binary!(uuid1), :node1}, + {string_to_binary!(uuid2), :node2} + ] + + :ets.insert(subscribers_nodes_table, nodes_test_data) + + assert SubscriptionManager.pop_not_alive_pids([:pid1], subscribers_pids_table, subscribers_nodes_table, "id") == [ + string_to_binary!(uuid1) + ] + + assert :ets.tab2list(subscribers_pids_table) == [{:pid2, uuid2, :ref, :node2}] + assert :ets.tab2list(subscribers_nodes_table) == [{string_to_binary!(uuid2), :node2}] + end + + test "returns empty list when pid not found in table" do + subscribers_pids_table = :ets.new(:table, [:public, :bag]) + subscribers_nodes_table = :ets.new(:table, [:public, :set]) + + assert SubscriptionManager.pop_not_alive_pids( + [:nonexistent_pid], + subscribers_pids_table, + subscribers_nodes_table, + "tenant_id" + ) == [] + end + end + + describe "not_alive_pids_dist/1" do + setup :set_mimic_global + + test "handles remote node RPC error gracefully" do + remote_node = :some_remote@node + + stub(GenRpc, :call, fn ^remote_node, SubscriptionManager, :not_alive_pids, _pids, _opts -> + {:error, :rpc_error, :timeout} + end) + + log = + capture_log(fn -> + result = SubscriptionManager.not_alive_pids_dist(%{remote_node => MapSet.new([self()])}) + assert result == [] + end) + + assert log =~ "UnableToCheckProcessesOnRemoteNode" + end + + test "returns pids from remote node when RPC succeeds" do + remote_node = :some_remote@node + dead_pid = self() + + stub(GenRpc, :call, fn ^remote_node, SubscriptionManager, :not_alive_pids, [pids_set], _opts -> + MapSet.to_list(pids_set) + end) + + result = SubscriptionManager.not_alive_pids_dist(%{remote_node => MapSet.new([dead_pid])}) + assert dead_pid in result + end + + test "checks local pids directly without RPC" do + dead_pid = spawn(fn -> :ok end) + ref = Process.monitor(dead_pid) + receive do: ({:DOWN, ^ref, :process, ^dead_pid, _} -> :ok) + + result = SubscriptionManager.not_alive_pids_dist(%{node() => MapSet.new([dead_pid])}) + assert dead_pid in result + end + end + + # Simulates a SubscriptionManager-only restart: the ETS tables in `args` are owned by the test + # process (acting as the WorkerSupervisor) and survive, so the new manager re-adopts them. + defp restart_manager(pid, args) do + GenServer.stop(pid) + {:ok, new_pid} = SubscriptionManager.start_link(args) + :sys.get_state(new_pid) + new_pid + end + + # The set of processes `pid` is currently monitoring. + defp monitored_pids(pid) do + {:monitors, monitors} = Process.info(pid, :monitors) + for {:process, monitored} <- monitors, into: MapSet.new(), do: monitored + end + + defp pg_change_params do + uuid = UUID.uuid1() + + pg_change_params = %{ + id: uuid, + subscription_params: {"*", "public", "*", [], nil}, + claims: %{ + "exp" => System.system_time(:second) + 100_000, + "iat" => 0, + "role" => "anon" + } + } + + {uuid, UUID.string_to_binary!(uuid), pg_change_params} + end +end diff --git a/test/realtime/extensions/cdc_rls/subscriptions_checker_test.exs b/test/realtime/extensions/cdc_rls/subscriptions_checker_test.exs deleted file mode 100644 index bfbb4bd7a..000000000 --- a/test/realtime/extensions/cdc_rls/subscriptions_checker_test.exs +++ /dev/null @@ -1,80 +0,0 @@ -defmodule SubscriptionsCheckerTest do - use ExUnit.Case, async: true - alias Extensions.PostgresCdcRls.SubscriptionsChecker, as: Checker - - test "subscribers_by_node/1" do - tid = :ets.new(:table, [:public, :bag]) - - test_data = [ - {:pid1, "id1", :ref, :node1}, - {:pid1, "id1.2", :ref, :node1}, - {:pid2, "id2", :ref, :node2} - ] - - :ets.insert(tid, test_data) - - assert Checker.subscribers_by_node(tid) == %{ - node1: MapSet.new([:pid1]), - node2: MapSet.new([:pid2]) - } - end - - describe "not_alive_pids/1" do - test "returns empty list for empty input" do - assert Checker.not_alive_pids(MapSet.new()) == [] - end - - test "returns empty list for all alive PIDs" do - pid1 = spawn(fn -> Process.sleep(5000) end) - pid2 = spawn(fn -> Process.sleep(5000) end) - pid3 = spawn(fn -> Process.sleep(5000) end) - assert Checker.not_alive_pids(MapSet.new([pid1, pid2, pid3])) == [] - end - - test "returns list of dead PIDs" do - pid1 = spawn(fn -> Process.sleep(5000) end) - pid2 = spawn(fn -> Process.sleep(5000) end) - pid3 = spawn(fn -> Process.sleep(5000) end) - Process.exit(pid2, :kill) - assert Checker.not_alive_pids(MapSet.new([pid1, pid2, pid3])) == [pid2] - end - end - - describe "pop_not_alive_pids/2" do - test "one subscription per channel" do - tid = :ets.new(:table, [:public, :bag]) - - uuid1 = UUID.uuid1() - uuid2 = UUID.uuid1() - - test_data = [ - {:pid1, uuid1, :ref, :node1}, - {:pid1, uuid2, :ref, :node1}, - {:pid2, "uuid", :ref, :node2} - ] - - :ets.insert(tid, test_data) - - not_alive = Enum.sort(Checker.pop_not_alive_pids([:pid1], tid, "id")) - expected = Enum.sort([UUID.string_to_binary!(uuid1), UUID.string_to_binary!(uuid2)]) - assert not_alive == expected - - assert :ets.tab2list(tid) == [{:pid2, "uuid", :ref, :node2}] - end - - test "two subscriptions per channel" do - tid = :ets.new(:table, [:public, :bag]) - - uuid1 = UUID.uuid1() - - test_data = [ - {:pid1, uuid1, :ref, :node1}, - {:pid2, "uuid", :ref, :node2} - ] - - :ets.insert(tid, test_data) - assert Checker.pop_not_alive_pids([:pid1], tid, "id") == [UUID.string_to_binary!(uuid1)] - assert :ets.tab2list(tid) == [{:pid2, "uuid", :ref, :node2}] - end - end -end diff --git a/test/realtime/extensions/cdc_rls/subscriptions_test.exs b/test/realtime/extensions/cdc_rls/subscriptions_test.exs index cb53b72ed..e51c43bf7 100644 --- a/test/realtime/extensions/cdc_rls/subscriptions_test.exs +++ b/test/realtime/extensions/cdc_rls/subscriptions_test.exs @@ -1,121 +1,829 @@ -defmodule Realtime.Extensionsubscriptions.CdcRlsSubscriptionsTest do +defmodule Realtime.Extensions.PostgresCdcRls.SubscriptionsTest do use RealtimeWeb.ChannelCase, async: true - doctest Extensions.PostgresCdcRls.Subscriptions + + doctest Extensions.PostgresCdcRls.Subscriptions, import: true + + import ExUnit.CaptureLog alias Extensions.PostgresCdcRls.Subscriptions alias Realtime.Database - alias Realtime.Tenants setup do - tenant = Tenants.get_tenant_by_external_id("dev_tenant") + tenant = Containers.checkout_tenant(run_migrations: true) + + {:ok, db_settings} = Database.from_tenant(tenant, "realtime_rls") {:ok, conn} = - tenant - |> Database.from_tenant("realtime_rls") + db_settings |> Map.from_struct() |> Keyword.new() |> Postgrex.start_link() - %{conn: conn} - end - - test "create", %{conn: conn} do + Integrations.setup_postgres_changes(conn) Subscriptions.delete_all(conn) - assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) - params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), params: %{"event" => "*", "schema" => "public"}}] - - assert {:ok, [%Postgrex.Result{}]} = - Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) - - Process.sleep(500) + %{conn: conn, tenant: tenant} + end - params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), params: %{"schema" => "public", "table" => "test"}}] + describe "subscribing with row filters" do + test "user can combine two range conditions to create a bounded filter" do + assert {:ok, {"*", "public", "test", filters, _}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "id=gt.0,id=lt.100" + }) + + assert [{"id", "gt", "0"}, {"id", "lt", "100"}] = Enum.sort(filters) + end + + test "user gets a clear error when one filter in a multi-filter expression is unsupported" do + assert {:error, msg} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "id=gt.0,id=like.100" + }) + + assert msg =~ "Error parsing `filter` params" + end + + test "user can omit the filter value entirely to subscribe to all rows" do + assert {:ok, {"*", "public", "test", [], _}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "" + }) + end + + test "user can filter by a single equality condition" do + assert {:ok, {"*", "public", "test", [{"id", "eq", "5"}], _}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "id=eq.5" + }) + end + + test "user can combine an in-list filter with an equality filter" do + assert {:ok, {"*", "public", "test", filters, _}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "id=in.(1,2,3),details=eq.active" + }) + + assert [{"details", "eq", "active"}, {"id", "in", "{1,2,3}"}] = Enum.sort(filters) + end + + test "user can use an in-list filter with multi-word string values alongside another filter" do + assert {:ok, {"*", "public", "test", filters, _}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "name=in.(red,blue),quantity=gt.0" + }) + + assert [{"name", "in", "{red,blue}"}, {"quantity", "gt", "0"}] = filters + end + + test "user can place an in-list filter after a range filter" do + assert {:ok, {"*", "public", "test", filters, _}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "quantity=gt.0,name=in.(red,blue)" + }) + + assert [{"quantity", "gt", "0"}, {"name", "in", "{red,blue}"}] = filters + end + + test "user can combine two in-list filters each with multiple values" do + assert {:ok, {"*", "public", "test", filters, _}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "name=in.(red,blue,green),status=in.(active,inactive)" + }) + + assert [{"name", "in", "{red,blue,green}"}, {"status", "in", "{active,inactive}"}] = filters + end + + test "user can use filter values that contain a closing parenthesis character" do + assert {:ok, {"*", "public", "test", filters, _}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "a=eq.x),b=eq.y),c=eq.z" + }) + + assert [{"a", "eq", "x)"}, {"b", "eq", "y)"}, {"c", "eq", "z"}] = filters + end + + test "user gets a clear error when the filter string ends with a stray comma" do + assert {:error, msg} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "id=gt.0," + }) + + assert msg =~ "empty segments" + end + + test "user gets a clear error when the filter string starts with a stray comma" do + assert {:error, msg} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => ",id=gt.0" + }) + + assert msg =~ "empty segments" + end + + test "user gets a clear error when two commas appear back-to-back in a filter string" do + assert {:error, msg} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "a=eq.1,,b=eq.2" + }) + + assert msg =~ "empty segments" + end + + test "whitespace-only filter string is treated the same as no filter" do + assert {:ok, {"*", "public", "test", [], _}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => " " + }) + end + end - assert {:ok, [%Postgrex.Result{}]} = - Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) + describe "subscribing to table changes" do + test "user can subscribe to all events on all tables in a schema", %{conn: conn} do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{"event" => "*", "schema" => "public"}) + + params_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + assert {:ok, [%Postgrex.Result{}]} = + Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) + + assert %Postgrex.Result{rows: rows} = + Postgrex.query!(conn, "select filters, action_filter from realtime.subscription", []) + + assert rows != [] + assert Enum.all?(rows, &match?([[], "*"], &1)) + end + + test "create with filter on valid column succeeds", %{conn: conn} do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "id=eq.123" + }) + + params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}] + + assert {:ok, [%Postgrex.Result{}]} = + Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) + + assert %Postgrex.Result{ + rows: [ + [ + "test", + [{"id", "eq", "123"}], + "*" + ] + ] + } = + Postgrex.query!( + conn, + "select entity::text, filters, action_filter from realtime.subscription", + [] + ) + end + + test "subscription works when role lacks usage permission", %{conn: conn, tenant: tenant} do + {:ok, admin_settings} = Database.from_tenant(tenant, "realtime_test", :stop) + + {:ok, admin_conn} = + Postgrex.start_link( + hostname: admin_settings.hostname, + port: admin_settings.port, + database: admin_settings.database, + username: "supabase_admin", + password: admin_settings.password + ) + + Postgrex.query!(admin_conn, "CREATE SCHEMA IF NOT EXISTS vault", []) + Postgrex.query!(admin_conn, "REVOKE USAGE ON SCHEMA vault FROM supabase_realtime_admin", []) + + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "id=eq.1" + }) + + params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}] + + assert {:ok, [%Postgrex.Result{}]} = + Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) + end + + test "user can subscribe to only INSERT events", %{conn: conn} do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{"event" => "INSERT", "schema" => "public"}) + + params_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + assert {:ok, [%Postgrex.Result{}]} = + Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) + + assert %Postgrex.Result{rows: rows} = + Postgrex.query!(conn, "select filters, action_filter from realtime.subscription", []) + + assert rows != [] + assert Enum.all?(rows, &match?([[], "INSERT"], &1)) + end + + test "user can subscribe to a specific table", %{conn: conn} do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{"schema" => "public", "table" => "test"}) + + subscription_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + assert {:ok, [%Postgrex.Result{}]} = + Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self()) + + %Postgrex.Result{rows: [[1]]} = + Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + end + + test "create works for a table whose name contains a backslash", %{conn: conn} do + Postgrex.query!(conn, ~s|CREATE TABLE "my\\table" (id int)|, []) + Postgrex.query!(conn, ~s|GRANT ALL ON "my\\table" TO anon|, []) + Postgrex.query!(conn, ~s|ALTER PUBLICATION supabase_realtime_test ADD TABLE "my\\table"|, []) + + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{"schema" => "public", "table" => "my\\table"}) + + subscription_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + assert {:ok, [%Postgrex.Result{num_rows: 1}]} = + Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self()) + end + + test "user gets an error when Realtime is not enabled for the publication", %{conn: conn} do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{"schema" => "public", "table" => "test"}) + + subscription_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + Postgrex.query!(conn, "drop publication if exists supabase_realtime_test", []) + + assert {:error, + {:subscription_insert_failed, + "Unable to subscribe to changes with given parameters. Please check Realtime is enabled for the given connect parameters: [event: *, schema: public, table: test, filters: [], select: nil]"}} = + Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self()) + + %Postgrex.Result{rows: [[0]]} = + Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + end + + test "user gets an error when subscribing to a table that does not exist", %{conn: conn} do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "doesnotexist" + }) + + subscription_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + assert {:error, + {:subscription_insert_failed, + "Unable to subscribe to changes with given parameters. Please check Realtime is enabled for the given connect parameters: [event: *, schema: public, table: doesnotexist, filters: [], select: nil]"}} = + Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self()) + + %Postgrex.Result{rows: [[0]]} = + Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + end + + test "user gets an error when filtering on a column that does not exist", %{conn: conn} do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "subject=eq.hey" + }) + + subscription_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + assert {:error, + {:subscription_insert_failed, + "Unable to subscribe to changes with given parameters. An exception happened so please check your connect parameters: [event: *, schema: public, table: test, filters: [{\"subject\", \"eq\", \"hey\"}], select: nil]. Exception: ERROR P0001 (raise_exception) invalid column for filter subject"}} = + Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self()) + + %Postgrex.Result{rows: [[0]]} = + Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + end + + test "user gets an error when filter value is incompatible with column type", %{conn: conn} do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "id=eq.hey" + }) + + subscription_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + assert {:error, + {:subscription_insert_failed, + "Unable to subscribe to changes with given parameters. An exception happened so please check your connect parameters: [event: *, schema: public, table: test, filters: [{\"id\", \"eq\", \"hey\"}], select: nil]. Exception: ERROR 22P02 (invalid_text_representation) invalid input syntax for type integer: \"hey\""}} = + Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self()) + + %Postgrex.Result{rows: [[0]]} = + Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + end + + test "subscription creation fails gracefully when database connection is dead" do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{"schema" => "public", "table" => "test"}) + + subscription_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + conn = spawn(fn -> :ok end) + + assert {:error, {:exit, _}} = + Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self()) + end + + test "subscription creation fails gracefully when the connection pool is exhausted", %{ + conn: conn + } do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{"schema" => "public", "table" => "test"}) + + Task.start(fn -> Postgrex.query!(conn, "SELECT pg_sleep(11)", []) end) + + subscription_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + assert {:error, %DBConnection.ConnectionError{reason: :queue_timeout}} = + Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self()) + end + + test "user gets an error when table param is not a string" do + {:error, msg} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => %{"actually a" => "map"} + }) + + assert msg =~ "No subscription params provided" + end + + test "user gets an error when schema param is not a string" do + {:error, msg} = + Subscriptions.parse_subscription_params(%{ + "table" => "images", + "schema" => %{"actually a" => "map"} + }) + + assert msg =~ "No subscription params provided" + end + + test "user gets an error when filter param is not a string" do + {:error, msg} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "images", + "filter" => [123] + }) + + assert msg =~ "No subscription params provided" + end + + test "user can combine AND row filters which are all stored in the subscription", %{ + conn: conn + } do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "id=gt.0,id=lt.100" + }) + + params_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + assert {:ok, [%Postgrex.Result{}]} = + Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) + + assert %Postgrex.Result{rows: [[filters]]} = + Postgrex.query!(conn, "select filters from realtime.subscription", []) + + assert [_, _] = filters + end + end - Process.sleep(500) + describe "delete_all/1" do + test "delete_all", %{conn: conn} do + create_subscriptions(conn, 10) + assert :ok = Subscriptions.delete_all(conn) + assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + end - params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), params: %{}}] + test "returns ok when connection is unavailable" do + conn = spawn(fn -> :ok end) + assert :ok = Subscriptions.delete_all(conn) + end - assert {:error, - "No subscription params provided. Please provide at least a `schema` or `table` to subscribe to: %{}"} = - Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) + test "logs error when subscription table is dropped", %{conn: conn} do + Postgrex.query!(conn, "drop table if exists realtime.subscription cascade", []) - Process.sleep(500) + log = capture_log(fn -> Subscriptions.delete_all(conn) end) + assert log =~ "SubscriptionDeletionFailed" + end + end - params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), params: %{"user_token" => "potato"}}] + describe "delete/2" do + test "returns error when subscription table is dropped", %{conn: conn} do + Postgrex.query!(conn, "drop table if exists realtime.subscription cascade", []) - assert {:error, - "No subscription params provided. Please provide at least a `schema` or `table` to subscribe to: "} = - Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) + assert {:error, %Postgrex.Error{}} = Subscriptions.delete(conn, UUID.string_to_binary!(UUID.uuid1())) + end - Process.sleep(500) + test "delete", %{conn: conn} do + id = UUID.uuid1() + bin_id = UUID.string_to_binary!(id) - params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), params: %{"auth_token" => "potato"}}] + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "id=eq.hey" + }) - assert {:error, - "No subscription params provided. Please provide at least a `schema` or `table` to subscribe to: "} = - Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) + subscription_list = [%{claims: %{"role" => "anon"}, id: id, subscription_params: subscription_params}] + Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self()) - Process.sleep(500) + assert {:ok, %Postgrex.Result{}} = Subscriptions.delete(conn, bin_id) + assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + end - %Postgrex.Result{rows: [[num]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) - assert num != 0 + test "returns error when connection is unavailable" do + conn = spawn(fn -> :ok end) + assert {:error, _} = Subscriptions.delete(conn, UUID.uuid1()) + end end - test "delete_all", %{conn: conn} do - create_subscriptions(conn, 10) - assert {:ok, %Postgrex.Result{}} = Subscriptions.delete_all(conn) - assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + describe "delete_multi/2" do + test "delete_multi", %{conn: conn} do + Subscriptions.delete_all(conn) + id1 = UUID.uuid1() + id2 = UUID.uuid1() + + bin_id2 = UUID.string_to_binary!(id2) + bin_id1 = UUID.string_to_binary!(id1) + + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "filter" => "id=eq.123" + }) + + subscription_list = [ + %{claims: %{"role" => "anon"}, id: id1, subscription_params: subscription_params}, + %{claims: %{"role" => "anon"}, id: id2, subscription_params: subscription_params} + ] + + assert {:ok, _} = Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self()) + + assert %Postgrex.Result{rows: [[2]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + assert {:ok, %Postgrex.Result{}} = Subscriptions.delete_multi(conn, [bin_id1, bin_id2]) + assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + end end - test "delete", %{conn: conn} do - Subscriptions.delete_all(conn) - id = UUID.uuid1() - bin_id = UUID.string_to_binary!(id) + describe "delete_all_if_table_exists/1" do + test "delete_all_if_table_exists", %{conn: conn} do + Subscriptions.delete_all(conn) + create_subscriptions(conn, 10) + + assert :ok = Subscriptions.delete_all_if_table_exists(conn) + assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + end + + test "logs error when trigger raises on delete", %{conn: conn, tenant: tenant} do + create_subscriptions(conn, 3) + + Postgrex.query!( + conn, + """ + create or replace function realtime.evil_delete_trigger() + returns trigger language plpgsql as $$ + begin raise exception 'evil trigger'; end; + $$; + """, + [] + ) + + Postgrex.query!( + conn, + """ + create trigger evil_delete_trigger + before delete on realtime.subscription + for each row execute function realtime.evil_delete_trigger(); + """, + [] + ) + + on_exit(fn -> + {:ok, db_settings} = Database.from_tenant(tenant, "realtime_rls") + + {:ok, cleanup_conn} = + db_settings + |> Map.from_struct() + |> Keyword.new() + |> Postgrex.start_link() + + Postgrex.query(cleanup_conn, "drop trigger if exists evil_delete_trigger on realtime.subscription", []) + Postgrex.query(cleanup_conn, "drop function if exists realtime.evil_delete_trigger()", []) + GenServer.stop(cleanup_conn) + end) - params_list = [%{id: id, claims: %{"role" => "anon"}, params: %{"event" => "*"}}] - Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) - Process.sleep(500) + log = capture_log(fn -> Subscriptions.delete_all_if_table_exists(conn) end) + assert log =~ "SubscriptionCleanupFailed" + end - assert {:ok, %Postgrex.Result{}} = Subscriptions.delete(conn, bin_id) - assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + test "logs error when connection is dead" do + conn = spawn(fn -> :ok end) + log = capture_log(fn -> Subscriptions.delete_all_if_table_exists(conn) end) + assert log =~ "SubscriptionCleanupFailed" + end end - test "delete_multi", %{conn: conn} do - Subscriptions.delete_all(conn) - id1 = UUID.uuid1() - id2 = UUID.uuid1() - - bin_id2 = UUID.string_to_binary!(id2) - bin_id1 = UUID.string_to_binary!(id1) + describe "fetch_publication_tables/2" do + test "returns {:ok, tables} for an existing publication", %{conn: conn} do + assert {:ok, tables} = Subscriptions.fetch_publication_tables(conn, "supabase_realtime_test") + assert tables[{"*"}] != nil + end - params_list = [ - %{claims: %{"role" => "anon"}, id: id1, params: %{"event" => "*"}}, - %{claims: %{"role" => "anon"}, id: id2, params: %{"event" => "*"}} - ] + test "returns {:ok, %{}} for a publication with no tables", %{conn: conn} do + assert {:ok, %{}} = Subscriptions.fetch_publication_tables(conn, "non_existing_publication") + end - Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) - Process.sleep(500) - - assert {:ok, %Postgrex.Result{}} = Subscriptions.delete_multi(conn, [bin_id1, bin_id2]) - assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + test "returns {:error, _} when the query fails", %{conn: conn} do + GenServer.stop(conn) + assert {:error, _reason} = Subscriptions.fetch_publication_tables(conn, "supabase_realtime_test") + end end - test "maybe_delete_all", %{conn: conn} do - Subscriptions.delete_all(conn) - create_subscriptions(conn, 10) - - assert {:ok, %Postgrex.Result{}} = Subscriptions.maybe_delete_all(conn) - assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + describe "existing subscriptions without column selection continue to receive full payloads" do + test "omitting select returns all columns (no behavior change for existing clients)" do + assert {:ok, {"*", "public", "messages", [], nil}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "messages" + }) + end + + test "passing an empty select list is treated as no column selection" do + assert {:ok, {"*", "public", "messages", [], nil}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "messages", + "select" => [] + }) + end + + test "subscription without select stores NULL in the database (no column restriction)", %{ + conn: conn + } do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{"schema" => "public", "table" => "test"}) + + params_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + assert {:ok, [%Postgrex.Result{}]} = + Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) + + assert %Postgrex.Result{rows: [[nil]]} = + Postgrex.query!(conn, "select selected_columns from realtime.subscription", []) + end + + test "apply_rls returns all columns in the payload when no column selection is set", %{ + conn: conn + } do + sub_id = UUID.uuid1() + slot_name = "test_apply_rls_no_select_#{:rand.uniform(999_999)}" + + Postgrex.query!( + conn, + "insert into realtime.subscription (subscription_id, entity, claims) values ($1::text::uuid, 'public.test'::regclass, $2)", + [sub_id, %{"role" => "anon"}] + ) + + Postgrex.query!(conn, "SELECT pg_create_logical_replication_slot($1, 'wal2json')", [slot_name]) + + try do + Postgrex.query!(conn, "insert into test (details) values ('hello')", []) + + %{rows: rows} = + Postgrex.query!( + conn, + "select wal, subscription_ids from realtime.list_changes($1, $2, 100, 1048576)", + ["supabase_realtime_test", slot_name] + ) + + # apply_rls stores subscription_ids as binary UUIDs + bin_sub_id = UUID.string_to_binary!(sub_id) + matching = Enum.find(rows, fn [_wal, sub_ids] -> bin_sub_id in (sub_ids || []) end) + assert matching != nil, "Expected sub_id in list_changes result. rows=#{inspect(rows)}" + [wal_result, _] = matching + assert Map.has_key?(wal_result["record"], "id") + assert Map.has_key?(wal_result["record"], "details") + after + Postgrex.query(conn, "SELECT pg_drop_replication_slot($1)", [slot_name]) + end + end end - test "fetch_publication_tables", %{conn: conn} do - tables = Subscriptions.fetch_publication_tables(conn, "supabase_realtime_test") - assert tables[{"*"}] != nil + describe "subscribing with column selection (select param)" do + test "user can pass a list of column names to limit the payload" do + assert {:ok, {"*", "public", "messages", [], ["id", "details"]}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "messages", + "select" => ["id", "details"] + }) + end + + test "passing a string to select is rejected with a clear error message" do + assert {:error, msg} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "messages", + "select" => "id,details" + }) + + assert msg =~ "`select`" + end + + test "non-binary entries in a select list are silently dropped" do + assert {:ok, {"*", "public", "messages", [], ["id", "details"]}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "messages", + "select" => ["id", 123, "details", nil] + }) + end + + test "passing any string value to select is rejected" do + assert {:error, msg} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "messages", + "select" => "" + }) + + assert msg =~ "`select`" + end + + test "user can combine column selection with a row filter" do + assert {:ok, {"*", "public", "messages", [{"id", "eq", "5"}], ["id", "details"]}} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "messages", + "filter" => "id=eq.5", + "select" => ["id", "details"] + }) + end + + test "selected columns are stored in normalized (sorted) order in the database", %{ + conn: conn + } do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "select" => ["details", "id"] + }) + + params_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + assert {:ok, [%Postgrex.Result{}]} = + Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) + + assert %Postgrex.Result{rows: [[selected_columns]]} = + Postgrex.query!(conn, "select selected_columns from realtime.subscription", []) + + assert ["details", "id"] = Enum.sort(selected_columns) + end + + test "two subscriptions on the same table with different column selections are stored as separate rows", + %{conn: conn} do + id = UUID.uuid1() + + {:ok, params1} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "select" => ["id"] + }) + + {:ok, params2} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "select" => ["id", "details"] + }) + + params_list = [ + %{claims: %{"role" => "anon"}, id: id, subscription_params: params1}, + %{claims: %{"role" => "anon"}, id: id, subscription_params: params2} + ] + + assert {:ok, [%Postgrex.Result{}, %Postgrex.Result{}]} = + Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) + + assert %Postgrex.Result{rows: [[2]]} = + Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + end + + test "user gets an error when select references a column that does not exist", %{conn: conn} do + {:ok, subscription_params} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "test", + "select" => ["nonexistent_column"] + }) + + params_list = [ + %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params} + ] + + assert {:error, {:subscription_insert_failed, msg}} = + Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) + + assert msg =~ "invalid column for select nonexistent_column" + end + + test "user gets an error when using select with a schema-only (wildcard table) subscription" do + assert {:error, msg} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "select" => ["id"] + }) + + assert msg =~ "wildcard" + end + + test "user gets an error when using select with an explicit wildcard table" do + assert {:error, msg} = + Subscriptions.parse_subscription_params(%{ + "schema" => "public", + "table" => "*", + "select" => ["id"] + }) + + assert msg =~ "wildcard" + end end defp create_subscriptions(conn, num) do @@ -131,13 +839,12 @@ defmodule Realtime.Extensionsubscriptions.CdcRlsSubscriptionsTest do "role" => "anon" }, id: UUID.uuid1(), - params: %{"event" => "*", "schema" => "public"} + subscription_params: {"*", "public", "*", [], nil} } | acc ] end) Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self()) - Process.sleep(500) end end diff --git a/test/realtime/feature_flags_test.exs b/test/realtime/feature_flags_test.exs new file mode 100644 index 000000000..b2d069c8e --- /dev/null +++ b/test/realtime/feature_flags_test.exs @@ -0,0 +1,71 @@ +defmodule Realtime.FeatureFlagsTest do + use Realtime.DataCase, async: false + + alias Realtime.Api + alias Realtime.FeatureFlags + alias Realtime.FeatureFlags.Cache + alias Realtime.Tenants.Cache, as: TenantsCache + + setup do + Cachex.clear(Cache) + Cachex.clear(TenantsCache) + :ok + end + + describe "enabled?/1" do + test "returns false when flag does not exist" do + refute FeatureFlags.enabled?("missing_flag") + end + + test "returns false when flag is disabled" do + {:ok, _} = Api.upsert_feature_flag(%{name: "off_flag", enabled: false}) + refute FeatureFlags.enabled?("off_flag") + end + + test "returns true when flag is enabled" do + {:ok, _} = Api.upsert_feature_flag(%{name: "on_flag", enabled: true}) + assert FeatureFlags.enabled?("on_flag") + end + end + + describe "enabled?/2" do + test "returns false when flag does not exist" do + refute FeatureFlags.enabled?("missing_flag", "tenant_1") + end + + test "returns false when flag is disabled and tenant has no entry (follows global)" do + {:ok, _} = Api.upsert_feature_flag(%{name: "off_flag", enabled: false}) + tenant = tenant_fixture(%{feature_flags: %{}}) + refute FeatureFlags.enabled?("off_flag", tenant.external_id) + end + + test "returns true when flag is disabled globally but tenant has it explicitly enabled" do + {:ok, _} = Api.upsert_feature_flag(%{name: "tenant_override_flag", enabled: false}) + tenant = tenant_fixture(%{feature_flags: %{"tenant_override_flag" => true}}) + assert FeatureFlags.enabled?("tenant_override_flag", tenant.external_id) + end + + test "returns global value when flag is enabled but tenant does not exist" do + {:ok, _} = Api.upsert_feature_flag(%{name: "enabled_flag", enabled: true}) + assert FeatureFlags.enabled?("enabled_flag", "nonexistent_tenant") + end + + test "returns true when flag is enabled and tenant has no entry (follows global)" do + {:ok, _} = Api.upsert_feature_flag(%{name: "partial_flag", enabled: true}) + tenant = tenant_fixture(%{feature_flags: %{}}) + assert FeatureFlags.enabled?("partial_flag", tenant.external_id) + end + + test "returns true when flag is enabled and tenant has it explicitly enabled" do + {:ok, _} = Api.upsert_feature_flag(%{name: "tenant_flag", enabled: true}) + tenant = tenant_fixture(%{feature_flags: %{"tenant_flag" => true}}) + assert FeatureFlags.enabled?("tenant_flag", tenant.external_id) + end + + test "returns false when flag is enabled but tenant has it explicitly disabled" do + {:ok, _} = Api.upsert_feature_flag(%{name: "disabled_for_tenant", enabled: true}) + tenant = tenant_fixture(%{feature_flags: %{"disabled_for_tenant" => false}}) + refute FeatureFlags.enabled?("disabled_for_tenant", tenant.external_id) + end + end +end diff --git a/test/realtime/gen_rpc_pub_sub/worker_test.exs b/test/realtime/gen_rpc_pub_sub/worker_test.exs new file mode 100644 index 000000000..880fa5132 --- /dev/null +++ b/test/realtime/gen_rpc_pub_sub/worker_test.exs @@ -0,0 +1,71 @@ +defmodule Realtime.GenRpcPubSub.WorkerTest do + use ExUnit.Case, async: true + alias Realtime.GenRpcPubSub.Worker + alias Realtime.GenRpc + alias Realtime.Nodes + + use Mimic + + @topic "test_topic" + + setup do + worker = start_link_supervised!({Worker, {Realtime.PubSub, __MODULE__}}) + %{worker: worker} + end + + describe "forward to local" do + test "local broadcast", %{worker: worker} do + :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, @topic) + send(worker, Worker.forward_to_local(@topic, "le message", Phoenix.PubSub)) + + assert_receive "le message" + refute_receive _any + end + end + + describe "forward to region" do + setup %{worker: worker} do + GenRpc + |> stub() + |> allow(self(), worker) + + Nodes + |> stub() + |> allow(self(), worker) + + :ok + end + + test "local broadcast + forward to other nodes", %{worker: worker} do + parent = self() + expect(Nodes, :region_nodes, fn "us-east-1" -> [node(), :node_us_2, :node_us_3] end) + + expect(GenRpc, :abcast, fn [:node_us_2, :node_us_3], + Realtime.GenRpcPubSub.WorkerTest, + {:ftl, "test_topic", "le message", Phoenix.PubSub}, + [] -> + send(parent, :abcast_called) + :ok + end) + + :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, @topic) + send(worker, Worker.forward_to_region(@topic, "le message", Phoenix.PubSub)) + + assert_receive "le message" + assert_receive :abcast_called + refute_receive _any + end + + test "local broadcast and no other nodes", %{worker: worker} do + expect(Nodes, :region_nodes, fn "us-east-1" -> [node()] end) + + reject(GenRpc, :abcast, 4) + + :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, @topic) + send(worker, Worker.forward_to_region(@topic, "le message", Phoenix.PubSub)) + + assert_receive "le message" + refute_receive _any + end + end +end diff --git a/test/realtime/gen_rpc_pub_sub_test.exs b/test/realtime/gen_rpc_pub_sub_test.exs new file mode 100644 index 000000000..c273f1484 --- /dev/null +++ b/test/realtime/gen_rpc_pub_sub_test.exs @@ -0,0 +1,120 @@ +Application.put_env(:phoenix_pubsub, :test_adapter, {Realtime.GenRpcPubSub, []}) +Code.require_file("../../deps/phoenix_pubsub/test/shared/pubsub_test.exs", __DIR__) + +defmodule Realtime.GenRpcPubSubTest do + # Application env being changed + use ExUnit.Case, async: false + + test "it sets off_heap message_queue_data flag on the workers" do + assert Realtime.PubSubElixir.Realtime.PubSub.Adapter_1 + |> Process.whereis() + |> Process.info(:message_queue_data) == {:message_queue_data, :off_heap} + end + + test "it sets fullsweep_after flag on the workers" do + assert Realtime.PubSubElixir.Realtime.PubSub.Adapter_1 + |> Process.whereis() + |> Process.info(:fullsweep_after) == {:fullsweep_after, 20} + end + + @aux_mod (quote do + defmodule Subscriber do + # Relay messages to testing node + def subscribe(subscriber, topic) do + spawn(fn -> + RealtimeWeb.Endpoint.subscribe(topic) + 2 = length(Realtime.Nodes.region_nodes("us-east-1")) + 2 = length(Realtime.Nodes.region_nodes("ap-southeast-2")) + send(subscriber, {:ready, Application.get_env(:realtime, :region)}) + + loop = fn f -> + receive do + msg -> send(subscriber, {:relay, node(), msg}) + end + + f.(f) + end + + loop.(loop) + end) + end + end + end) + + Code.eval_quoted(@aux_mod) + + @topic "gen-rpc-pub-sub-test-topic" + + describe "regional broadcasting" do + setup do + previous_region = Application.get_env(:realtime, :region) + Application.put_env(:realtime, :region, "us-east-1") + on_exit(fn -> Application.put_env(:realtime, :region, previous_region) end) + + :ok + end + + test "all messages are received" do + # start 1 node in us-east-1 to test my region broadcasting + # start 2 nodes in ap-southeast-2 to test other region broadcasting + + us_node = :us_node + ap2_nodeX = :ap2_nodeX + ap2_nodeY = :ap2_nodeY + + # Avoid port collision + gen_rpc_port = Application.fetch_env!(:gen_rpc, :tcp_server_port) + + client_config_per_node = %{ + node() => gen_rpc_port, + :"#{us_node}@127.0.0.1" => 16970, + :"#{ap2_nodeX}@127.0.0.1" => 16971, + :"#{ap2_nodeY}@127.0.0.1" => 16972 + } + + extra_config = [{:gen_rpc, :client_config_per_node, {:internal, client_config_per_node}}] + + on_exit(fn -> Application.put_env(:gen_rpc, :client_config_per_node, {:internal, %{}}) end) + Application.put_env(:gen_rpc, :client_config_per_node, {:internal, client_config_per_node}) + + us_extra_config = + [{:realtime, :region, "us-east-1"}, {:gen_rpc, :tcp_server_port, 16970}] ++ extra_config + + {:ok, _} = Clustered.start(@aux_mod, name: us_node, extra_config: us_extra_config, phoenix_port: 4014) + + ap2_nodeX_extra_config = + [{:realtime, :region, "ap-southeast-2"}, {:gen_rpc, :tcp_server_port, 16971}] ++ extra_config + + {:ok, _} = Clustered.start(@aux_mod, name: ap2_nodeX, extra_config: ap2_nodeX_extra_config, phoenix_port: 4015) + + ap2_nodeY_extra_config = + [{:realtime, :region, "ap-southeast-2"}, {:gen_rpc, :tcp_server_port, 16972}] ++ extra_config + + {:ok, _} = Clustered.start(@aux_mod, name: ap2_nodeY, extra_config: ap2_nodeY_extra_config, phoenix_port: 4016) + + # Ensuring that syn had enough time to propagate to all nodes the group information + Process.sleep(3000) + + RealtimeWeb.Endpoint.subscribe(@topic) + :erpc.multicall(Node.list(), Subscriber, :subscribe, [self(), @topic]) + + assert length(Realtime.Nodes.region_nodes("us-east-1")) == 2 + assert length(Realtime.Nodes.region_nodes("ap-southeast-2")) == 2 + + assert_receive {:ready, "us-east-1"} + assert_receive {:ready, "ap-southeast-2"} + assert_receive {:ready, "ap-southeast-2"} + + message = %Phoenix.Socket.Broadcast{topic: @topic, event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]} + Phoenix.PubSub.broadcast(Realtime.PubSub, @topic, message) + + assert_receive ^message + + # Remote nodes received the broadcast + assert_receive {:relay, :"us_node@127.0.0.1", ^message}, 5000 + assert_receive {:relay, :"ap2_nodeX@127.0.0.1", ^message}, 1000 + assert_receive {:relay, :"ap2_nodeY@127.0.0.1", ^message}, 1000 + refute_receive _any + end + end +end diff --git a/test/realtime/gen_rpc_test.exs b/test/realtime/gen_rpc_test.exs index dd837aaf8..511aafc14 100644 --- a/test/realtime/gen_rpc_test.exs +++ b/test/realtime/gen_rpc_test.exs @@ -28,7 +28,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^current_node, success: true, - tenant: "123", mechanism: :gen_rpc }} end @@ -43,7 +42,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^current_node, success: false, - tenant: "123", mechanism: :gen_rpc }} end @@ -57,7 +55,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: true, - tenant: "123", mechanism: :gen_rpc }} end @@ -72,7 +69,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: false, - tenant: "123", mechanism: :gen_rpc }} end @@ -87,14 +83,13 @@ defmodule Realtime.GenRpcTest do end) assert log =~ - "project=123 external_id=123 [error] ErrorOnRpcCall: %{error: :timeout, mod: Process, func: :sleep, target: :\"main@127.0.0.1\"}" + "project=123 external_id=123 [error] ErrorOnRpcCall: %{error: :timeout, mod: Process, func: :sleep, target: :\"#{current_node}\"}" assert_receive {[:realtime, :rpc], %{latency: _}, %{ origin_node: ^current_node, target_node: ^current_node, success: false, - tenant: 123, mechanism: :gen_rpc }} end @@ -116,7 +111,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: false, - tenant: 123, mechanism: :gen_rpc }} end @@ -131,7 +125,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^current_node, success: false, - tenant: "123", mechanism: :gen_rpc }} end @@ -146,7 +139,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: false, - tenant: "123", mechanism: :gen_rpc }} end @@ -168,10 +160,115 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: false, - tenant: 123, mechanism: :gen_rpc }} end + + test "bad node" do + node = :"unknown@1.1.1.1" + + log = + capture_log(fn -> + assert GenRpc.call(node, Map, :fetch, [%{a: 1}, :a], tenant_id: 123) == {:error, :rpc_error, :badnode} + end) + + assert log =~ + ~r/project=123 external_id=123 \[error\] ErrorOnRpcCall: %{+error: :badnode, mod: Map, func: :fetch, target: :"#{node}"/ + end + end + + describe "abcast/4" do + test "abcast to registered process", %{node: node} do + name = + System.unique_integer() + |> to_string() + |> String.to_atom() + + :erlang.register(name, self()) + + # Use erpc to make the other node abcast to this one + :erpc.call(node, GenRpc, :abcast, [[node()], name, "a message", []]) + + assert_receive "a message" + refute_receive _any + end + + test "abcast to registered process on the local node" do + name = + System.unique_integer() + |> to_string() + |> String.to_atom() + + :erlang.register(name, self()) + + assert GenRpc.abcast([node()], name, "a message", []) == :ok + + assert_receive "a message" + refute_receive _any + end + + @tag extra_config: [{:gen_rpc, :tcp_server_port, 9999}] + test "tcp error" do + Logger.put_process_level(self(), :debug) + + log = + capture_log(fn -> + assert GenRpc.abcast(Node.list(), :some_process_name, "a message", []) == :ok + # We have to wait for gen_rpc logs to show up + Process.sleep(100) + end) + + assert log =~ "failed_to_connect_server" + + refute_receive _any + end + end + + describe "cast/5" do + test "apply on a local node" do + parent = self() + + assert GenRpc.cast(node(), Kernel, :send, [parent, :sent]) == :ok + + assert_receive :sent + refute_receive _any + end + + test "apply on a remote node", %{node: node} do + parent = self() + + assert GenRpc.cast(node, Kernel, :send, [parent, :sent]) == :ok + + assert_receive :sent + refute_receive _any + end + + test "bad node does nothing" do + node = :"unknown@1.1.1.1" + + parent = self() + + assert GenRpc.cast(node, Kernel, :send, [parent, :sent]) == :ok + + refute_receive _any + end + + @tag extra_config: [{:gen_rpc, :tcp_server_port, 9999}] + test "tcp error", %{node: node} do + parent = self() + Logger.put_process_level(self(), :debug) + + log = + capture_log(fn -> + assert GenRpc.cast(node, Kernel, :send, [parent, :sent]) == :ok + # We have to wait for gen_rpc logs to show up + Process.sleep(100) + end) + + assert log =~ "failed_to_connect_server" + + refute_receive _any + end end describe "multicast/4" do @@ -197,7 +294,7 @@ defmodule Realtime.GenRpcTest do Process.sleep(100) end) - assert log =~ "[error] event=connect_to_remote_server" + assert log =~ "failed_to_connect_server" assert_receive :sent refute_receive _any @@ -214,7 +311,7 @@ defmodule Realtime.GenRpcTest do current_node = node() assert GenRpc.multicall(Map, :fetch, [%{a: 1}, :a], tenant_id: "123") == [ - {:"main@127.0.0.1", {:ok, 1}}, + {current_node, {:ok, 1}}, {node, {:ok, 1}} ] @@ -223,7 +320,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: true, - tenant: "123", mechanism: :gen_rpc }} @@ -232,7 +328,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^current_node, success: true, - tenant: "123", mechanism: :gen_rpc }} end @@ -243,13 +338,13 @@ defmodule Realtime.GenRpcTest do log = capture_log(fn -> assert GenRpc.multicall(Process, :sleep, [500], timeout: 100, tenant_id: 123) == [ - {:"main@127.0.0.1", {:error, :rpc_error, :timeout}}, + {current_node, {:error, :rpc_error, :timeout}}, {node, {:error, :rpc_error, :timeout}} ] end) assert log =~ - "project=123 external_id=123 [error] ErrorOnRpcCall: %{error: :timeout, mod: Process, func: :sleep, target: :\"main@127.0.0.1\"}" + "project=123 external_id=123 [error] ErrorOnRpcCall: %{error: :timeout, mod: Process, func: :sleep, target: :\"#{current_node}\"}" assert log =~ ~r/project=123 external_id=123 \[error\] ErrorOnRpcCall: %{\s+error: :timeout,\s+mod: Process,\s+func: :sleep,\s+target:\s+:"#{node}"/ @@ -259,7 +354,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: false, - tenant: 123, mechanism: :gen_rpc }} @@ -268,7 +362,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^current_node, success: false, - tenant: 123, mechanism: :gen_rpc }} end @@ -280,7 +373,7 @@ defmodule Realtime.GenRpcTest do log = capture_log(fn -> assert GenRpc.multicall(Map, :fetch, [%{a: 1}, :a], tenant_id: 123) == [ - {:"main@127.0.0.1", {:ok, 1}}, + {node(), {:ok, 1}}, {node, {:error, :rpc_error, :econnrefused}} ] end) @@ -293,7 +386,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: false, - tenant: 123, mechanism: :gen_rpc }} @@ -302,7 +394,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^current_node, success: true, - tenant: 123, mechanism: :gen_rpc }} end diff --git a/test/realtime/log_filter_test.exs b/test/realtime/log_filter_test.exs new file mode 100644 index 000000000..2fcec358f --- /dev/null +++ b/test/realtime/log_filter_test.exs @@ -0,0 +1,84 @@ +defmodule Realtime.LogFilterTest do + use ExUnit.Case, async: true + + alias Realtime.LogFilter + + describe "filter/2 - gen_statem crash reports" do + test "stops DBConnection.ConnectionError crashes" do + event = gen_statem_event(%DBConnection.ConnectionError{message: "tcp connect: connection refused"}) + assert :stop = LogFilter.filter(event, []) + end + + test "passes through gen_statem crashes for other reasons" do + event = gen_statem_event(:some_other_reason) + assert ^event = LogFilter.filter(event, []) + end + + test "passes through non-gen_statem reports" do + event = %{msg: {:report, %{label: {:supervisor, :child_terminated}}}, meta: %{}} + assert ^event = LogFilter.filter(event, []) + end + end + + describe "filter/2 - DBConnection.Connection log calls" do + test "stops messages from DBConnection.Connection" do + event = db_connection_log_event("Postgrex.Protocol failed to connect: connection refused") + assert :stop = LogFilter.filter(event, []) + end + + test "passes through messages from other modules" do + event = %{msg: {:string, "some log"}, meta: %{mfa: {SomeOtherModule, :some_fun, 1}}} + assert ^event = LogFilter.filter(event, []) + end + + test "passes through messages with no mfa metadata" do + event = %{msg: {:string, "some log"}, meta: %{}} + assert ^event = LogFilter.filter(event, []) + end + end + + describe "filter/2 - Ranch connection killed reports" do + test "stops Ranch reports when connection was killed" do + event = ranch_event(RealtimeWeb.Endpoint.HTTP, :cowboy_clear, self(), :killed) + assert :stop = LogFilter.filter(event, []) + end + + test "passes through Ranch reports when connection exited for other reasons" do + event = ranch_event(RealtimeWeb.Endpoint.HTTP, :cowboy_clear, self(), :some_error) + assert ^event = LogFilter.filter(event, []) + end + end + + describe "setup/0" do + test "installs the primary filter" do + LogFilter.setup() + %{filters: filters} = :logger.get_primary_config() + assert List.keymember?(filters, :connection_noise, 0) + end + + test "is idempotent when called multiple times" do + LogFilter.setup() + assert :ok = LogFilter.setup() + end + end + + defp gen_statem_event(reason) do + %{ + msg: {:report, %{label: {:gen_statem, :terminate}, name: self(), reason: {:error, reason, []}}}, + meta: %{pid: self(), time: System.system_time()} + } + end + + @ranch_format "Ranch listener ~p had connection process started with ~p:start_link/3 at ~p exit with reason: ~0p~n" + + defp ranch_event(ref, protocol, pid, reason) do + %{msg: {:format, @ranch_format, [ref, protocol, pid, reason]}, meta: %{pid: self()}} + end + + defp db_connection_log_event(message) do + %{ + msg: {:string, message}, + meta: %{mfa: {DBConnection.Connection, :handle_event, 4}, pid: self()} + } + end +end diff --git a/test/realtime/logs_test.exs b/test/realtime/logs_test.exs index feee48ac6..3882a6750 100644 --- a/test/realtime/logs_test.exs +++ b/test/realtime/logs_test.exs @@ -1,6 +1,52 @@ defmodule Realtime.LogsTest do use ExUnit.Case + import ExUnit.CaptureLog + + alias Realtime.Logs + + describe "to_log/1" do + test "returns binary as-is" do + assert Logs.to_log("hello") == "hello" + end + + test "inspects non-binary values" do + assert Logs.to_log(%{key: "value"}) == inspect(%{key: "value"}, pretty: true) + assert Logs.to_log(123) == "123" + assert Logs.to_log([:a, :b]) == inspect([:a, :b], pretty: true) + end + end + + describe "log_error/2" do + test "logs error with code and message" do + defmodule LogErrorTest do + use Realtime.Logs + + def do_log do + log_error("TestCode", "something broke") + end + end + + log = capture_log(fn -> LogErrorTest.do_log() end) + assert log =~ "TestCode: something broke" + end + end + + describe "log_warning/2" do + test "logs warning with code and message" do + defmodule LogWarningTest do + use Realtime.Logs + + def do_log do + log_warning("WarnCode", "something suspicious") + end + end + + log = capture_log(fn -> LogWarningTest.do_log() end) + assert log =~ "WarnCode: something suspicious" + end + end + describe "Jason.Encoder implementation" do test "encodes DBConnection.ConnectionError" do error = %DBConnection.ConnectionError{ @@ -31,5 +77,15 @@ defmodule Realtime.LogsTest do assert encoded =~ "table: \"users\"" assert encoded =~ "code: \"42P01\"" end + + test "encodes Tuple with error logging" do + log = + capture_log(fn -> + encoded = Jason.encode!({:error, "test"}) + assert encoded =~ "error: \"unable to parse response\"" + end) + + assert log =~ "UnableToEncodeJson" + end end end diff --git a/test/realtime/messages_test.exs b/test/realtime/messages_test.exs index 3bef9a5e0..5590adca9 100644 --- a/test/realtime/messages_test.exs +++ b/test/realtime/messages_test.exs @@ -1,10 +1,11 @@ defmodule Realtime.MessagesTest do - use Realtime.DataCase, async: true + # usage of Clustered + use Realtime.DataCase, async: false alias Realtime.Api.Message alias Realtime.Database alias Realtime.Messages - alias Realtime.Repo + alias Realtime.Tenants.Repo setup do tenant = Containers.checkout_tenant(run_migrations: true) @@ -13,35 +14,248 @@ defmodule Realtime.MessagesTest do date_start = Date.utc_today() |> Date.add(-10) date_end = Date.utc_today() create_messages_partitions(conn, date_start, date_end) + + on_exit(fn -> :telemetry.detach(__MODULE__) end) + + :telemetry.attach( + __MODULE__, + [:realtime, :tenants, :replay], + &__MODULE__.handle_telemetry/4, + pid: self() + ) + %{conn: conn, tenant: tenant, date_start: date_start, date_end: date_end} end - test "delete_old_messages/1 deletes messages older than 72 hours", %{ - conn: conn, - tenant: tenant, - date_start: date_start, - date_end: date_end - } do - utc_now = NaiveDateTime.utc_now() - limit = NaiveDateTime.add(utc_now, -72, :hour) - - messages = - for date <- Date.range(date_start, date_end) do - inserted_at = date |> NaiveDateTime.new!(Time.new!(0, 0, 0)) - message_fixture(tenant, %{inserted_at: inserted_at}) + describe "replay/5" do + test "invalid replay params", %{tenant: tenant} do + assert Messages.replay(self(), tenant.external_id, "a topic", "not a number", 123) == + {:error, :invalid_replay_params} + + assert Messages.replay(self(), tenant.external_id, "a topic", 123, "not a number") == + {:error, :invalid_replay_params} + + assert Messages.replay(self(), tenant.external_id, "a topic", 253_402_300_800_000, 10) == + {:error, :invalid_replay_params} + end + + test "empty replay", %{conn: conn} do + assert Messages.replay(conn, "tenant_id", "test", 0, 10) == {:ok, [], MapSet.new()} + end + + test "replay respects limit", %{conn: conn, tenant: tenant} do + external_id = tenant.external_id + + m1 = + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute), + "event" => "new", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "new"} + }) + + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute), + "event" => "old", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "old"} + }) + + assert Messages.replay(conn, external_id, "test", 0, 1) == {:ok, [m1], MapSet.new([m1.id])} + + assert_receive { + :telemetry, + [:realtime, :tenants, :replay], + %{latency: _}, + %{tenant: ^external_id} + } + end + + test "replay private topic only", %{conn: conn, tenant: tenant} do + privatem = + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute), + "event" => "new", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "new"} + }) + + message_fixture(tenant, %{ + "private" => false, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute), + "event" => "old", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "old"} + }) + + assert Messages.replay(conn, tenant.external_id, "test", 0, 10) == {:ok, [privatem], MapSet.new([privatem.id])} + end + + test "replay extension=broadcast", %{conn: conn, tenant: tenant} do + privatem = + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute), + "event" => "new", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "new"} + }) + + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute), + "event" => "old", + "extension" => "presence", + "topic" => "test", + "payload" => %{"value" => "old"} + }) + + assert Messages.replay(conn, tenant.external_id, "test", 0, 10) == {:ok, [privatem], MapSet.new([privatem.id])} + end + + test "replay respects since", %{conn: conn, tenant: tenant} do + m1 = + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute), + "event" => "first", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "first"} + }) + + m2 = + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute), + "event" => "second", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "second"} + }) + + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-10, :minute), + "event" => "old", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "old"} + }) + + since = DateTime.utc_now() |> DateTime.add(-3, :minute) |> DateTime.to_unix(:millisecond) + + assert Messages.replay(conn, tenant.external_id, "test", since, 10) == {:ok, [m1, m2], MapSet.new([m1.id, m2.id])} + end + + test "replay respects hard max limit of 25", %{conn: conn, tenant: tenant} do + for _i <- 1..30 do + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now(), + "event" => "event", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "message"} + }) end - assert length(messages) == 11 + assert {:ok, messages, set} = Messages.replay(conn, tenant.external_id, "test", 0, 30) + assert length(messages) == 25 + assert MapSet.size(set) == 25 + end + + test "replay respects hard min limit of 1", %{conn: conn, tenant: tenant} do + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now(), + "event" => "event", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "message"} + }) + + assert {:ok, messages, set} = Messages.replay(conn, tenant.external_id, "test", 0, 0) + assert length(messages) == 1 + assert MapSet.size(set) == 1 + end + + test "distributed replay", %{conn: conn, tenant: tenant} do + m = + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now(), + "event" => "event", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "message"} + }) + + {:ok, node} = Clustered.start() + + # Call remote node passing the database connection that is local to this node + assert :erpc.call(node, Messages, :replay, [conn, tenant.external_id, "test", 0, 30]) == + {:ok, [m], MapSet.new([m.id])} + end - to_keep = - Enum.reject( - messages, - &(NaiveDateTime.compare(limit, &1.inserted_at) == :gt) - ) + test "distributed replay error", %{tenant: tenant} do + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now(), + "event" => "event", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "message"} + }) - assert :ok = Messages.delete_old_messages(conn) - {:ok, current} = Repo.all(conn, from(m in Message), Message) + {:ok, node} = Clustered.start() - assert Enum.sort(current) == Enum.sort(to_keep) + # Call remote node passing the database connection that is local to this node + pid = spawn(fn -> :ok end) + + assert :erpc.call(node, Messages, :replay, [pid, tenant.external_id, "test", 0, 30]) == + {:error, :failed_to_replay_messages} + end end + + describe "delete_old_messages/1" do + test "delete_old_messages/1 deletes messages older than 72 hours", %{ + conn: conn, + tenant: tenant, + date_start: date_start, + date_end: date_end + } do + utc_now = NaiveDateTime.utc_now() + limit = NaiveDateTime.add(utc_now, -72, :hour) + + messages = + for date <- Date.range(date_start, date_end) do + inserted_at = date |> NaiveDateTime.new!(Time.new!(0, 0, 0)) + message_fixture(tenant, %{inserted_at: inserted_at}) + end + + assert length(messages) == 11 + + to_keep = + Enum.reject( + messages, + &(NaiveDateTime.compare(NaiveDateTime.beginning_of_day(limit), &1.inserted_at) == :gt) + ) + + assert :ok = Messages.delete_old_messages(conn) + {:ok, current} = Repo.all(conn, from(m in Message), Message) + + assert Enum.sort(current) == Enum.sort(to_keep) + end + end + + def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {:telemetry, event, measures, metadata}) end diff --git a/test/realtime/metrics_cleaner_test.exs b/test/realtime/metrics_cleaner_test.exs index fbe9d8515..3eb5ef1ac 100644 --- a/test/realtime/metrics_cleaner_test.exs +++ b/test/realtime/metrics_cleaner_test.exs @@ -1,45 +1,261 @@ defmodule Realtime.MetricsCleanerTest do - # async: false due to potentially polluting metrics with other tenant metrics from other tests - use Realtime.DataCase, async: false + use Realtime.DataCase, async: true alias Realtime.MetricsCleaner alias Realtime.Tenants.Connect + alias Forum.Census - setup do - interval = Application.get_env(:realtime, :metrics_cleaner_schedule_timer_in_ms) - Application.put_env(:realtime, :metrics_cleaner_schedule_timer_in_ms, 100) - tenant = Containers.checkout_tenant(run_migrations: true) + describe "metrics cleanup - vacant websockets" do + test "cleans up metrics for users that have been disconnected" do + :telemetry.execute( + [:realtime, :connections], + %{connected: 1, connected_cluster: 10, limit: 100}, + %{tenant: "occupied-tenant"} + ) + + :telemetry.execute( + [:realtime, :connections], + %{connected: 0, connected_cluster: 20, limit: 100}, + %{tenant: "vacant-tenant1"} + ) + + :telemetry.execute( + [:realtime, :connections], + %{connected: 0, connected_cluster: 20, limit: 100}, + %{tenant: "vacant-tenant2"} + ) + + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + pid3 = spawn_link(fn -> Process.sleep(:infinity) end) + + Census.join(:users, "occupied-tenant", pid1) + Census.join(:users, "vacant-tenant1", pid2) + Census.join(:users, "vacant-tenant2", pid3) + + metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary() + + assert String.contains?(metrics, "tenant=\"occupied-tenant\"") + assert String.contains?(metrics, "tenant=\"vacant-tenant1\"") + assert String.contains?(metrics, "tenant=\"vacant-tenant2\"") + + start_supervised!( + {MetricsCleaner, [metrics_cleaner_schedule_timer_in_ms: 100, vacant_metric_threshold_in_seconds: 1]} + ) + + # Now let's disconnect vacant tenants + Census.leave(:users, "vacant-tenant1", pid2) + Census.leave(:users, "vacant-tenant2", pid3) + + # Wait for clean up to run + Process.sleep(200) + + # Nothing changes yet (threshold not reached) + metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary() + + assert String.contains?(metrics, "tenant=\"occupied-tenant\"") + assert String.contains?(metrics, "tenant=\"vacant-tenant1\"") + assert String.contains?(metrics, "tenant=\"vacant-tenant2\"") + + # Wait for threshold to pass and cleanup to run + Process.sleep(2200) + + # vacant tenant metrics are now gone + metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary() - on_exit(fn -> - Application.put_env(:realtime, :metrics_cleaner_schedule_timer_in_ms, interval) - end) + assert String.contains?(metrics, "tenant=\"occupied-tenant\"") + refute String.contains?(metrics, "tenant=\"vacant-tenant1\"") + refute String.contains?(metrics, "tenant=\"vacant-tenant2\"") + end + + test "does not clean up metrics if websockets reconnect before threshold" do + :telemetry.execute( + [:realtime, :connections], + %{connected: 1, connected_cluster: 10, limit: 100}, + %{tenant: "reconnect-tenant"} + ) + + pid = spawn_link(fn -> Process.sleep(:infinity) end) + + Census.join(:users, "reconnect-tenant", pid) + + metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary() + assert String.contains?(metrics, "tenant=\"reconnect-tenant\"") + + start_supervised!( + {MetricsCleaner, [metrics_cleaner_schedule_timer_in_ms: 100, vacant_metric_threshold_in_seconds: 1]} + ) + + # Disconnect + Census.leave(:users, "reconnect-tenant", pid) + Process.sleep(500) + + # Reconnect before threshold + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + Census.join(:users, "reconnect-tenant", pid2) - %{tenant: tenant} + # Wait for cleanup to run + Process.sleep(2200) + + # Metrics should still be present + metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary() + assert String.contains?(metrics, "tenant=\"reconnect-tenant\"") + end end - describe "metrics cleanup" do - test "cleans up metrics for users that have been disconnected", %{tenant: %{external_id: external_id}} do - start_supervised!(MetricsCleaner) - {:ok, _} = Connect.lookup_or_start_connection(external_id) - # Wait for promex to collect the metrics - Process.sleep(6000) + describe "metrics cleanup - disconnected tenants" do + test "cleans up metrics for tenants that have been unregistered" do + :telemetry.execute( + [:realtime, :connections], + %{connected: 1, connected_cluster: 10, limit: 100}, + %{tenant: "connected-tenant"} + ) + + :telemetry.execute( + [:realtime, :connections], + %{connected: 0, connected_cluster: 20, limit: 100}, + %{tenant: "disconnected-tenant1"} + ) - Realtime.Telemetry.execute( + :telemetry.execute( [:realtime, :connections], - %{connected: 10, connected_cluster: 10, limit: 100}, - %{tenant: external_id} + %{connected: 0, connected_cluster: 20, limit: 100}, + %{tenant: "disconnected-tenant2"} ) - assert Realtime.PromEx.Metrics - |> :ets.select([{{{:_, %{tenant: :"$1"}}, :_}, [], [:"$1"]}]) - |> Enum.any?(&(&1 == external_id)) + metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary() + + assert String.contains?(metrics, "tenant=\"connected-tenant\"") + assert String.contains?(metrics, "tenant=\"disconnected-tenant1\"") + assert String.contains?(metrics, "tenant=\"disconnected-tenant2\"") + + start_supervised!( + {MetricsCleaner, [metrics_cleaner_schedule_timer_in_ms: 100, vacant_metric_threshold_in_seconds: 1]} + ) - Connect.shutdown(external_id) + # Simulate tenant registration (connected) + :telemetry.execute([:syn, Connect, :registered], %{}, %{name: "connected-tenant"}) + + # Simulate tenant unregistration (disconnected) + :telemetry.execute([:syn, Connect, :unregistered], %{}, %{name: "disconnected-tenant1"}) + :telemetry.execute([:syn, Connect, :unregistered], %{}, %{name: "disconnected-tenant2"}) + + # Wait for clean up to run Process.sleep(200) - refute Realtime.PromEx.Metrics - |> :ets.select([{{{:_, %{tenant: :"$1"}}, :_}, [], [:"$1"]}]) - |> Enum.any?(&(&1 == external_id)) + # Nothing changes yet (threshold not reached) + metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary() + + assert String.contains?(metrics, "tenant=\"connected-tenant\"") + assert String.contains?(metrics, "tenant=\"disconnected-tenant1\"") + assert String.contains?(metrics, "tenant=\"disconnected-tenant2\"") + + # Wait for threshold to pass and cleanup to run + Process.sleep(2200) + + # disconnected tenant metrics are now gone + metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary() + + assert String.contains?(metrics, "tenant=\"connected-tenant\"") + refute String.contains?(metrics, "tenant=\"disconnected-tenant1\"") + refute String.contains?(metrics, "tenant=\"disconnected-tenant2\"") + end + + test "does not clean up metrics if tenant reconnects before threshold" do + :telemetry.execute( + [:realtime, :connections], + %{connected: 1, connected_cluster: 10, limit: 100}, + %{tenant: "reconnect-tenant"} + ) + + metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary() + assert String.contains?(metrics, "tenant=\"reconnect-tenant\"") + + start_supervised!( + {MetricsCleaner, [metrics_cleaner_schedule_timer_in_ms: 100, vacant_metric_threshold_in_seconds: 1]} + ) + + # Simulate tenant unregistration + :telemetry.execute([:syn, Connect, :unregistered], %{}, %{name: "reconnect-tenant"}) + Process.sleep(500) + + # Re-register before threshold + :telemetry.execute([:syn, Connect, :registered], %{}, %{name: "reconnect-tenant"}) + + # Wait for cleanup to run + Process.sleep(2200) + + # Metrics should still be present + metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary() + assert String.contains?(metrics, "tenant=\"reconnect-tenant\"") + end + end + + describe "handle_info/2 unexpected message" do + test "logs error for unexpected messages" do + import ExUnit.CaptureLog + + pid = + start_supervised!( + {MetricsCleaner, [metrics_cleaner_schedule_timer_in_ms: 60_000, vacant_metric_threshold_in_seconds: 600]} + ) + + log = + capture_log(fn -> + send(pid, :something_unexpected) + Process.sleep(100) + end) + + assert log =~ "Unexpected message" + assert log =~ "something_unexpected" + end + end + + describe "handle_forum_event/4" do + test "inserts and deletes from ETS table" do + table = :ets.new(:test_forum, [:set, :public]) + + MetricsCleaner.handle_forum_event( + [:forum, :users, :group, :vacant], + %{}, + %{group: "test-tenant"}, + table + ) + + assert [{"test-tenant", _timestamp}] = :ets.lookup(table, "test-tenant") + + MetricsCleaner.handle_forum_event( + [:forum, :users, :group, :occupied], + %{}, + %{group: "test-tenant"}, + table + ) + + assert [] = :ets.lookup(table, "test-tenant") + end + end + + describe "handle_syn_event/4" do + test "inserts and deletes from ETS table" do + table = :ets.new(:test_syn, [:set, :public]) + + MetricsCleaner.handle_syn_event( + [:syn, Connect, :unregistered], + %{}, + %{name: "test-tenant"}, + table + ) + + assert [{"test-tenant", _timestamp}] = :ets.lookup(table, "test-tenant") + + MetricsCleaner.handle_syn_event( + [:syn, Connect, :registered], + %{}, + %{name: "test-tenant"}, + table + ) + + assert [] = :ets.lookup(table, "test-tenant") end end end diff --git a/test/realtime/metrics_pusher_test.exs b/test/realtime/metrics_pusher_test.exs new file mode 100644 index 000000000..5392a929d --- /dev/null +++ b/test/realtime/metrics_pusher_test.exs @@ -0,0 +1,226 @@ +defmodule Realtime.MetricsPusherTest do + use Realtime.DataCase, async: true + import ExUnit.CaptureLog + + alias Realtime.MetricsPusher + alias Plug.Conn + + setup {Req.Test, :verify_on_exit!} + + # Helper function to start MetricsPusher and allow it to use Req.Test + defp start_and_allow_pusher(opts) do + opts = Keyword.put(opts, :interval, :timer.minutes(5)) + pid = start_supervised!({MetricsPusher, opts}) + Req.Test.allow(MetricsPusher, self(), pid) + send(pid, :push) + {:ok, pid} + end + + describe "start_link/1" do + test "does not start when URL is missing" do + opts = [enabled: true] + assert :ignore = MetricsPusher.start_link(opts) + end + + test "sends request successfully" do + opts = [ + url: "https://example.com:8428/api/v1/import/prometheus", + user: "realtime", + auth: "hunter2", + compress: true, + timeout: 5000 + ] + + :telemetry.execute([:realtime, :channel, :input_bytes], %{size: 1024}, %{tenant: "test_tenant"}) + + parent = self() + + # Expect 2 requests: one for global metrics, one for tenant metrics + Req.Test.expect(MetricsPusher, 2, fn conn -> + assert conn.method == "POST" + assert conn.scheme == :https + assert conn.host == "example.com" + assert conn.port == 8428 + assert conn.request_path == "/api/v1/import/prometheus" + assert Conn.get_req_header(conn, "authorization") == ["Basic #{Base.encode64("realtime:hunter2")}"] + assert Conn.get_req_header(conn, "content-encoding") == ["gzip"] + assert Conn.get_req_header(conn, "content-type") == ["text/plain"] + + body = Req.Test.raw_body(conn) + decompressed_body = :zlib.gunzip(body) + + # Collect decompressed bodies so we can assert that one has global metrics + # and the other has tenant metrics. + send(parent, {:req_called, decompressed_body}) + Req.Test.text(conn, "") + end) + + {:ok, _pid} = start_and_allow_pusher(opts) + + # Receive both request bodies + assert_receive {:req_called, body1}, 300 + assert_receive {:req_called, body2}, 300 + + global_metric = ~r/beam_stats_run_queue_count/ + tenant_metric = ~r/realtime_channel_input_bytes/ + + # One request must contain a global-only metric, the other a tenant-only metric. + assert (Regex.match?(global_metric, body1) and Regex.match?(tenant_metric, body2)) or + (Regex.match?(global_metric, body2) and Regex.match?(tenant_metric, body1)) + end + + test "sends request successfully without auth header" do + opts = [ + url: "http://localhost:8428/api/v1/import/prometheus", + compress: true, + timeout: 5000 + ] + + parent = self() + + Req.Test.expect(MetricsPusher, 2, fn conn -> + assert Conn.get_req_header(conn, "authorization") == [] + + send(parent, :req_called) + Req.Test.text(conn, "") + end) + + {:ok, _pid} = start_and_allow_pusher(opts) + assert_receive :req_called, 300 + assert_receive :req_called, 300 + end + + test "sends request body untouched when compress=false" do + opts = [ + url: "http://localhost:8428/api/v1/import/prometheus", + user: "hunter2", + auth: "realtime", + compress: false, + timeout: 5000 + ] + + parent = self() + + Req.Test.expect(MetricsPusher, 2, fn conn -> + assert Conn.get_req_header(conn, "content-encoding") == [] + assert Conn.get_req_header(conn, "content-type") == ["text/plain"] + + send(parent, :req_called) + Req.Test.text(conn, "") + end) + + {:ok, _pid} = start_and_allow_pusher(opts) + assert_receive :req_called, 300 + assert_receive :req_called, 300 + end + + test "when request receives non 2XX response" do + opts = [ + url: "https://example.com:8428/api/v1/import/prometheus", + auth: "hunter2", + compress: true, + timeout: 5000 + ] + + parent = self() + + log = + capture_log(fn -> + Req.Test.expect(MetricsPusher, 2, fn conn -> + send(parent, :req_called) + Conn.send_resp(conn, 500, "") + end) + + {:ok, pid} = start_and_allow_pusher(opts) + assert_receive :req_called, 300 + assert_receive :req_called, 300 + assert Process.alive?(pid) + # Wait enough for the log to be captured + Process.sleep(100) + end) + + assert log =~ "MetricsPusher: Failed to push" + assert log =~ "metrics to" + assert log =~ "error_code=MetricsPusherFailed" + end + + test "when an error is raised" do + opts = [ + url: "https://example.com:8428/api/v1/import/prometheus", + timeout: 5000 + ] + + parent = self() + + log = + capture_log(fn -> + Req.Test.expect(MetricsPusher, 2, fn _conn -> + send(parent, :req_called) + raise RuntimeError, "unexpected error" + end) + + {:ok, pid} = start_and_allow_pusher(opts) + assert_receive :req_called, 300 + assert_receive :req_called, 300 + assert Process.alive?(pid) + # Wait enough for the log to be captured + Process.sleep(100) + end) + + assert log =~ "MetricsPusher: Exception during" + assert log =~ "push: %RuntimeError{message: \"unexpected error\"}" + assert log =~ "error_code=MetricsPusherException" + end + + test "appends extra_label query params to URL" do + opts = [ + url: "http://localhost:8428/api/v1/import/prometheus", + compress: false, + timeout: 5000, + extra_labels: [{"region", "us-east-1"}, {"env", "prod"}] + ] + + parent = self() + + Req.Test.expect(MetricsPusher, 2, fn conn -> + send(parent, {:req_called, conn.query_string}) + Req.Test.text(conn, "") + end) + + {:ok, _pid} = start_and_allow_pusher(opts) + assert_receive {:req_called, query_string}, 300 + assert_receive {:req_called, _}, 300 + + decoded_params = query_string |> String.split("&") |> Enum.map(&URI.decode_www_form/1) + assert "extra_label=region=us-east-1" in decoded_params + assert "extra_label=env=prod" in decoded_params + end + + test "logs unexpected messages and stays alive" do + parent = self() + + Req.Test.expect(MetricsPusher, 2, fn conn -> + send(parent, :push_happened) + Req.Test.text(conn, "") + end) + + {:ok, pid} = + start_and_allow_pusher( + url: "http://localhost:8428/api/v1/import/prometheus", + timeout: 5000 + ) + + assert_receive :push_happened, 500 + assert_receive :push_happened, 500 + + log = + capture_log(fn -> + send(pid, :unexpected_message) + Process.sleep(50) + assert Process.alive?(pid) + end) + + assert log =~ "MetricsPusher received unexpected message: :unexpected_message" + end + end +end diff --git a/test/realtime/monitoring/distributed_metrics_test.exs b/test/realtime/monitoring/distributed_metrics_test.exs index 491083973..49fe4af6f 100644 --- a/test/realtime/monitoring/distributed_metrics_test.exs +++ b/test/realtime/monitoring/distributed_metrics_test.exs @@ -15,7 +15,7 @@ defmodule Realtime.DistributedMetricsTest do ^node => %{ pid: _pid, port: _port, - queue_size: {:ok, 0}, + queue_size: {:ok, _}, state: :up, inet_stats: [ recv_oct: _, diff --git a/test/realtime/monitoring/erl_sys_mon_test.exs b/test/realtime/monitoring/erl_sys_mon_test.exs index b1e122d58..b14f79b58 100644 --- a/test/realtime/monitoring/erl_sys_mon_test.exs +++ b/test/realtime/monitoring/erl_sys_mon_test.exs @@ -5,16 +5,53 @@ defmodule Realtime.Monitoring.ErlSysMonTest do describe "system monitoring" do test "logs system monitor events" do - start_supervised!({ErlSysMon, config: [{:long_message_queue, {1, 10}}]}) - - assert capture_log(fn -> - Task.async(fn -> - Enum.map(1..1000, &send(self(), &1)) - # Wait for ErlSysMon to notice - Process.sleep(4000) - end) - |> Task.await() - end) =~ "Realtime.ErlSysMon message:" + start_supervised!({ErlSysMon, config: [{:long_message_queue, {1, 100}}]}) + + log = + capture_log(fn -> + Task.async(fn -> + Process.register(self(), TestProcess) + Enum.map(1..1000, &send(self(), &1)) + # Wait for ErlSysMon to notice + Process.sleep(4000) + end) + |> Task.await() + end) + + assert log =~ "Realtime.ErlSysMon message:" + assert log =~ "$initial_call\", {Realtime.Monitoring.ErlSysMonTest" + assert log =~ "ancestors\", [#{inspect(self())}]" + assert log =~ "registered_name: TestProcess" + assert log =~ "message_queue_len: " + assert log =~ "total_heap_size: " + end + + test "logs non-pid monitor messages" do + {:ok, pid} = ErlSysMon.start_link(config: []) + + log = + capture_log(fn -> + send(pid, {:unexpected, "message"}) + Process.sleep(100) + end) + + assert log =~ "Realtime.ErlSysMon message:" + assert log =~ "unexpected" + end + + test "handles monitor event for dead process without crashing" do + {:ok, pid} = ErlSysMon.start_link(config: []) + + dead_pid = spawn(fn -> :ok end) + Process.sleep(50) + + log = + capture_log(fn -> + send(pid, {:monitor, dead_pid, :long_gc, %{timeout: 500}}) + Process.sleep(100) + end) + + assert log =~ "Realtime.ErlSysMon message:" end end end diff --git a/test/realtime/monitoring/gen_rpc_metrics_test.exs b/test/realtime/monitoring/gen_rpc_metrics_test.exs index 722bc8c02..1c25b8c0d 100644 --- a/test/realtime/monitoring/gen_rpc_metrics_test.exs +++ b/test/realtime/monitoring/gen_rpc_metrics_test.exs @@ -60,20 +60,17 @@ defmodule Realtime.GenRpcMetricsTest do assert local_metrics[:connections] == remote_metrics[:connections] - assert local_metrics[:send_avg] == remote_metrics[:recv_avg] - assert local_metrics[:recv_avg] == remote_metrics[:send_avg] + assert_in_delta local_metrics[:send_avg], remote_metrics[:recv_avg], 200 + assert_in_delta local_metrics[:recv_avg], remote_metrics[:send_avg], 200 - assert local_metrics[:send_oct] == remote_metrics[:recv_oct] - assert local_metrics[:recv_oct] == remote_metrics[:send_oct] + assert_in_delta local_metrics[:send_oct], remote_metrics[:recv_oct], 1000 + assert_in_delta local_metrics[:recv_oct], remote_metrics[:send_oct], 1000 - assert local_metrics[:send_cnt] == remote_metrics[:recv_cnt] - assert local_metrics[:recv_cnt] == remote_metrics[:send_cnt] + assert_in_delta local_metrics[:send_cnt], remote_metrics[:recv_cnt], 10 + assert_in_delta local_metrics[:recv_cnt], remote_metrics[:send_cnt], 10 - assert local_metrics[:send_max] == remote_metrics[:recv_max] - assert local_metrics[:recv_max] == remote_metrics[:send_max] - - assert local_metrics[:send_max] == remote_metrics[:recv_max] - assert local_metrics[:recv_max] == remote_metrics[:send_max] + assert_in_delta local_metrics[:send_max], remote_metrics[:recv_max], 1000 + assert_in_delta local_metrics[:recv_max], remote_metrics[:send_max], 1000 end end end diff --git a/test/realtime/monitoring/latency_test.exs b/test/realtime/monitoring/latency_test.exs index 8e43f0d06..379e5f212 100644 --- a/test/realtime/monitoring/latency_test.exs +++ b/test/realtime/monitoring/latency_test.exs @@ -3,30 +3,59 @@ defmodule Realtime.LatencyTest do use Realtime.DataCase, async: false alias Realtime.Latency + describe "pong/0" do + test "returns pong with region" do + assert {:ok, {:pong, region}} = Latency.pong() + assert is_binary(region) + end + end + + describe "pong/1" do + test "returns pong after sleeping for the given latency" do + assert {:ok, {:pong, _region}} = Latency.pong(0) + end + end + + describe "handle_info/2" do + test "unexpected message does not crash the server" do + pid = Process.whereis(Latency) + send(pid, :unexpected_message) + assert Process.alive?(pid) + end + end + + describe "handle_cast/2" do + test "ping cast triggers a ping and does not crash" do + pid = Process.whereis(Latency) + GenServer.cast(pid, {:ping, 0, 5_000, 5_000}) + assert Process.alive?(pid) + end + end + describe "ping/3" do setup do - Node.stop() + for node <- Node.list(), do: Node.disconnect(node) :ok end - @tag skip: "Clustered tests creating flakiness, requires time to analyse" - test "emulate a healthy remote node" do - assert [{%Task{}, {:ok, %{response: {:ok, {:pong, "not_set"}}}}}] = Latency.ping() + test "returns pong from healthy remote node" do + {:ok, _node} = Clustered.start() + results = Latency.ping() + assert Enum.all?(results, fn {%Task{}, result} -> match?({:ok, %{response: {:ok, {:pong, _}}}}, result) end) end - @tag skip: "Clustered tests creating flakiness, requires time to analyse" - test "emulate a slow but healthy remote node" do - assert [{%Task{}, {:ok, %{response: {:ok, {:pong, "not_set"}}}}}] = Latency.ping(5_000, 10_000, 30_000) + test "returns pong from slow but healthy remote node" do + {:ok, _node} = Clustered.start() + results = Latency.ping(100, 10_000, 30_000) + assert Enum.all?(results, fn {%Task{}, result} -> match?({:ok, %{response: {:ok, {:pong, _}}}}, result) end) end - @tag skip: "Clustered tests creating flakiness, requires time to analyse" - test "emulate an unhealthy remote node" do - assert [{%Task{}, {:ok, %{response: {:badrpc, :timeout}}}}] = Latency.ping(5_000, 1_000) + test "returns error when remote node exceeds timer timeout" do + assert [{%Task{}, {:ok, %{response: {:error, :rpc_error, _}}}}] = Latency.ping(500, 100) end - @tag skip: "Clustered tests creating flakiness, requires time to analyse" - test "no response from our Task for a remote node at all" do - assert [{%Task{}, nil}] = Latency.ping(10_000, 5_000, 2_000) + test "returns nil when task does not yield before yield timeout" do + assert [{%Task{}, nil}] = Latency.ping(1_000, 500, 100) end end end diff --git a/test/realtime/monitoring/peep/partitioned_tables_test.exs b/test/realtime/monitoring/peep/partitioned_tables_test.exs new file mode 100644 index 000000000..88918dee8 --- /dev/null +++ b/test/realtime/monitoring/peep/partitioned_tables_test.exs @@ -0,0 +1,158 @@ +Application.put_env(:peep, :test_storages, [ + {Realtime.Monitoring.Peep.PartitionedTables, [tables: 4]}, + {Realtime.Monitoring.Peep.PartitionedTables, [tables: 4, routing_tag: :tenant_id]}, + {Realtime.Monitoring.Peep.PartitionedTables, [tables: 1]} +]) + +Code.require_file("../../../../deps/peep/test/shared/storage_test.exs", __DIR__) + +defmodule Realtime.Monitoring.Peep.PartitionedTablesTest do + use ExUnit.Case, async: true + + alias Realtime.Monitoring.Peep.PartitionedTables + alias Telemetry.Metrics + + describe "get_all_metrics" do + test "collects metrics from all tables" do + counter = Metrics.counter("all_metrics.test.counter") + last_value = Metrics.last_value("all_metrics.test.gauge") + + n_tables = 4 + tenant_a = "tenant-alpha" + tenant_b = "tenant-beta" + + assert :erlang.phash2(tenant_a, n_tables) != :erlang.phash2(tenant_b, n_tables) + + name = :"test_all_metrics_#{System.unique_integer([:positive])}" + + {:ok, _} = + Peep.start_link( + name: name, + metrics: [counter, last_value], + storage: {PartitionedTables, [tables: n_tables, routing_tag: :tenant_id]} + ) + + tags_a = %{tenant_id: tenant_a} + tags_b = %{tenant_id: tenant_b} + + for _ <- 1..3, do: Peep.insert_metric(name, counter, 1, tags_a) + for _ <- 1..7, do: Peep.insert_metric(name, counter, 1, tags_b) + for _ <- 1..11, do: Peep.insert_metric(name, counter, 1, %{}) + Peep.insert_metric(name, last_value, 42, tags_a) + Peep.insert_metric(name, last_value, 99, tags_b) + Peep.insert_metric(name, last_value, 111, %{}) + + all = Peep.get_all_metrics(name) + + assert all[counter][tags_a] == 3 + assert all[counter][tags_b] == 7 + assert all[counter][%{}] == 11 + assert all[last_value][tags_a] == 42 + assert all[last_value][tags_b] == 99 + assert all[last_value][%{}] == 111 + end + end + + describe "routing tag" do + test "routes different tag values to different tables" do + n_tables = 4 + routing_tag = :tenant_id + {tids, ^routing_tag} = PartitionedTables.new(tables: n_tables, routing_tag: routing_tag) + + counter = Metrics.counter("routing.test.counter") + id = :erlang.phash2(counter) + + tenant_a = "tenant-alpha" + tenant_b = "tenant-beta" + + index_a = :erlang.phash2(tenant_a, n_tables) + index_b = :erlang.phash2(tenant_b, n_tables) + + # Ensure the two tenants map to different tables for this test to be meaningful + assert index_a != index_b, + "tenant-alpha and tenant-beta must hash to different table indices" + + tags_a = %{tenant_id: tenant_a} + tags_b = %{tenant_id: tenant_b} + + for _ <- 1..10 do + PartitionedTables.insert_metric({tids, routing_tag}, id, counter, 1, tags_a) + PartitionedTables.insert_metric({tids, routing_tag}, id, counter, 1, tags_b) + end + + # Each tenant's data is in its own table + assert :ets.lookup(elem(tids, index_a), {id, tags_a}) != [] + assert :ets.lookup(elem(tids, index_b), {id, tags_b}) != [] + + # Cross-table: each tenant's key must NOT exist in the other's table + assert :ets.lookup(elem(tids, index_a), {id, tags_b}) == [] + assert :ets.lookup(elem(tids, index_b), {id, tags_a}) == [] + end + + test "falls back to first table when routing tag is absent from tags" do + n_tables = 4 + routing_tag = :tenant_id + {tids, ^routing_tag} = PartitionedTables.new(tables: n_tables, routing_tag: routing_tag) + + counter = Metrics.counter("fallback.test.counter") + id = :erlang.phash2(counter) + tags = %{env: :prod} + + PartitionedTables.insert_metric({tids, routing_tag}, id, counter, 1, tags) + + assert :ets.lookup(elem(tids, 0), {id, tags}) != [] + + for i <- 1..(n_tables - 1) do + assert :ets.lookup(elem(tids, i), {id, tags}) == [] + end + end + + test "prune_tags targets only the relevant table for patterns with routing tag" do + n_tables = 4 + routing_tag = :tenant_id + {tids, ^routing_tag} = PartitionedTables.new(tables: n_tables, routing_tag: routing_tag) + + counter = Metrics.counter("prune.targeted.test.counter") + id = :erlang.phash2(counter) + + tenant_a = "tenant-alpha" + tenant_b = "tenant-beta" + + index_a = :erlang.phash2(tenant_a, n_tables) + index_b = :erlang.phash2(tenant_b, n_tables) + assert index_a != index_b + + tags_a = %{tenant_id: tenant_a} + tags_b = %{tenant_id: tenant_b} + + PartitionedTables.insert_metric({tids, routing_tag}, id, counter, 1, tags_a) + PartitionedTables.insert_metric({tids, routing_tag}, id, counter, 1, tags_b) + + # Prune only tenant A using a targeted pattern + :ok = PartitionedTables.prune_tags({tids, routing_tag}, [tags_a]) + + assert :ets.lookup(elem(tids, index_a), {id, tags_a}) == [] + assert :ets.lookup(elem(tids, index_b), {id, tags_b}) != [] + end + + test "prune_tags targets table 0 when pattern has no routing tag" do + n_tables = 4 + routing_tag = :tenant_id + {tids, ^routing_tag} = PartitionedTables.new(tables: n_tables, routing_tag: routing_tag) + + counter = Metrics.counter("prune.broadcast.test.counter") + id = :erlang.phash2(counter) + + # Tags with no routing key → written to table 0 + tags = %{env: :prod} + PartitionedTables.insert_metric({tids, routing_tag}, id, counter, 1, tags) + + assert :ets.lookup(elem(tids, 0), {id, tags}) != [] + + # Pattern without routing tag → deletes from table 0 only + :ok = PartitionedTables.prune_tags({tids, routing_tag}, [%{env: :prod}]) + + assert :ets.lookup(elem(tids, 0), {id, tags}) == [] + end + end +end diff --git a/test/realtime/monitoring/peep/partitioned_test.exs b/test/realtime/monitoring/peep/partitioned_test.exs new file mode 100644 index 000000000..47be695c8 --- /dev/null +++ b/test/realtime/monitoring/peep/partitioned_test.exs @@ -0,0 +1,6 @@ +Application.put_env(:peep, :test_storages, [ + {Realtime.Monitoring.Peep.Partitioned, 3}, + {Realtime.Monitoring.Peep.Partitioned, 1} +]) + +Code.require_file("../../../../deps/peep/test/shared/storage_test.exs", __DIR__) diff --git a/test/realtime/monitoring/prom_ex/plugins/channels_test.exs b/test/realtime/monitoring/prom_ex/plugins/channels_test.exs new file mode 100644 index 000000000..b006ab387 --- /dev/null +++ b/test/realtime/monitoring/prom_ex/plugins/channels_test.exs @@ -0,0 +1,42 @@ +defmodule Realtime.PromEx.Plugins.ChannelsTest do + use Realtime.DataCase, async: false + + alias Realtime.PromEx.Plugins.Channels + alias RealtimeWeb.RealtimeChannel.Logging + + defmodule MetricsTest do + use PromEx, otp_app: :realtime_test_channels + @impl true + def plugins do + [Channels] + end + end + + setup_all do + start_supervised!(MetricsTest) + :ok + end + + test "counts channel errors with tenant tag in prometheus" do + tenant_id = random_string() + socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}} + error = "TestError" + + previous_value = metric_value("realtime_channel_error", code: error, tenant: tenant_id) || 0 + Logging.maybe_log_error(socket, error, "test error") + assert metric_value("realtime_channel_error", code: error, tenant: tenant_id) == previous_value + 1 + end + + test "does not count warnings in the error metric" do + tenant_id = random_string() + socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}} + error = "TestWarning" + + Logging.maybe_log_warning(socket, error, "test warning") + assert metric_value("realtime_channel_error", code: error, tenant: tenant_id) == nil + end + + defp metric_value(metric, expected_tags) do + MetricsHelper.search(PromEx.get_metrics(MetricsTest), metric, expected_tags) + end +end diff --git a/test/realtime/monitoring/prom_ex/plugins/distributed_test.exs b/test/realtime/monitoring/prom_ex/plugins/distributed_test.exs index ff4c4f098..731873066 100644 --- a/test/realtime/monitoring/prom_ex/plugins/distributed_test.exs +++ b/test/realtime/monitoring/prom_ex/plugins/distributed_test.exs @@ -23,55 +23,41 @@ defmodule Realtime.PromEx.Plugins.DistributedTest do describe "pooling metrics" do setup do - metrics = - PromEx.get_metrics(MetricsTest) - |> String.split("\n", trim: true) - - %{metrics: metrics} + %{metrics: PromEx.get_metrics(MetricsTest)} end test "send_pending_bytes", %{metrics: metrics, node: node} do - pattern = ~r/dist_send_pending_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/ - assert metric_value(metrics, pattern) == 0 + assert metric_value(metrics, "dist_send_pending_bytes", origin_node: node(), target_node: node) == 0 end test "send_count", %{metrics: metrics, node: node} do - pattern = ~r/dist_send_count{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/ - assert metric_value(metrics, pattern) > 0 + value = metric_value(metrics, "dist_send_count", origin_node: node(), target_node: node) + assert is_integer(value) + assert value > 0 end test "send_bytes", %{metrics: metrics, node: node} do - pattern = ~r/dist_send_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/ - assert metric_value(metrics, pattern) > 0 + value = metric_value(metrics, "dist_send_bytes", origin_node: node(), target_node: node) + assert is_integer(value) + assert value > 0 end test "recv_count", %{metrics: metrics, node: node} do - pattern = ~r/dist_recv_count{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/ - assert metric_value(metrics, pattern) > 0 + value = metric_value(metrics, "dist_recv_count", origin_node: node(), target_node: node) + assert is_integer(value) + assert value > 0 end test "recv_bytes", %{metrics: metrics, node: node} do - pattern = ~r/dist_recv_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/ - assert metric_value(metrics, pattern) > 0 + value = metric_value(metrics, "dist_recv_bytes", origin_node: node(), target_node: node) + assert is_integer(value) + assert value > 0 end test "queue_size", %{metrics: metrics, node: node} do - pattern = ~r/dist_queue_size{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/ - assert is_integer(metric_value(metrics, pattern)) + assert is_integer(metric_value(metrics, "dist_queue_size", origin_node: node(), target_node: node)) end end - defp metric_value(metrics, pattern) do - metrics - |> Enum.find_value( - "0", - fn item -> - case Regex.run(pattern, item, capture: ["number"]) do - [number] -> number - _ -> false - end - end - ) - |> String.to_integer() - end + defp metric_value(metrics, metric, expected_tags), do: MetricsHelper.search(metrics, metric, expected_tags) end diff --git a/test/realtime/monitoring/prom_ex/plugins/gen_rpc_test.exs b/test/realtime/monitoring/prom_ex/plugins/gen_rpc_test.exs index 25d8fae16..5396aae6b 100644 --- a/test/realtime/monitoring/prom_ex/plugins/gen_rpc_test.exs +++ b/test/realtime/monitoring/prom_ex/plugins/gen_rpc_test.exs @@ -23,55 +23,42 @@ defmodule Realtime.PromEx.Plugins.GenRpcTest do describe "pooling metrics" do setup do - metrics = - PromEx.get_metrics(MetricsTest) - |> String.split("\n", trim: true) - - %{metrics: metrics} + %{metrics: PromEx.get_metrics(MetricsTest)} end test "send_pending_bytes", %{metrics: metrics, node: node} do - pattern = ~r/gen_rpc_send_pending_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/ - assert metric_value(metrics, pattern) == 0 + assert metric_value(metrics, "gen_rpc_send_pending_bytes", origin_node: node(), target_node: node) == 0 end test "send_count", %{metrics: metrics, node: node} do - pattern = ~r/gen_rpc_send_count{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/ - assert metric_value(metrics, pattern) > 0 + value = metric_value(metrics, "gen_rpc_send_count", origin_node: node(), target_node: node) + assert is_integer(value) + assert value > 0 end test "send_bytes", %{metrics: metrics, node: node} do - pattern = ~r/gen_rpc_send_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/ - assert metric_value(metrics, pattern) > 0 + value = metric_value(metrics, "gen_rpc_send_bytes", origin_node: node(), target_node: node) + assert is_integer(value) + assert value > 0 end test "recv_count", %{metrics: metrics, node: node} do - pattern = ~r/gen_rpc_recv_count{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/ - assert metric_value(metrics, pattern) > 0 + value = metric_value(metrics, "gen_rpc_recv_count", origin_node: node(), target_node: node) + assert is_integer(value) + assert value > 0 end test "recv_bytes", %{metrics: metrics, node: node} do - pattern = ~r/gen_rpc_recv_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/ - assert metric_value(metrics, pattern) > 0 + value = metric_value(metrics, "gen_rpc_recv_bytes", origin_node: node(), target_node: node) + assert is_integer(value) + assert value > 0 end test "queue_size", %{metrics: metrics, node: node} do - pattern = ~r/gen_rpc_queue_size_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/ - assert metric_value(metrics, pattern) == 0 + value = metric_value(metrics, "gen_rpc_queue_size_bytes", origin_node: node(), target_node: node) + assert is_integer(value) end end - defp metric_value(metrics, pattern) do - metrics - |> Enum.find_value( - "0", - fn item -> - case Regex.run(pattern, item, capture: ["number"]) do - [number] -> number - _ -> false - end - end - ) - |> String.to_integer() - end + defp metric_value(metrics, metric, expected_tags), do: MetricsHelper.search(metrics, metric, expected_tags) end diff --git a/test/realtime/monitoring/prom_ex/plugins/migrations_test.exs b/test/realtime/monitoring/prom_ex/plugins/migrations_test.exs new file mode 100644 index 000000000..70333af97 --- /dev/null +++ b/test/realtime/monitoring/prom_ex/plugins/migrations_test.exs @@ -0,0 +1,126 @@ +defmodule Realtime.PromEx.Plugins.MigrationsTest do + use Realtime.DataCase, async: false + + alias Realtime.PromEx.Plugins.Migrations + alias Realtime.Telemetry + + defmodule MetricsTest do + use PromEx, otp_app: :realtime_test_migrations + + @impl true + def plugins, do: [Migrations] + end + + setup_all do + start_supervised!(MetricsTest) + :ok + end + + defp metric_value(metric, expected_tags \\ nil) do + MetricsTest + |> PromEx.get_metrics() + |> MetricsHelper.search(metric, expected_tags) + end + + test "records migration duration histogram on stop" do + start_time = + Telemetry.start([:realtime, :tenants, :migrations], %{ + external_id: "tenant", + hostname: "localhost", + platform_region: "sa-east-1" + }) + + Telemetry.stop( + [:realtime, :tenants, :migrations], + start_time, + %{external_id: "tenant", hostname: "localhost", platform_region: "sa-east-1", migrations_executed: 3} + ) + + assert metric_value("realtime_tenants_migrations_duration_milliseconds_count", platform_region: "sa-east-1") == 1 + + assert metric_value("realtime_tenants_migrations_duration_milliseconds_bucket", + platform_region: "sa-east-1", + le: "100.0" + ) > 0 + end + + test "skips duration histogram when migrations_executed is 0" do + before = + metric_value("realtime_tenants_migrations_duration_milliseconds_count", platform_region: "sa-east-1") || 0 + + start_time = + Telemetry.start([:realtime, :tenants, :migrations], %{ + external_id: "tenant", + hostname: "localhost", + platform_region: "sa-east-1" + }) + + Telemetry.stop( + [:realtime, :tenants, :migrations], + start_time, + %{external_id: "tenant", hostname: "localhost", platform_region: "sa-east-1", migrations_executed: 0} + ) + + assert (metric_value("realtime_tenants_migrations_duration_milliseconds_count", platform_region: "sa-east-1") || 0) == + before + end + + test "tags Postgrex errors with the SQLSTATE atom" do + metric = "realtime_tenants_migrations_exceptions_total" + start_time = Telemetry.start([:realtime, :tenants, :migrations], %{external_id: "tenant", hostname: "localhost"}) + + Telemetry.exception( + [:realtime, :tenants, :migrations], + start_time, + :error, + %Postgrex.Error{postgres: %{code: :undefined_column}}, + [], + %{external_id: "tenant", error_code: :undefined_column} + ) + + assert metric_value(metric, error_code: "undefined_column") == 1 + end + + test "tags connection errors with error_code=connection_error" do + metric = "realtime_tenants_migrations_exceptions_total" + start_time = Telemetry.start([:realtime, :tenants, :migrations], %{external_id: "tenant", hostname: "localhost"}) + + Telemetry.exception( + [:realtime, :tenants, :migrations], + start_time, + :error, + %DBConnection.ConnectionError{message: "ssl send: closed"}, + [], + %{external_id: "tenant", error_code: :connection_error} + ) + + assert metric_value(metric, error_code: "connection_error") == 1 + end + + test "counts reconciliations" do + start_time = Telemetry.start([:realtime, :tenants, :migrations, :reconcile], %{external_id: "tenant"}) + + Telemetry.stop( + [:realtime, :tenants, :migrations, :reconcile], + start_time, + %{external_id: "tenant", cached_migrations_ran: 60, database_migrations_ran: 65} + ) + + assert metric_value("realtime_tenants_migrations_reconcile_total") == 1 + end + + test "counts reconcile exceptions" do + start_time = Telemetry.start([:realtime, :tenants, :migrations, :reconcile], %{external_id: "tenant"}) + + Telemetry.exception( + [:realtime, :tenants, :migrations, :reconcile], + start_time, + :error, + %RuntimeError{message: "boom"}, + [], + %{external_id: "tenant"} + ) + + assert metric_value("realtime_tenants_migrations_reconcile_exceptions_total") == 1 + end +end diff --git a/test/realtime/monitoring/prom_ex/plugins/phoenix_test.exs b/test/realtime/monitoring/prom_ex/plugins/phoenix_test.exs index a73e6e2f5..8f1d7d5be 100644 --- a/test/realtime/monitoring/prom_ex/plugins/phoenix_test.exs +++ b/test/realtime/monitoring/prom_ex/plugins/phoenix_test.exs @@ -1,6 +1,7 @@ defmodule Realtime.PromEx.Plugins.PhoenixTest do use Realtime.DataCase, async: false alias Realtime.PromEx.Plugins + alias Realtime.Integration.WebsocketClient defmodule MetricsTest do use PromEx, otp_app: :realtime_test_phoenix @@ -10,34 +11,79 @@ defmodule Realtime.PromEx.Plugins.PhoenixTest do end end + setup_all do + start_supervised!(MetricsTest) + :ok + end + + setup do + %{tenant: Containers.checkout_tenant(run_migrations: true)} + end + describe "pooling metrics" do - setup do - start_supervised!(MetricsTest) - :ok + test "number of connections", %{tenant: tenant} do + {:ok, token} = token_valid(tenant, "anon", %{}) + + {:ok, _} = + WebsocketClient.connect( + self(), + uri(tenant, Phoenix.Socket.V1.JSONSerializer), + Phoenix.Socket.V1.JSONSerializer, + [{"x-api-key", token}] + ) + + {:ok, _} = + WebsocketClient.connect( + self(), + uri(tenant, Phoenix.Socket.V1.JSONSerializer), + Phoenix.Socket.V1.JSONSerializer, + [{"x-api-key", token}] + ) + + Process.sleep(200) + assert metric_value("phoenix_connections_total") >= 2 end + end + + describe "event metrics" do + test "socket connected", %{tenant: tenant} do + {:ok, token} = token_valid(tenant, "anon", %{}) - test "number of connections" do - # Trigger a connection by making a request to the endpoint - url = RealtimeWeb.Endpoint.url() <> "/healthcheck" - Req.get!(url) + {:ok, _} = + WebsocketClient.connect( + self(), + uri(tenant, Phoenix.Socket.V1.JSONSerializer), + Phoenix.Socket.V1.JSONSerializer, + [{"x-api-key", token}] + ) + + {:ok, _} = + WebsocketClient.connect( + self(), + uri(tenant, RealtimeWeb.Socket.V2Serializer), + RealtimeWeb.Socket.V2Serializer, + [{"x-api-key", token}] + ) Process.sleep(200) - assert metric_value() > 0 + + assert metric_value("phoenix_socket_connected_duration_milliseconds_count", + endpoint: "RealtimeWeb.Endpoint", + result: "ok", + serializer: "Elixir.Phoenix.Socket.V1.JSONSerializer", + transport: "websocket" + ) >= 1 + + assert metric_value("phoenix_socket_connected_duration_milliseconds_count", + endpoint: "RealtimeWeb.Endpoint", + result: "ok", + serializer: "Elixir.RealtimeWeb.Socket.V2Serializer", + transport: "websocket" + ) >= 1 end end - defp metric_value() do - PromEx.get_metrics(MetricsTest) - |> String.split("\n", trim: true) - |> Enum.find_value( - "0", - fn item -> - case Regex.run(~r/phoenix_connections_total\s(?\d+)/, item, capture: ["number"]) do - [number] -> number - _ -> false - end - end - ) - |> String.to_integer() + defp metric_value(metric, expected_tags \\ nil) do + MetricsHelper.search(PromEx.get_metrics(MetricsTest), metric, expected_tags) end end diff --git a/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs b/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs index 164c8d2eb..70ad301a4 100644 --- a/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs +++ b/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs @@ -1,18 +1,26 @@ defmodule Realtime.PromEx.Plugins.TenantTest do - alias Realtime.Tenants.Authorization.Policies use Realtime.DataCase, async: false + alias Forum.Census + alias Realtime.GenCounter alias Realtime.PromEx.Plugins.Tenant + alias Realtime.PromEx.Plugins.TenantGlobal + alias Realtime.RateCounter alias Realtime.Rpc - alias Realtime.UsersCounter - alias Realtime.Tenants.Authorization.Policies alias Realtime.Tenants.Authorization + alias Realtime.Tenants.Authorization.Policies + alias Realtime.Tenants.Authorization.Policies defmodule MetricsTest do use PromEx, otp_app: :realtime_test_phoenix @impl true - def plugins, do: [{Tenant, poll_rate: 50}] + def plugins, do: [{Tenant, poll_rate: 50}, {TenantGlobal, poll_rate: 50}] + end + + setup_all do + start_supervised!(MetricsTest) + :ok end def handle_telemetry(event, metadata, content, pid: pid), do: send(pid, {event, metadata, content}) @@ -20,49 +28,57 @@ defmodule Realtime.PromEx.Plugins.TenantTest do @aux_mod (quote do defmodule FakeUserCounter do def fake_add(external_id) do - :ok = UsersCounter.add(spawn(fn -> Process.sleep(2000) end), external_id) + pid = spawn(fn -> Process.sleep(2000) end) + :ok = Census.join(:users, external_id, pid) end def fake_db_event(external_id) do - external_id - |> Realtime.Tenants.db_events_per_second_rate() - |> Realtime.RateCounter.new() + rate = Realtime.Tenants.db_events_per_second_rate(external_id, 100) - external_id - |> Realtime.Tenants.db_events_per_second_key() - |> Realtime.GenCounter.add() + rate + |> tap(&RateCounter.new(&1)) + |> tap(&GenCounter.add(&1.id)) + |> RateCounterHelper.tick!() end def fake_event(external_id) do - external_id - |> Realtime.Tenants.events_per_second_rate(123) - |> Realtime.RateCounter.new() + rate = Realtime.Tenants.events_per_second_rate(external_id, 123) - external_id - |> Realtime.Tenants.events_per_second_key() - |> Realtime.GenCounter.add() + rate + |> tap(&RateCounter.new(&1)) + |> tap(&GenCounter.add(&1.id)) + |> RateCounterHelper.tick!() end def fake_presence_event(external_id) do - external_id - |> Realtime.Tenants.presence_events_per_second_rate(123) - |> Realtime.RateCounter.new() + rate = Realtime.Tenants.presence_events_per_second_rate(external_id, 123) - external_id - |> Realtime.Tenants.presence_events_per_second_key() - |> Realtime.GenCounter.add() + rate + |> tap(&RateCounter.new(&1)) + |> tap(&GenCounter.add(&1.id)) + |> RateCounterHelper.tick!() end def fake_broadcast_from_database(external_id) do Realtime.Telemetry.execute( [:realtime, :tenants, :broadcast_from_database], %{ - latency_committed_at: 10, - latency_inserted_at: 1 + # millisecond + latency_committed_at: 9, + # microsecond + latency_inserted_at: 9000 }, %{tenant: external_id} ) end + + def fake_input_bytes(external_id) do + Realtime.Telemetry.execute([:realtime, :channel, :input_bytes], %{size: 10}, %{tenant: external_id}) + end + + def fake_output_bytes(external_id) do + Realtime.Telemetry.execute([:realtime, :channel, :output_bytes], %{size: 10}, %{tenant: external_id}) + end end end) @@ -75,7 +91,8 @@ defmodule Realtime.PromEx.Plugins.TenantTest do on_exit(fn -> :telemetry.detach(__MODULE__) end) - {:ok, node} = Clustered.start(@aux_mod) + {:ok, _} = Realtime.Tenants.Connect.lookup_or_start_connection(tenant.external_id) + {:ok, node} = Clustered.start(@aux_mod, extra_config: [{:realtime, :users_scope_broadcast_interval_in_ms, 50}]) %{tenant: tenant, node: node} end @@ -83,18 +100,22 @@ defmodule Realtime.PromEx.Plugins.TenantTest do tenant: %{external_id: external_id}, node: node } do - UsersCounter.add(self(), external_id) + :ok = Census.join(:users, external_id, self()) # Add bad tenant id - UsersCounter.add(self(), random_string()) + bad_tenant_id = random_string() + :ok = Census.join(:users, bad_tenant_id, self()) _ = Rpc.call(node, FakeUserCounter, :fake_add, [external_id]) + Process.sleep(500) Tenant.execute_tenant_metrics() assert_receive {[:realtime, :connections], %{connected: 1, limit: 200, connected_cluster: 2}, - %{tenant: ^external_id}} + %{tenant: ^external_id}}, + 500 - refute_receive :_ + refute_receive {[:realtime, :connections], %{connected: 1, limit: 200, connected_cluster: 2}, + %{tenant: ^bad_tenant_id}} end end @@ -113,47 +134,59 @@ defmodule Realtime.PromEx.Plugins.TenantTest do role: "anon" }) - start_supervised!(MetricsTest) - %{authorization_context: authorization_context, db_conn: db_conn, tenant: tenant} end test "event exists after counter added", %{tenant: %{external_id: external_id}} do - pattern = - ~r/realtime_channel_events{tenant="#{external_id}"}\s(?\d+)/ + metric_value = metric_value("realtime_channel_events", tenant: external_id) || 0 + FakeUserCounter.fake_event(external_id) + + Process.sleep(100) + assert metric_value("realtime_channel_events", tenant: external_id) == metric_value + 1 + end + + test "global event exists after counter added", %{tenant: %{external_id: external_id}} do + metric_value = metric_value("realtime_channel_global_events") || 0 - metric_value = metric_value(pattern) FakeUserCounter.fake_event(external_id) - Process.sleep(200) - assert metric_value(pattern) == metric_value + 1 + Process.sleep(100) + assert metric_value("realtime_channel_global_events") == metric_value + 1 end test "db_event exists after counter added", %{tenant: %{external_id: external_id}} do - pattern = - ~r/realtime_channel_db_events{tenant="#{external_id}"}\s(?\d+)/ + metric_value = metric_value("realtime_channel_db_events", tenant: external_id) || 0 + FakeUserCounter.fake_db_event(external_id) + Process.sleep(100) + assert metric_value("realtime_channel_db_events", tenant: external_id) == metric_value + 1 + end + + test "global db_event exists after counter added", %{tenant: %{external_id: external_id}} do + metric_value = metric_value("realtime_channel_global_db_events") || 0 - metric_value = metric_value(pattern) FakeUserCounter.fake_db_event(external_id) - Process.sleep(200) - assert metric_value(pattern) == metric_value + 1 + Process.sleep(100) + assert metric_value("realtime_channel_global_db_events") == metric_value + 1 end test "presence_event exists after counter added", %{tenant: %{external_id: external_id}} do - pattern = - ~r/realtime_channel_presence_events{tenant="#{external_id}"}\s(?\d+)/ + metric_value = metric_value("realtime_channel_presence_events", tenant: external_id) || 0 - metric_value = metric_value(pattern) FakeUserCounter.fake_presence_event(external_id) - Process.sleep(200) - assert metric_value(pattern) == metric_value + 1 + Process.sleep(100) + assert metric_value("realtime_channel_presence_events", tenant: external_id) == metric_value + 1 end - test "metric read_authorization_check exists after check", context do - pattern = - ~r/realtime_tenants_read_authorization_check_count{tenant="#{context.tenant.external_id}"}\s(?\d+)/ + test "global presence_event exists after counter added", %{tenant: %{external_id: external_id}} do + metric_value = metric_value("realtime_channel_global_presence_events") || 0 + FakeUserCounter.fake_presence_event(external_id) + Process.sleep(100) + assert metric_value("realtime_channel_global_presence_events") == metric_value + 1 + end - metric_value = metric_value(pattern) + test "metric read_authorization_check exists after check", context do + metric = "realtime_tenants_read_authorization_check_count" + metric_value = metric_value(metric, tenant: context.tenant.external_id) || 0 {:ok, _} = Authorization.get_read_authorizations( @@ -164,19 +197,17 @@ defmodule Realtime.PromEx.Plugins.TenantTest do Process.sleep(200) - assert metric_value(pattern) == metric_value + 1 + assert metric_value(metric, tenant: context.tenant.external_id) == metric_value + 1 - bucket_pattern = - ~r/realtime_tenants_read_authorization_check_bucket{tenant="#{context.tenant.external_id}",le="250"}\s(?\d+)/ - - assert metric_value(bucket_pattern) > 0 + assert metric_value("realtime_tenants_read_authorization_check_bucket", + tenant: context.tenant.external_id, + le: "250.0" + ) > 0 end test "metric write_authorization_check exists after check", context do - pattern = - ~r/realtime_tenants_write_authorization_check_count{tenant="#{context.tenant.external_id}"}\s(?\d+)/ - - metric_value = metric_value(pattern) + metric = "realtime_tenants_write_authorization_check_count" + metric_value = metric_value(metric, tenant: context.tenant.external_id) || 0 {:ok, _} = Authorization.get_write_authorizations( @@ -188,96 +219,215 @@ defmodule Realtime.PromEx.Plugins.TenantTest do # Wait enough time for the poll rate to be triggered at least once Process.sleep(200) - assert metric_value(pattern) == metric_value + 1 + assert metric_value(metric, tenant: context.tenant.external_id) == metric_value + 1 + + assert metric_value("realtime_tenants_write_authorization_check_bucket", + tenant: context.tenant.external_id, + le: "250.0" + ) > 0 + end + + test "metric replay exists after check", context do + external_id = context.tenant.external_id + metric = "realtime_tenants_replay_count" + metric_value = metric_value(metric, tenant: external_id) || 0 - bucket_pattern = - ~r/realtime_tenants_write_authorization_check_bucket{tenant="#{context.tenant.external_id}",le="250"}\s(?\d+)/ + assert {:ok, _, _} = Realtime.Messages.replay(context.db_conn, external_id, "test", 0, 1) + + # Wait enough time for the poll rate to be triggered at least once + Process.sleep(200) - assert metric_value(bucket_pattern) > 0 + assert metric_value(metric, tenant: external_id) == metric_value + 1 + + assert metric_value("realtime_tenants_replay_bucket", tenant: external_id, le: "250.0") > 0 end test "metric realtime_tenants_broadcast_from_database_latency_committed_at exists after check", context do - pattern = - ~r/realtime_tenants_broadcast_from_database_latency_committed_at_count{tenant="#{context.tenant.external_id}"}\s(?\d+)/ + external_id = context.tenant.external_id + metric = "realtime_tenants_broadcast_from_database_latency_committed_at_count" + metric_value = metric_value(metric, tenant: external_id) || 0 - metric_value = metric_value(pattern) FakeUserCounter.fake_broadcast_from_database(context.tenant.external_id) Process.sleep(200) - assert metric_value(pattern) == metric_value + 1 - - bucket_pattern = - ~r/realtime_tenants_broadcast_from_database_latency_committed_at_bucket{tenant="#{context.tenant.external_id}",le="10"}\s(?\d+)/ + assert metric_value(metric, tenant: external_id) == metric_value + 1 - assert metric_value(bucket_pattern) > 0 + assert metric_value("realtime_tenants_broadcast_from_database_latency_committed_at_bucket", + tenant: external_id, + le: "10.0" + ) > 0 end test "metric realtime_tenants_broadcast_from_database_latency_inserted_at exists after check", context do - pattern = - ~r/realtime_tenants_broadcast_from_database_latency_inserted_at_count{tenant="#{context.tenant.external_id}"}\s(?\d+)/ - - metric_value = metric_value(pattern) + external_id = context.tenant.external_id + metric = "realtime_tenants_broadcast_from_database_latency_inserted_at_count" + metric_value = metric_value(metric, tenant: external_id) || 0 FakeUserCounter.fake_broadcast_from_database(context.tenant.external_id) Process.sleep(200) - assert metric_value(pattern) == metric_value + 1 - - bucket_pattern = - ~r/realtime_tenants_broadcast_from_database_latency_inserted_at_bucket{tenant="#{context.tenant.external_id}",le="5"}\s(?\d+)/ + assert metric_value(metric, tenant: external_id) == metric_value + 1 - assert metric_value(bucket_pattern) > 0 + assert metric_value("realtime_tenants_broadcast_from_database_latency_inserted_at_bucket", + tenant: external_id, + le: "10.0" + ) > 0 end test "tenant metric payload size", context do external_id = context.tenant.external_id + metric = "realtime_tenants_payload_size_count" + metric_value = metric_value(metric, message_type: "presence", tenant: external_id) || 0 - pattern = - ~r/realtime_tenants_payload_size_count{tenant="#{external_id}"}\s(?\d+)/ + message = %{topic: "a topic", event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]} + RealtimeWeb.TenantBroadcaster.pubsub_broadcast(external_id, "a topic", message, Phoenix.PubSub, :presence) - metric_value = metric_value(pattern) + Process.sleep(200) + assert metric_value(metric, message_type: "presence", tenant: external_id) == metric_value + 1 + + assert metric_value("realtime_tenants_payload_size_bucket", tenant: external_id, le: "250") > 0 + end + + test "global metric payload size", context do + external_id = context.tenant.external_id + + metric = "realtime_payload_size_count" + metric_value = metric_value(metric, message_type: "broadcast") || 0 message = %{topic: "a topic", event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]} - RealtimeWeb.TenantBroadcaster.pubsub_broadcast(external_id, "a topic", message, Phoenix.PubSub) + RealtimeWeb.TenantBroadcaster.pubsub_broadcast(external_id, "a topic", message, Phoenix.PubSub, :broadcast) Process.sleep(200) - assert metric_value(pattern) == metric_value + 1 + assert metric_value(metric, message_type: "broadcast") == metric_value + 1 - bucket_pattern = - ~r/realtime_tenants_payload_size_bucket{tenant="#{external_id}",le="100"}\s(?\d+)/ - - assert metric_value(bucket_pattern) > 0 + assert metric_value("realtime_payload_size_bucket", le: "250.0") > 0 end - test "global metric payload size", context do + test "channel input bytes", context do external_id = context.tenant.external_id - pattern = ~r/realtime_payload_size_count\s(?\d+)/ + FakeUserCounter.fake_input_bytes(external_id) + FakeUserCounter.fake_input_bytes(external_id) + + Process.sleep(200) + assert metric_value("realtime_channel_input_bytes", tenant: external_id) == 20 + end - metric_value = metric_value(pattern) + test "channel output bytes", context do + external_id = context.tenant.external_id - message = %{topic: "a topic", event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]} - RealtimeWeb.TenantBroadcaster.pubsub_broadcast(external_id, "a topic", message, Phoenix.PubSub) + FakeUserCounter.fake_output_bytes(external_id) + FakeUserCounter.fake_output_bytes(external_id) Process.sleep(200) - assert metric_value(pattern) == metric_value + 1 + assert metric_value("realtime_channel_output_bytes", tenant: external_id) == 20 + end + end + + describe "subscription pooler metrics" do + setup do + tenant = Containers.checkout_tenant() + on_exit(fn -> Peep.prune_tags(MetricsTest.__metrics_collector_name__(), [%{tenant: tenant.external_id}]) end) + %{tenant: tenant} + end + + test "subscribers gauge reports the latest value", %{tenant: %{external_id: external_id}} do + Realtime.Telemetry.execute([:realtime, :subscriptions, :manager, :subscribers], %{count: 7}, %{ + tenant: external_id + }) + + assert metric_value("realtime_subscriptions_manager_subscribers", tenant: external_id) == 7 + end + + test "poller stop counter increments tagged by reason", %{tenant: %{external_id: external_id}} do + Realtime.Telemetry.execute([:realtime, :replication, :poller, :stop], %{duration: 1}, %{ + tenant: external_id, + reason: {:shutdown, :max_retries_reached} + }) + + assert metric_value( + "realtime_replication_poller_stop_total", + tenant: external_id, + reason: "max_retries_reached" + ) == 1 + end + + test "poller exception counter increments on crash", %{tenant: %{external_id: external_id}} do + Realtime.Telemetry.execute([:realtime, :replication, :poller, :exception], %{duration: 1}, %{tenant: external_id}) + + assert metric_value("realtime_replication_poller_exception_total", tenant: external_id) == 1 + end + + test "query exception counter increments tagged by reason", %{tenant: %{external_id: external_id}} do + Realtime.Telemetry.execute([:realtime, :replication, :poller, :query, :exception], %{}, %{ + tenant: external_id, + reason: :object_in_use + }) + + assert metric_value("realtime_replication_poller_query_exception_total", + tenant: external_id, + reason: "object_in_use" + ) == 1 + end + + test "prepare exception counter increments", %{tenant: %{external_id: external_id}} do + Realtime.Telemetry.execute([:realtime, :replication, :poller, :prepare, :exception], %{}, %{ + tenant: external_id, + reason: :some_error + }) + + assert metric_value("realtime_replication_poller_prepare_exception_total", tenant: external_id) == 1 + end + + test "changes dispatch sum increments by dispatched count", %{tenant: %{external_id: external_id}} do + Realtime.Telemetry.execute([:realtime, :replication, :poller, :changes, :dispatch], %{count: 5}, %{ + tenant: external_id + }) + + assert metric_value("realtime_replication_poller_changes_dispatch", tenant: external_id) == 5 + end + + test "changes skip sum increments by skipped count tagged by reason", %{tenant: %{external_id: external_id}} do + Realtime.Telemetry.execute([:realtime, :replication, :poller, :changes, :skip], %{count: 3}, %{ + tenant: external_id, + reason: :rate_limited + }) + + assert metric_value("realtime_replication_poller_changes_skip", tenant: external_id, reason: "rate_limited") == 3 + end + + test "dead pid sum increments tagged by phantom reason", %{tenant: %{external_id: external_id}} do + Realtime.Telemetry.execute([:realtime, :subscriptions, :manager, :dead_pid], %{quantity: 1}, %{ + tenant: external_id, + reason: :phantom + }) + + assert metric_value("realtime_subscriptions_manager_dead_pid", tenant: external_id, reason: "phantom") == 1 + end + + test "dead pid sum increments tagged by not_found reason", %{tenant: %{external_id: external_id}} do + Realtime.Telemetry.execute([:realtime, :subscriptions, :manager, :dead_pid], %{quantity: 1}, %{ + tenant: external_id, + reason: :not_found + }) + + assert metric_value("realtime_subscriptions_manager_dead_pid", tenant: external_id, reason: "not_found") == 1 + end + end + + describe "execute_global_connection_metrics/0" do + test "emits global connection counts without a tenant tag" do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + :ok = Census.join(:users, "global-test-tenant", pid) + + TenantGlobal.execute_global_connection_metrics() - bucket_pattern = ~r/realtime_payload_size_bucket{le="100"}\s(?\d+)/ + Process.sleep(100) - assert metric_value(bucket_pattern) > 0 + assert metric_value("realtime_connections_global_connected") >= 0 + assert metric_value("realtime_connections_global_connected_cluster") >= 0 end end - defp metric_value(pattern) do - PromEx.get_metrics(MetricsTest) - |> String.split("\n", trim: true) - |> Enum.find_value( - "0", - fn item -> - case Regex.run(pattern, item, capture: ["number"]) do - [number] -> number - _ -> false - end - end - ) - |> String.to_integer() + defp metric_value(metric, expected_tags \\ nil) do + MetricsHelper.search(PromEx.get_metrics(MetricsTest), metric, expected_tags) end end diff --git a/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs b/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs index 080fd3cfb..f747daac2 100644 --- a/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs +++ b/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs @@ -10,7 +10,7 @@ defmodule Realtime.PromEx.Plugins.TenantsTest do use PromEx, otp_app: :realtime_test_tenants @impl true def plugins do - [{Tenants, poll_rate: 100}] + [{Tenants, poll_rate: 50}] end end @@ -20,118 +20,107 @@ defmodule Realtime.PromEx.Plugins.TenantsTest do def exception, do: raise(RuntimeError) end - setup do - local_tenant = Containers.checkout_tenant(run_migrations: true) + setup_all do start_supervised!(MetricsTest) - {:ok, %{tenant: local_tenant}} + :ok end describe "event_metrics erpc" do - test "success" do - pattern = ~r/realtime_rpc_count{mechanism=\"erpc\",success="true",tenant="123"}\s(?\d+)/ + setup do + %{tenant: random_string()} + end + + test "global success", %{tenant: tenant} do + metric = "realtime_global_rpc_count" # Enough time for the poll rate to be triggered at least once Process.sleep(200) - previous_value = metric_value(pattern) - assert {:ok, "success"} = Rpc.enhanced_call(node(), Test, :success, [], tenant_id: "123") + previous_value = metric_value(metric, mechanism: "erpc", success: true) || 0 + assert {:ok, "success"} = Rpc.enhanced_call(node(), Test, :success, [], tenant_id: tenant) Process.sleep(200) - assert metric_value(pattern) == previous_value + 1 + assert metric_value(metric, mechanism: "erpc", success: true) == previous_value + 1 end - test "failure" do - pattern = ~r/realtime_rpc_count{mechanism=\"erpc\",success="false",tenant="123"}\s(?\d+)/ + test "global failure", %{tenant: tenant} do + metric = "realtime_global_rpc_count" # Enough time for the poll rate to be triggered at least once Process.sleep(200) - previous_value = metric_value(pattern) - assert {:error, "failure"} = Rpc.enhanced_call(node(), Test, :failure, [], tenant_id: "123") + previous_value = metric_value(metric, mechanism: "erpc", success: false) || 0 + assert {:error, "failure"} = Rpc.enhanced_call(node(), Test, :failure, [], tenant_id: tenant) Process.sleep(200) - assert metric_value(pattern) == previous_value + 1 + assert metric_value(metric, mechanism: "erpc", success: false) == previous_value + 1 end - test "exception" do - pattern = ~r/realtime_rpc_count{mechanism=\"erpc\",success="false",tenant="123"}\s(?\d+)/ + test "global exception", %{tenant: tenant} do + metric = "realtime_global_rpc_count" # Enough time for the poll rate to be triggered at least once Process.sleep(200) - previous_value = metric_value(pattern) + previous_value = metric_value(metric, mechanism: "erpc", success: false) || 0 assert {:error, :rpc_error, %RuntimeError{message: "runtime error"}} = - Rpc.enhanced_call(node(), Test, :exception, [], tenant_id: "123") + Rpc.enhanced_call(node(), Test, :exception, [], tenant_id: tenant) Process.sleep(200) - assert metric_value(pattern) == previous_value + 1 + assert metric_value(metric, mechanism: "erpc", success: false) == previous_value + 1 end end - test "event_metrics rpc" do - pattern = ~r/realtime_rpc_count{mechanism=\"rpc\",success="",tenant="123"}\s(?\d+)/ - # Enough time for the poll rate to be triggered at least once - Process.sleep(200) - previous_value = metric_value(pattern) - assert {:ok, "success"} = Rpc.call(node(), Test, :success, [], tenant_id: "123") - Process.sleep(200) - assert metric_value(pattern) == previous_value + 1 - end - describe "event_metrics gen_rpc" do - test "success" do - pattern = ~r/realtime_rpc_count{mechanism=\"gen_rpc\",success="true",tenant="123"}\s(?\d+)/ + setup do + %{tenant: random_string()} + end + + test "global success", %{tenant: tenant} do + metric = "realtime_global_rpc_count" # Enough time for the poll rate to be triggered at least once Process.sleep(200) - previous_value = metric_value(pattern) - assert GenRpc.multicall(Test, :success, [], tenant_id: "123") == [{node(), {:ok, "success"}}] + previous_value = metric_value(metric, mechanism: "gen_rpc", success: true) || 0 + assert GenRpc.multicall(Test, :success, [], tenant_id: tenant) == [{node(), {:ok, "success"}}] Process.sleep(200) - assert metric_value(pattern) == previous_value + 1 + assert metric_value(metric, mechanism: "gen_rpc", success: true) == previous_value + 1 end - test "failure" do - pattern = ~r/realtime_rpc_count{mechanism=\"gen_rpc\",success="false",tenant="123"}\s(?\d+)/ + test "global failure", %{tenant: tenant} do + metric = "realtime_global_rpc_count" # Enough time for the poll rate to be triggered at least once Process.sleep(200) - previous_value = metric_value(pattern) - assert GenRpc.multicall(Test, :failure, [], tenant_id: "123") == [{node(), {:error, "failure"}}] + previous_value = metric_value(metric, mechanism: "gen_rpc", success: false) || 0 + assert GenRpc.multicall(Test, :failure, [], tenant_id: tenant) == [{node(), {:error, "failure"}}] Process.sleep(200) - assert metric_value(pattern) == previous_value + 1 + assert metric_value(metric, mechanism: "gen_rpc", success: false) == previous_value + 1 end - test "exception" do - pattern = ~r/realtime_rpc_count{mechanism=\"gen_rpc\",success="false",tenant="123"}\s(?\d+)/ + test "global exception", %{tenant: tenant} do + metric = "realtime_global_rpc_count" # Enough time for the poll rate to be triggered at least once Process.sleep(200) - previous_value = metric_value(pattern) - + previous_value = metric_value(metric, mechanism: "gen_rpc", success: false) || 0 node = node() assert assert [{^node, {:error, :rpc_error, {:EXIT, {%RuntimeError{message: "runtime error"}, _stacktrace}}}}] = - GenRpc.multicall(Test, :exception, [], tenant_id: "123") + GenRpc.multicall(Test, :exception, [], tenant_id: tenant) Process.sleep(200) - assert metric_value(pattern) == previous_value + 1 + assert metric_value(metric, mechanism: "gen_rpc", success: false) == previous_value + 1 end end describe "pooling metrics" do + setup do + local_tenant = Containers.checkout_tenant(run_migrations: true) + {:ok, %{tenant: local_tenant}} + end + test "conneted based on Connect module information for local node only", %{tenant: tenant} do - pattern = ~r/realtime_tenants_connected\s(?\d+)/ # Enough time for the poll rate to be triggered at least once Process.sleep(200) - previous_value = metric_value(pattern) + previous_value = metric_value("realtime_tenants_connected") {:ok, _} = Connect.lookup_or_start_connection(tenant.external_id) Process.sleep(200) - assert metric_value(pattern) == previous_value + 1 + assert metric_value("realtime_tenants_connected") == previous_value + 1 end end - defp metric_value(pattern) do - PromEx.get_metrics(MetricsTest) - |> String.split("\n", trim: true) - |> Enum.find_value( - "0", - fn item -> - case Regex.run(pattern, item, capture: ["number"]) do - [number] -> number - _ -> false - end - end - ) - |> String.to_integer() + defp metric_value(metric, expected_tags \\ nil) do + MetricsHelper.search(PromEx.get_metrics(MetricsTest), metric, expected_tags) end end diff --git a/test/realtime/monitoring/prom_ex_test.exs b/test/realtime/monitoring/prom_ex_test.exs index 849536543..a466e5efd 100644 --- a/test/realtime/monitoring/prom_ex_test.exs +++ b/test/realtime/monitoring/prom_ex_test.exs @@ -5,7 +5,7 @@ defmodule Realtime.PromExTest do describe "get_metrics/0" do test "builds metrics in prometheus format which includes host region and id" do - metrics = PromEx.get_metrics() + metrics = PromEx.get_metrics() |> IO.iodata_to_binary() assert String.contains?( metrics, @@ -16,27 +16,7 @@ defmodule Realtime.PromExTest do assert String.contains?( metrics, - "beam_system_schedulers_online_info{host=\"nohost\",region=\"us-east-1\",id=\"nohost\"}" - ) - end - end - - describe "get_compressed_metrics/0" do - test "builds metrics compressed using zlib" do - compressed_metrics = PromEx.get_compressed_metrics() - - metrics = :zlib.uncompress(compressed_metrics) - - assert String.contains?( - metrics, - "# HELP beam_system_schedulers_online_info The number of scheduler threads that are online." - ) - - assert String.contains?(metrics, "# TYPE beam_system_schedulers_online_info gauge") - - assert String.contains?( - metrics, - "beam_system_schedulers_online_info{host=\"nohost\",region=\"us-east-1\",id=\"nohost\"}" + "beam_system_schedulers_online_info{host=\"nohost\",id=\"nohost\",region=\"us-east-1\"}" ) end end diff --git a/test/realtime/monitoring/prometheus_test.exs b/test/realtime/monitoring/prometheus_test.exs new file mode 100644 index 000000000..980fa7d34 --- /dev/null +++ b/test/realtime/monitoring/prometheus_test.exs @@ -0,0 +1,434 @@ +# Based on https://github.com/rkallos/peep/blob/708546ed069aebdf78ac1f581130332bd2e8b5b1/test/prometheus_test.exs +defmodule Realtime.Monitoring.PrometheusTest do + use ExUnit.Case, async: true + + alias Realtime.Monitoring.Prometheus + alias Telemetry.Metrics + + defmodule StorageCounter do + @moduledoc false + use Agent + + def start() do + Agent.start(fn -> 0 end, name: __MODULE__) + end + + def fresh_id() do + Agent.get_and_update(__MODULE__, fn i -> {:"#{i}", i + 1} end) + end + end + + # Test struct that doesn't implement String.Chars + defmodule TestError do + defstruct [:reason, :code] + end + + setup_all do + StorageCounter.start() + :ok + end + + @impls [:default, {Realtime.Monitoring.Peep.Partitioned, 4}, :striped] + + for impl <- @impls do + test "#{inspect(impl)} - counter formatting" do + counter = Metrics.counter("prometheus.test.counter", description: "a counter") + name = StorageCounter.fresh_id() + + opts = [ + name: name, + metrics: [counter], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + Peep.insert_metric(name, counter, 1, %{foo: :bar, baz: "quux"}) + + expected = [ + "# HELP prometheus_test_counter a counter", + "# TYPE prometheus_test_counter counter", + ~s(prometheus_test_counter{baz="quux",foo="bar"} 1) + ] + + assert export(name) == lines_to_string(expected) + end + + describe "#{inspect(impl)} - sum" do + test "sum formatting" do + name = StorageCounter.fresh_id() + sum = Metrics.sum("prometheus.test.sum", description: "a sum") + + opts = [ + name: name, + metrics: [sum], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + Peep.insert_metric(name, sum, 5, %{foo: :bar, baz: "quux"}) + Peep.insert_metric(name, sum, 3, %{foo: :bar, baz: "quux"}) + + expected = [ + "# HELP prometheus_test_sum a sum", + "# TYPE prometheus_test_sum counter", + ~s(prometheus_test_sum{baz="quux",foo="bar"} 8) + ] + + assert export(name) == lines_to_string(expected) + end + + test "custom type" do + name = StorageCounter.fresh_id() + + sum = + Metrics.sum("prometheus.test.sum", + description: "a sum", + reporter_options: [prometheus_type: "gauge"] + ) + + opts = [ + name: name, + metrics: [sum], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + Peep.insert_metric(name, sum, 5, %{foo: :bar, baz: "quux"}) + Peep.insert_metric(name, sum, 3, %{foo: :bar, baz: "quux"}) + + expected = [ + "# HELP prometheus_test_sum a sum", + "# TYPE prometheus_test_sum gauge", + ~s(prometheus_test_sum{baz="quux",foo="bar"} 8) + ] + + assert export(name) == lines_to_string(expected) + end + end + + describe "#{inspect(impl)} - last_value" do + test "formatting" do + name = StorageCounter.fresh_id() + last_value = Metrics.last_value("prometheus.test.gauge", description: "a last_value") + + opts = [ + name: name, + metrics: [last_value], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + Peep.insert_metric(name, last_value, 5, %{blee: :bloo, flee: "floo"}) + + expected = [ + "# HELP prometheus_test_gauge a last_value", + "# TYPE prometheus_test_gauge gauge", + ~s(prometheus_test_gauge{blee="bloo",flee="floo"} 5) + ] + + assert export(name) == lines_to_string(expected) + end + + test "custom type" do + name = StorageCounter.fresh_id() + + last_value = + Metrics.last_value("prometheus.test.gauge", + description: "a last_value", + reporter_options: [prometheus_type: :sum] + ) + + opts = [ + name: name, + metrics: [last_value], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + Peep.insert_metric(name, last_value, 5, %{blee: :bloo, flee: "floo"}) + + expected = [ + "# HELP prometheus_test_gauge a last_value", + "# TYPE prometheus_test_gauge sum", + ~s(prometheus_test_gauge{blee="bloo",flee="floo"} 5) + ] + + assert export(name) == lines_to_string(expected) + end + end + + test "#{inspect(impl)} - dist formatting" do + name = StorageCounter.fresh_id() + + dist = + Metrics.distribution("prometheus.test.distribution", + description: "a distribution", + reporter_options: [max_value: 1000] + ) + + opts = [ + name: name, + metrics: [dist], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + expected = [] + assert export(name) == lines_to_string(expected) + + Peep.insert_metric(name, dist, 1, %{glee: :gloo}) + + expected = [ + "# HELP prometheus_test_distribution a distribution", + "# TYPE prometheus_test_distribution histogram", + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.222222"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.493827"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.825789"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="2.23152"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="2.727413"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="3.333505"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="4.074283"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="4.97968"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="6.086275"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="7.438781"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="9.091843"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="11.112253"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="13.581642"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="16.599785"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="20.288626"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="24.79721"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="30.307701"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="37.042745"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="45.274466"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="55.335459"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="67.632227"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="82.661611"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="101.030858"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="123.48216"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="150.92264"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="184.461004"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="225.452339"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="275.552858"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="336.786827"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="411.628344"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="503.101309"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="614.9016"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="751.5464"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="918.556711"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1122.680424"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="+Inf"} 1), + ~s(prometheus_test_distribution_sum{glee="gloo"} 1), + ~s(prometheus_test_distribution_count{glee="gloo"} 1) + ] + + assert export(name) == lines_to_string(expected) + + for i <- 2..2000 do + Peep.insert_metric(name, dist, i, %{glee: :gloo}) + end + + expected = [ + "# HELP prometheus_test_distribution a distribution", + "# TYPE prometheus_test_distribution histogram", + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.222222"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.493827"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.825789"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="2.23152"} 2), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="2.727413"} 2), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="3.333505"} 3), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="4.074283"} 4), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="4.97968"} 4), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="6.086275"} 6), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="7.438781"} 7), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="9.091843"} 9), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="11.112253"} 11), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="13.581642"} 13), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="16.599785"} 16), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="20.288626"} 20), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="24.79721"} 24), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="30.307701"} 30), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="37.042745"} 37), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="45.274466"} 45), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="55.335459"} 55), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="67.632227"} 67), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="82.661611"} 82), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="101.030858"} 101), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="123.48216"} 123), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="150.92264"} 150), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="184.461004"} 184), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="225.452339"} 225), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="275.552858"} 275), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="336.786827"} 336), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="411.628344"} 411), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="503.101309"} 503), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="614.9016"} 614), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="751.5464"} 751), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="918.556711"} 918), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1122.680424"} 1122), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="+Inf"} 2000), + ~s(prometheus_test_distribution_sum{glee="gloo"} 2001000), + ~s(prometheus_test_distribution_count{glee="gloo"} 2000) + ] + + assert export(name) == lines_to_string(expected) + end + + test "#{inspect(impl)} - dist formatting pow10" do + name = StorageCounter.fresh_id() + + dist = + Metrics.distribution("prometheus.test.distribution", + description: "a distribution", + reporter_options: [ + max_value: 1000, + peep_bucket_calculator: Peep.Buckets.PowersOfTen + ] + ) + + opts = [ + name: name, + metrics: [dist], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + expected = [] + assert export(name) == lines_to_string(expected) + + Peep.insert_metric(name, dist, 1, %{glee: :gloo}) + + expected = [ + "# HELP prometheus_test_distribution a distribution", + "# TYPE prometheus_test_distribution histogram", + ~s(prometheus_test_distribution_bucket{glee="gloo",le="10.0"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="100.0"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e3"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e4"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e5"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e6"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e7"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e8"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e9"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="+Inf"} 1), + ~s(prometheus_test_distribution_sum{glee="gloo"} 1), + ~s(prometheus_test_distribution_count{glee="gloo"} 1) + ] + + assert export(name) == lines_to_string(expected) + + f = fn -> + for i <- 1..2000 do + Peep.insert_metric(name, dist, i, %{glee: :gloo}) + end + end + + 1..20 |> Enum.map(fn _ -> Task.async(f) end) |> Task.await_many() + + expected = + [ + "# HELP prometheus_test_distribution a distribution", + "# TYPE prometheus_test_distribution histogram", + ~s(prometheus_test_distribution_bucket{glee="gloo",le="10.0"} 181), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="100.0"} 1981), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e3"} 19981), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e4"} 40001), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e5"} 40001), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e6"} 40001), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e7"} 40001), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e8"} 40001), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e9"} 40001), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="+Inf"} 40001), + ~s(prometheus_test_distribution_sum{glee="gloo"} 40020001), + ~s(prometheus_test_distribution_count{glee="gloo"} 40001) + ] + + assert export(name) == lines_to_string(expected) + end + + test "#{inspect(impl)} - regression: label escaping" do + name = StorageCounter.fresh_id() + + counter = + Metrics.counter( + "prometheus.test.counter", + description: "a counter" + ) + + opts = [ + name: name, + metrics: [counter], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + Peep.insert_metric(name, counter, 1, %{atom: "\"string\""}) + Peep.insert_metric(name, counter, 1, %{"\"string\"" => :atom}) + Peep.insert_metric(name, counter, 1, %{"\"string\"" => "\"string\""}) + Peep.insert_metric(name, counter, 1, %{"string" => "string\n"}) + + expected = [ + "# HELP prometheus_test_counter a counter", + "# TYPE prometheus_test_counter counter", + ~s(prometheus_test_counter{atom="\\\"string\\\""} 1), + ~s(prometheus_test_counter{\"string\"="atom"} 1), + ~s(prometheus_test_counter{\"string\"="\\\"string\\\""} 1), + ~s(prometheus_test_counter{string="string\\n"} 1) + ] + + assert export(name) == lines_to_string(expected) + end + + test "#{inspect(impl)} - regression: handle structs without String.Chars" do + name = StorageCounter.fresh_id() + + counter = + Metrics.counter( + "prometheus.test.counter", + description: "a counter" + ) + + opts = [ + name: name, + metrics: [counter], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + # Create a struct that doesn't implement String.Chars + error_struct = %TestError{reason: :tcp_closed, code: 1001} + + Peep.insert_metric(name, counter, 1, %{error: error_struct}) + + result = export(name) + + # Should not crash and should contain the inspected struct representation + assert result =~ "prometheus_test_counter" + assert result =~ "TestError" + assert result =~ "tcp_closed" + end + end + + defp export(name) do + Peep.get_all_metrics(name) + |> Prometheus.export() + |> IO.iodata_to_binary() + end + + defp lines_to_string(lines) do + lines + |> Enum.map(&[&1, ?\n]) + |> Enum.concat(["# EOF\n"]) + |> IO.iodata_to_binary() + end +end diff --git a/test/realtime/nodes_test.exs b/test/realtime/nodes_test.exs index ba3b6be0e..fc3203504 100644 --- a/test/realtime/nodes_test.exs +++ b/test/realtime/nodes_test.exs @@ -1,9 +1,82 @@ defmodule Realtime.NodesTest do - use Realtime.DataCase, async: true + # async: false due to use of Clustered and tweaking Application env + use Realtime.DataCase, async: false use Mimic alias Realtime.Nodes alias Realtime.Tenants + defp spawn_fake_node(region, node) do + parent = self() + + fun = fn -> + :syn.join(RegionNodes, region, self(), node: node) + send(parent, :joined) + + receive do + :ok -> :ok + end + end + + {:ok, _pid} = start_supervised({Task, fun}, id: {region, node}) + assert_receive :joined + end + + describe "all_node_regions/0" do + test "returns all regions with nodes" do + spawn_fake_node("us-east-1", :node_1) + spawn_fake_node("ap-2", :node_2) + spawn_fake_node("ap-2", :node_3) + + assert Nodes.all_node_regions() |> Enum.sort() == ["ap-2", "us-east-1"] + end + + test "with no other nodes, returns my region only" do + assert Nodes.all_node_regions() == ["us-east-1"] + end + end + + describe "region_nodes/1" do + test "nil region returns empty list" do + assert Nodes.region_nodes(nil) == [] + end + + test "returns nodes from region" do + region = "ap-southeast-2" + spawn_fake_node(region, :node_1) + spawn_fake_node(region, :node_2) + + spawn_fake_node("eu-west-2", :node_3) + + assert Nodes.region_nodes(region) == [:node_1, :node_2] + assert Nodes.region_nodes("eu-west-2") == [:node_3] + end + + test "on non-existing region, returns empty list" do + assert Nodes.region_nodes("non-existing-region") == [] + end + end + + describe "node_from_region/2" do + test "nil region returns error" do + assert {:error, :not_available} = Nodes.node_from_region(nil, :any_key) + end + + test "empty region returns error" do + assert {:error, :not_available} = Nodes.node_from_region("empty-region", :any_key) + end + + test "returns the same node given the same key" do + region = "ap-southeast-3" + spawn_fake_node(region, :node_1) + spawn_fake_node(region, :node_2) + + spawn_fake_node("eu-west-3", :node_3) + + assert {:ok, :node_2} = Nodes.node_from_region(region, :key1) + assert {:ok, :node_2} = Nodes.node_from_region(region, :key1) + end + end + describe "get_node_for_tenant/1" do setup do tenant = Containers.checkout_tenant() @@ -16,7 +89,7 @@ defmodule Realtime.NodesTest do reject(&:syn.members/2) end - test "on existing tenant id, returns the node for the region using syn", %{ + test "on existing tenant id, returns a node from the region using load-aware picking", %{ tenant: tenant, region: region } do @@ -29,26 +102,24 @@ defmodule Realtime.NodesTest do ] end) - index = :erlang.phash2(tenant.external_id, length(expected_nodes)) - - expected_node = Enum.fetch!(expected_nodes, index) expected_region = Tenants.region(tenant) assert {:ok, node, region} = Nodes.get_node_for_tenant(tenant) - assert node == expected_node assert region == expected_region + assert node in expected_nodes end - test "on existing tenant id, and a single node for a given region, returns default", %{ + test "on existing tenant id, and a single node for a given region, returns single node", %{ tenant: tenant, region: region } do expect(:syn, :members, fn RegionNodes, ^region -> [{self(), [node: :tenant@closest1]}] end) + assert {:ok, node, region} = Nodes.get_node_for_tenant(tenant) expected_region = Tenants.region(tenant) - assert node == node() + assert node == :tenant@closest1 assert region == expected_region end @@ -62,4 +133,248 @@ defmodule Realtime.NodesTest do assert region == expected_region end end + + describe "platform_region_translator/1" do + test "returns nil for nil input" do + assert Nodes.platform_region_translator(nil) == nil + end + + test "uses default mapping when no custom mapping configured" do + original = Application.get_env(:realtime, :region_mapping) + on_exit(fn -> Application.put_env(:realtime, :region_mapping, original) end) + + Application.put_env(:realtime, :region_mapping, nil) + + assert Nodes.platform_region_translator("eu-north-1") == "eu-west-2" + assert Nodes.platform_region_translator("us-west-2") == "us-west-1" + assert Nodes.platform_region_translator("unknown-region") == nil + end + + test "uses custom mapping when configured without falling back to defaults" do + original = Application.get_env(:realtime, :region_mapping) + on_exit(fn -> Application.put_env(:realtime, :region_mapping, original) end) + + custom_mapping = %{ + "custom-region-1" => "us-east-1", + "eu-north-1" => "custom-target" + } + + Application.put_env(:realtime, :region_mapping, custom_mapping) + + # Custom mappings work + assert Nodes.platform_region_translator("custom-region-1") == "us-east-1" + assert Nodes.platform_region_translator("eu-north-1") == "custom-target" + + # Unmapped regions return nil (no fallback to defaults) + assert Nodes.platform_region_translator("us-west-2") == nil + end + end + + describe "node_load/1" do + test "returns {:error, :not_enough_data} for local node with insufficient uptime" do + assert {:error, :not_enough_data} = Nodes.node_load(node()) + end + end + + describe "node_load/1 with sufficient uptime" do + setup do + Cachex.clear(Realtime.Nodes.Cache) + Application.put_env(:realtime, :node_balance_uptime_threshold_in_ms, 0) + + on_exit(fn -> + Application.put_env(:realtime, :node_balance_uptime_threshold_in_ms, 999_999_999_999) + end) + end + + test "returns cpu load for local node" do + load = Nodes.node_load(node()) + + assert is_integer(load) + assert load >= 0 + end + + test "returns cpu load for remote node" do + {:ok, remote_node} = Clustered.start() + + load = Nodes.node_load(remote_node) + + assert is_integer(load) + assert load >= 0 + end + + test "remote node can also get its own load" do + {:ok, remote_node} = Clustered.start() + + load = :rpc.call(remote_node, Nodes, :node_load, [remote_node]) + + assert is_integer(load) + assert load >= 0 + end + + test "caches remote node load and sets expiration" do + {:ok, remote_node} = Clustered.start(nil, extra_config: [{:realtime, :node_balance_uptime_threshold_in_ms, 0}]) + + assert {:ok, false} = Cachex.exists?(Realtime.Nodes.Cache, remote_node) + + load1 = Nodes.node_load(remote_node) + assert is_integer(load1) + + assert {:ok, true} = Cachex.exists?(Realtime.Nodes.Cache, remote_node) + assert {:ok, ttl} = Cachex.ttl(Realtime.Nodes.Cache, remote_node) + assert is_integer(ttl) and ttl > 0 and ttl <= 60_000 + + reject(&Realtime.GenRpc.call/5) + + load2 = Nodes.node_load(remote_node) + assert load1 == load2 + end + + test "does not cache rpc errors for remote node" do + fake_node = :fake_remote_node + + expect(Realtime.GenRpc, :call, fn _, _, _, _, _ -> {:error, :rpc_error, :badrpc} end) + + assert {:ok, false} = Cachex.exists?(Realtime.Nodes.Cache, fake_node) + + result = Nodes.node_load(fake_node) + assert result == {:error, :rpc_error, :badrpc} + + assert {:ok, false} = Cachex.exists?(Realtime.Nodes.Cache, fake_node) + end + + test "caches {:error, :not_enough_data} for remote node with insufficient uptime" do + {:ok, remote_node} = + Clustered.start(nil, extra_config: [{:realtime, :node_balance_uptime_threshold_in_ms, 999_999_999_999}]) + + assert {:ok, false} = Cachex.exists?(Realtime.Nodes.Cache, remote_node) + + result = Nodes.node_load(remote_node) + assert result == {:error, :not_enough_data} + + assert {:ok, true} = Cachex.exists?(Realtime.Nodes.Cache, remote_node) + + reject(&Realtime.GenRpc.call/5) + + assert {:error, :not_enough_data} = Nodes.node_load(remote_node) + end + end + + describe "launch_node/3 load-aware but not enough uptime" do + test "returns the one node from the region when one node is available" do + region = "clustered-test-region" + spawn_fake_node(region, :remote_node) + + result = Nodes.launch_node(region, node(), "test-tenant-123") + + assert result == :remote_node + end + + test "returns default node when no region nodes available" do + result = Nodes.launch_node("empty-region", node(), "test-tenant-123") + + assert result == node() + end + + test "same tenant_id picks same nodes" do + region = "deterministic-region" + spawn_fake_node(region, :node_a) + spawn_fake_node(region, :node_b) + spawn_fake_node(region, :node_c) + + tenant_id = "test-tenant-456" + + # Call 10 times, should always return same node with hashed tenant ID + results = for _ <- 1..10, do: Nodes.launch_node(region, node(), tenant_id) + + assert length(Enum.uniq(results)) == 1 + end + + test "different tenant_ids distribute across nodes" do + region = "distribution-region" + spawn_fake_node(region, :node_a) + spawn_fake_node(region, :node_b) + spawn_fake_node(region, :node_c) + + # Generate 30 different tenant IDs + tenant_ids = for i <- 1..30, do: "tenant-#{i}" + + results = + Enum.map(tenant_ids, fn id -> + Nodes.launch_node(region, node(), id) + end) + + # Should distribute across multiple nodes (at least 2) using the hashed tenant IDs + assert length(Enum.uniq(results)) >= 2 + end + end + + describe "launch_node/3 with load-aware node picking enabled" do + setup do + Application.put_env(:realtime, :node_balance_uptime_threshold_in_ms, 0) + + on_exit(fn -> + Application.put_env(:realtime, :node_balance_uptime_threshold_in_ms, 999_999_999_999) + end) + end + + test "picks deterministic node when one node has insufficient data" do + region = "uptime-test-region" + spawn_fake_node(region, :node_a) + spawn_fake_node(region, :node_b) + + stub(Nodes, :node_load, fn + :node_a -> {:error, :not_enough_data} + :node_b -> 100 + end) + + results = for _ <- 1..10, do: Nodes.launch_node(region, node(), "test-tenant-123") + + # Deterministic with hashed tenant ID + assert length(Enum.uniq(results)) == 1 + assert Enum.uniq(results) == [:node_b] + end + + test "compares load between nodes and picks the least loaded deterministically" do + {:ok, remote_node} = Clustered.start(nil, [{:realtime, :node_balance_uptime_threshold_in_ms, 0}]) + + region = "load-test-region" + spawn_fake_node(region, node()) + spawn_fake_node(region, remote_node) + + local_load = Nodes.node_load(node()) + remote_load = Nodes.node_load(remote_node) + + assert is_integer(local_load) and local_load >= 0 + assert is_integer(remote_load) and remote_load >= 0 + + results = for _ <- 1..10, do: Nodes.launch_node(region, node(), "test-tenant-789") + + # Should be deterministic - all same node within time bucket + assert length(Enum.uniq(results)) == 1 + assert Enum.all?(results, &(&1 in [node(), remote_node])) + end + end + + describe "short_node_id_from_name/1" do + test "extracts short ID from fly.io-style IPv6 node name" do + assert Nodes.short_node_id_from_name(:"realtime-prod@fdaa:0:cc:a7b:b385:83c3:cfe3:2") == + "83c3cfe3" + end + + test "returns full name for localhost node" do + assert Nodes.short_node_id_from_name(:"pink@127.0.0.1") == "pink@127.0.0.1" + end + + test "returns host for standard domain-style node names" do + assert Nodes.short_node_id_from_name(:"realtime@host.name.internal") == "host.name.internal" + end + + test "returns host for simple IP node" do + assert Nodes.short_node_id_from_name(:"pink@10.0.1.1") == "10.0.1.1" + end + + test "returns host for nonode@nohost" do + assert Nodes.short_node_id_from_name(:nonode@nohost) == "nohost" + end + end end diff --git a/test/realtime/postgres_decoder_test.exs b/test/realtime/postgres_decoder_test.exs index 9516e5e9a..b8bbe2723 100644 --- a/test/realtime/postgres_decoder_test.exs +++ b/test/realtime/postgres_decoder_test.exs @@ -2,24 +2,23 @@ defmodule Realtime.PostgresDecoderTest do use ExUnit.Case, async: true alias Realtime.Adapters.Postgres.Decoder - alias Decoder.Messages.{ - Begin, - Commit, - Origin, - Relation, - Relation.Column, - Insert, - Update, - Delete, - Truncate, - Type - } + alias Decoder.Messages.Begin + alias Decoder.Messages.Commit + alias Decoder.Messages.Insert + alias Decoder.Messages.Origin + alias Decoder.Messages.Relation + alias Decoder.Messages.Relation.Column + alias Decoder.Messages.Type + alias Decoder.Messages.Unsupported test "decodes begin messages" do {:ok, expected_dt_no_microseconds, 0} = DateTime.from_iso8601("2019-07-18T17:02:35Z") expected_dt = DateTime.add(expected_dt_no_microseconds, 726_322, :microsecond) - assert Decoder.decode_message(<<66, 0, 0, 0, 2, 167, 244, 168, 128, 0, 2, 48, 246, 88, 88, 213, 242, 0, 0, 2, 107>>) == + assert Decoder.decode_message( + <<66, 0, 0, 0, 2, 167, 244, 168, 128, 0, 2, 48, 246, 88, 88, 213, 242, 0, 0, 2, 107>>, + %{} + ) == %Begin{commit_timestamp: expected_dt, final_lsn: {2, 2_817_828_992}, xid: 619} end @@ -28,7 +27,8 @@ defmodule Realtime.PostgresDecoderTest do expected_dt = DateTime.add(expected_dt_no_microseconds, 726_322, :microsecond) assert Decoder.decode_message( - <<67, 0, 0, 0, 0, 2, 167, 244, 168, 128, 0, 0, 0, 2, 167, 244, 168, 176, 0, 2, 48, 246, 88, 88, 213, 242>> + <<67, 0, 0, 0, 0, 2, 167, 244, 168, 128, 0, 0, 0, 2, 167, 244, 168, 176, 0, 2, 48, 246, 88, 88, 213, 242>>, + %{} ) == %Commit{ flags: [], lsn: {2, 2_817_828_992}, @@ -38,7 +38,7 @@ defmodule Realtime.PostgresDecoderTest do end test "decodes origin messages" do - assert Decoder.decode_message(<<79, 0, 0, 0, 2, 167, 244, 168, 128>> <> "Elmer Fud") == + assert Decoder.decode_message(<<79, 0, 0, 0, 2, 167, 244, 168, 128>> <> "Elmer Fud", %{}) == %Origin{ origin_commit_lsn: {2, 2_817_828_992}, name: "Elmer Fud" @@ -48,7 +48,8 @@ defmodule Realtime.PostgresDecoderTest do test "decodes relation messages" do assert Decoder.decode_message( <<82, 0, 0, 96, 0, 112, 117, 98, 108, 105, 99, 0, 102, 111, 111, 0, 100, 0, 2, 0, 98, 97, 114, 0, 0, 0, 0, - 25, 255, 255, 255, 255, 1, 105, 100, 0, 0, 0, 0, 23, 255, 255, 255, 255>> + 25, 255, 255, 255, 255, 1, 105, 100, 0, 0, 0, 0, 23, 255, 255, 255, 255>>, + %{} ) == %Relation{ id: 24_576, namespace: "public", @@ -74,7 +75,8 @@ defmodule Realtime.PostgresDecoderTest do test "decodes type messages" do assert Decoder.decode_message( <<89, 0, 0, 128, 52, 112, 117, 98, 108, 105, 99, 0, 101, 120, 97, 109, 112, 108, 101, 95, 116, 121, 112, - 101, 0>> + 101, 0>>, + %{} ) == %Type{ id: 32_820, @@ -83,110 +85,135 @@ defmodule Realtime.PostgresDecoderTest do } end - describe "truncate messages" do - test "decodes messages" do - assert Decoder.decode_message(<<84, 0, 0, 0, 1, 0, 0, 0, 96, 0>>) == - %Truncate{ - number_of_relations: 1, - options: [], - truncated_relations: [24_576] - } - end - - test "decodes messages with cascade option" do - assert Decoder.decode_message(<<84, 0, 0, 0, 1, 1, 0, 0, 96, 0>>) == - %Truncate{ - number_of_relations: 1, - options: [:cascade], - truncated_relations: [24_576] - } + describe "data message (TupleData) decoder" do + setup do + relation = %{ + id: 24_576, + namespace: "public", + name: "foo", + columns: [ + %Column{name: "id", type: "uuid"}, + %Column{name: "bar", type: "text"} + ] + } + + %{relation: relation} end - test "decodes messages with restart identity option" do - assert Decoder.decode_message(<<84, 0, 0, 0, 1, 2, 0, 0, 96, 0>>) == - %Truncate{ - number_of_relations: 1, - options: [:restart_identity], - truncated_relations: [24_576] - } - end - end + test "decodes insert messages", %{relation: relation} do + uuid = UUID.uuid4() + string = Generators.random_string() + + data = + "I" <> + <> <> + "N" <> + <<2::integer-16>> <> + "b" <> + <<16::integer-32>> <> + UUID.string_to_binary!(uuid) <> + "b" <> + <> <> + string - describe "data message (TupleData) decoder" do - test "decodes insert messages" do assert Decoder.decode_message( - <<73, 0, 0, 96, 0, 78, 0, 2, 116, 0, 0, 0, 3, 98, 97, 122, 116, 0, 0, 0, 3, 53, 54, 48>> + data, + %{relation.id => relation} ) == %Insert{ - relation_id: 24_576, - tuple_data: {"baz", "560"} - } - end - - test "decodes insert messages with null values" do - assert Decoder.decode_message(<<73, 0, 0, 96, 0, 78, 0, 2, 110, 116, 0, 0, 0, 3, 53, 54, 48>>) == %Insert{ - relation_id: 24_576, - tuple_data: {nil, "560"} - } - end - - test "decodes insert messages with unchanged toasted values" do - assert Decoder.decode_message(<<73, 0, 0, 96, 0, 78, 0, 2, 117, 116, 0, 0, 0, 3, 53, 54, 48>>) == %Insert{ - relation_id: 24_576, - tuple_data: {:unchanged_toast, "560"} + relation_id: relation.id, + tuple_data: {uuid, string} } end - test "decodes update messages with default replica identity setting" do - assert Decoder.decode_message( - <<85, 0, 0, 96, 0, 78, 0, 2, 116, 0, 0, 0, 7, 101, 120, 97, 109, 112, 108, 101, 116, 0, 0, 0, 3, 53, 54, - 48>> - ) == %Update{ - relation_id: 24_576, - changed_key_tuple_data: nil, - old_tuple_data: nil, - tuple_data: {"example", "560"} - } - end + test "ignores unknown relations", %{relation: relation} do + uuid = UUID.uuid4() + string = Generators.random_string() + + data = + "I" <> + <<679::integer-32>> <> + "N" <> + <<2::integer-16>> <> + "b" <> + <<16::integer-32>> <> + UUID.string_to_binary!(uuid) <> + "b" <> + <> <> + string - test "decodes update messages with FULL replica identity setting" do assert Decoder.decode_message( - <<85, 0, 0, 96, 0, 79, 0, 2, 116, 0, 0, 0, 3, 98, 97, 122, 116, 0, 0, 0, 3, 53, 54, 48, 78, 0, 2, 116, 0, - 0, 0, 7, 101, 120, 97, 109, 112, 108, 101, 116, 0, 0, 0, 3, 53, 54, 48>> - ) == %Update{ - relation_id: 24_576, - changed_key_tuple_data: nil, - old_tuple_data: {"baz", "560"}, - tuple_data: {"example", "560"} - } + data, + %{relation.id => relation} + ) == %Unsupported{} end - test "decodes update messages with USING INDEX replica identity setting" do - assert Decoder.decode_message( - <<85, 0, 0, 96, 0, 75, 0, 2, 116, 0, 0, 0, 3, 98, 97, 122, 110, 78, 0, 2, 116, 0, 0, 0, 7, 101, 120, 97, - 109, 112, 108, 101, 116, 0, 0, 0, 3, 53, 54, 48>> - ) == %Update{ - relation_id: 24_576, - changed_key_tuple_data: {"baz", nil}, - old_tuple_data: nil, - tuple_data: {"example", "560"} + test "decodes insert messages with null values", %{relation: relation} do + string = Generators.random_string() + + data = + "I" <> + <> <> + "N" <> + <<2::integer-16>> <> + "n" <> + "b" <> + <> <> + string + + assert Decoder.decode_message(data, %{relation.id => relation}) == %Insert{ + relation_id: relation.id, + tuple_data: {nil, string} } end - test "decodes DELETE messages with USING INDEX replica identity setting" do - assert Decoder.decode_message( - <<68, 0, 0, 96, 0, 75, 0, 2, 116, 0, 0, 0, 7, 101, 120, 97, 109, 112, 108, 101, 110>> - ) == %Delete{ - relation_id: 24_576, - changed_key_tuple_data: {"example", nil} + test "decodes insert messages with bytea column" do + relation = %{ + id: 24_576, + namespace: "realtime", + name: "messages", + columns: [ + %Column{name: "id", type: "uuid"}, + %Column{name: "binary_payload", type: "bytea"} + ] + } + + uuid = UUID.uuid4() + bytes = <<0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF, 0x01>> + + data = + "I" <> + <> <> + "N" <> + <<2::integer-16>> <> + "b" <> + <<16::integer-32>> <> + UUID.string_to_binary!(uuid) <> + "b" <> + <> <> + bytes + + assert Decoder.decode_message(data, %{relation.id => relation}) == %Insert{ + relation_id: relation.id, + tuple_data: {uuid, bytes} } end - test "decodes DELETE messages with FULL replica identity setting" do - assert Decoder.decode_message( - <<68, 0, 0, 96, 0, 79, 0, 2, 116, 0, 0, 0, 3, 98, 97, 122, 116, 0, 0, 0, 3, 53, 54, 48>> - ) == %Delete{ - relation_id: 24_576, - old_tuple_data: {"baz", "560"} + test "decodes insert messages with unchanged toasted values", %{relation: relation} do + string = Generators.random_string() + + data = + "I" <> + <> <> + "N" <> + <<2::integer-16>> <> + "u" <> + "b" <> + <> <> + string + + assert Decoder.decode_message(data, %{relation.id => relation}) == %Insert{ + relation_id: relation.id, + tuple_data: {:unchanged_toast, string} } end end diff --git a/test/realtime/rate_counter/rate_counter_test.exs b/test/realtime/rate_counter/rate_counter_test.exs index 6d3f57401..3e62fd915 100644 --- a/test/realtime/rate_counter/rate_counter_test.exs +++ b/test/realtime/rate_counter/rate_counter_test.exs @@ -22,7 +22,7 @@ defmodule Realtime.RateCounterTest do max_bucket_len: 60, tick: 1000, tick_ref: _, - idle_shutdown: 900_000, + idle_shutdown: 300_000, idle_shutdown_ref: _, telemetry: %{emit: false}, limit: %{log: false} @@ -62,7 +62,7 @@ defmodule Realtime.RateCounterTest do max_bucket_len: 60, tick: 10, tick_ref: _, - idle_shutdown: 900_000, + idle_shutdown: 300_000, idle_shutdown_ref: _, telemetry: %{ emit: true, @@ -174,7 +174,7 @@ defmodule Realtime.RateCounterTest do log = capture_log(fn -> - GenCounter.add(args.id, 50) + GenCounter.add(args.id, 6) Process.sleep(300) end) @@ -185,7 +185,7 @@ defmodule Realtime.RateCounterTest do # Splitting by the error message returns the error message and the rest of the log only assert length(String.split(log, "ErrorMessage: Reason")) == 2 - Process.sleep(300) + Process.sleep(400) assert {:ok, %RateCounter{limit: %{triggered: false}}} = RateCounter.get(args) end @@ -197,7 +197,7 @@ defmodule Realtime.RateCounterTest do id: id, opts: [ tick: 100, - max_bucket_len: 3, + max_bucket_len: 5, limit: [ value: 49, measurement: :sum, @@ -215,7 +215,7 @@ defmodule Realtime.RateCounterTest do avg: +0.0, sum: 0, bucket: _, - max_bucket_len: 3, + max_bucket_len: 5, telemetry: %{emit: false}, limit: %{ log: true, @@ -228,7 +228,7 @@ defmodule Realtime.RateCounterTest do log = capture_log(fn -> GenCounter.add(args.id, 100) - Process.sleep(100) + Process.sleep(120) end) assert {:ok, %RateCounter{sum: sum, limit: %{triggered: true}}} = RateCounter.get(args) @@ -239,7 +239,7 @@ defmodule Realtime.RateCounterTest do # Splitting by the error message returns the error message and the rest of the log only assert length(String.split(log, "ErrorMessage: Reason")) == 2 - Process.sleep(400) + Process.sleep(600) assert {:ok, %RateCounter{sum: 0, limit: %{triggered: false}}} = RateCounter.get(args) end @@ -260,10 +260,10 @@ defmodule Realtime.RateCounterTest do test "rate counters shut themselves down when no activity occurs on the GenCounter" do args = %Args{id: {:domain, :metric, Ecto.UUID.generate()}} - {:ok, pid} = RateCounter.new(args, idle_shutdown: 5) + {:ok, pid} = RateCounter.new(args, idle_shutdown: 100) Process.monitor(pid) - assert_receive {:DOWN, _ref, :process, ^pid, :normal}, 25 + assert_receive {:DOWN, _ref, :process, ^pid, :normal}, 200 # Cache has not expired yet assert {:ok, %RateCounter{}} = Cachex.get(RateCounter, args.id) Process.sleep(2000) @@ -301,6 +301,78 @@ defmodule Realtime.RateCounterTest do end end + describe "avg normalization" do + test "avg represents events per second regardless of tick interval" do + # 1-second tick: add 10 events → avg should be ~10 events/second + id_1s = {:domain, :metric, Ecto.UUID.generate()} + args_1s = %Args{id: id_1s, opts: [tick: 1_000, max_bucket_len: 1]} + RateCounterHelper.new!(args_1s) + + GenCounter.add(id_1s, 10) + {:ok, state_1s} = RateCounterHelper.tick!(args_1s) + assert_in_delta state_1s.avg, 10.0, 0.01 + + # 5-second tick: add 50 events (= 10 per second) → avg should also be ~10 events/second + id_5s = {:domain, :metric, Ecto.UUID.generate()} + args_5s = %Args{id: id_5s, opts: [tick: 5_000, max_bucket_len: 1]} + RateCounterHelper.new!(args_5s) + + GenCounter.add(id_5s, 50) + {:ok, state_5s} = RateCounterHelper.tick!(args_5s) + assert_in_delta state_5s.avg, 10.0, 0.01 + end + + test "avg limit triggers and unsets correctly with a non-1-second tick" do + id = {:domain, :metric, Ecto.UUID.generate()} + + args = %Args{ + id: id, + opts: [ + tick: 5_000, + max_bucket_len: 1, + limit: [ + value: 10, + measurement: :avg, + log_fn: fn -> + Logger.warning("RateLimitReached", external_id: "tenant123", project: "tenant123") + end + ] + ] + } + + RateCounterHelper.new!(args) + + # 60 events over a 5-second tick = 12 events/second, above the 10/s limit + log = + capture_log(fn -> + GenCounter.add(id, 60) + RateCounterHelper.tick!(args) + end) + + assert {:ok, %RateCounter{avg: avg, limit: %{triggered: true}}} = RateCounter.get(args) + assert_in_delta avg, 12.0, 0.01 + assert log =~ "RateLimitReached" + + # 40 events over a 5-second tick = 8 events/second, below the 10/s limit + GenCounter.add(id, 40) + RateCounterHelper.tick!(args) + assert {:ok, %RateCounter{avg: avg, limit: %{triggered: false}}} = RateCounter.get(args) + assert_in_delta avg, 8.0, 0.01 + end + end + + describe "publish_update/1" do + test "cause shutdown with update message from update topic" do + args = %Args{id: {:domain, :metric, Ecto.UUID.generate()}} + {:ok, pid} = RateCounter.new(args) + + Process.monitor(pid) + RateCounter.publish_update(args.id) + + assert_receive {:DOWN, _ref, :process, ^pid, :normal} + end + end + describe "get/1" do test "gets the state of a rate counter" do args = %Args{id: {:domain, :metric, Ecto.UUID.generate()}} @@ -316,37 +388,5 @@ defmodule Realtime.RateCounterTest do end end - describe "stop/1" do - test "stops rate counters for a given entity" do - entity_id = Ecto.UUID.generate() - fake_terms = Enum.map(1..10, fn _ -> {:domain, :"metric_#{random_string()}", Ecto.UUID.generate()} end) - terms = Enum.map(1..10, fn _ -> {:domain, :"metric_#{random_string()}", entity_id} end) - - for term <- terms do - args = %Args{id: term} - {:ok, _} = RateCounter.new(args) - assert {:ok, %RateCounter{}} = RateCounter.get(args) - end - - for term <- fake_terms do - args = %Args{id: term} - {:ok, _} = RateCounter.new(args) - assert {:ok, %RateCounter{}} = RateCounter.get(args) - end - - assert :ok = RateCounter.stop(entity_id) - # Wait for processes to shut down and Registry to update - Process.sleep(100) - - for term <- terms do - assert [] = Registry.lookup(Realtime.Registry.Unique, {RateCounter, :rate_counter, term}) - end - - for term <- fake_terms do - assert [{_pid, _value}] = Registry.lookup(Realtime.Registry.Unique, {RateCounter, :rate_counter, term}) - end - end - end - def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {event, measures, metadata}) end diff --git a/test/realtime/repo_replica_test.exs b/test/realtime/repo_replica_test.exs index a3734d31b..e794f060f 100644 --- a/test/realtime/repo_replica_test.exs +++ b/test/realtime/repo_replica_test.exs @@ -1,14 +1,23 @@ defmodule Realtime.Repo.ReplicaTest do - use ExUnit.Case + # application env being changed + use ExUnit.Case, async: false alias Realtime.Repo.Replica setup do previous_platform = Application.get_env(:realtime, :platform) previous_region = Application.get_env(:realtime, :region) + previous_master_region = Application.get_env(:realtime, :master_region) + previous_main_replica = Application.get_env(:realtime, Replica) on_exit(fn -> Application.put_env(:realtime, :platform, previous_platform) Application.put_env(:realtime, :region, previous_region) + Application.put_env(:realtime, :master_region, previous_master_region) + Application.delete_env(:realtime, Replica) + + if previous_main_replica do + Application.put_env(:realtime, Replica, previous_main_replica) + end end) end @@ -16,12 +25,20 @@ defmodule Realtime.Repo.ReplicaTest do for {region, mod} <- Replica.replicas_aws() do setup do Application.put_env(:realtime, :platform, :aws) + Application.put_env(:realtime, :master_region, "special-region") + :ok end test "handles #{region} region" do Application.put_env(:realtime, :region, unquote(region)) replica_asserts(unquote(mod), Replica.replica()) end + + test "defaults to Realtime.Repo if region is equal to master region on #{region}" do + Application.put_env(:realtime, :region, unquote(region)) + Application.put_env(:realtime, :master_region, unquote(region)) + replica_asserts(Realtime.Repo, Replica.replica()) + end end test "defaults to Realtime.Repo if region is not configured" do @@ -34,6 +51,8 @@ defmodule Realtime.Repo.ReplicaTest do for {region, mod} <- Replica.replicas_fly() do setup do Application.put_env(:realtime, :platform, :fly) + Application.put_env(:realtime, :master_region, "special-region") + :ok end test "handles #{region} region" do @@ -48,6 +67,53 @@ defmodule Realtime.Repo.ReplicaTest do end end + describe "main replica module configuration" do + setup do + Application.put_env(:realtime, Replica, hostname: "test-replica-host") + :ok + end + + test "uses main replica module when configured on AWS platform" do + Application.put_env(:realtime, :platform, :aws) + Application.put_env(:realtime, :region, "us-west-1") + Application.put_env(:realtime, :master_region, "us-east-1") + + replica_asserts(Replica, Replica.replica()) + end + + test "uses main replica module when configured on Fly platform" do + Application.put_env(:realtime, :platform, :fly) + Application.put_env(:realtime, :region, "sea") + Application.put_env(:realtime, :master_region, "sjc") + + replica_asserts(Replica, Replica.replica()) + end + + test "still defaults to Realtime.Repo when region matches master region" do + Application.put_env(:realtime, :platform, :aws) + Application.put_env(:realtime, :region, "us-west-1") + Application.put_env(:realtime, :master_region, "us-west-1") + + replica_asserts(Realtime.Repo, Replica.replica()) + end + + test "uses main replica module when region is unknown" do + Application.put_env(:realtime, :platform, :aws) + Application.put_env(:realtime, :region, "unknown-region") + Application.put_env(:realtime, :master_region, "us-east-1") + + replica_asserts(Replica, Replica.replica()) + end + + test "uses main replica module without platform configuration" do + Application.delete_env(:realtime, :platform) + Application.put_env(:realtime, :region, "us-west-1") + Application.put_env(:realtime, :master_region, "us-east-1") + + replica_asserts(Replica, Replica.replica()) + end + end + defp replica_asserts(mod, replica) do assert mod == replica assert [Ecto.Repo] == replica.__info__(:attributes) |> Keyword.get(:behaviour) diff --git a/test/realtime/rpc_test.exs b/test/realtime/rpc_test.exs index 221cd781b..9c83d7064 100644 --- a/test/realtime/rpc_test.exs +++ b/test/realtime/rpc_test.exs @@ -81,8 +81,7 @@ defmodule Realtime.RpcTest do func: :test_success, origin_node: ^origin_node, target_node: ^node, - success: true, - tenant: "123" + success: true }} end @@ -100,8 +99,7 @@ defmodule Realtime.RpcTest do func: :test_raise, origin_node: ^origin_node, target_node: ^node, - success: false, - tenant: "123" + success: false }} end diff --git a/test/realtime/signal_handler_test.exs b/test/realtime/signal_handler_test.exs index e694f0a7a..37df34fec 100644 --- a/test/realtime/signal_handler_test.exs +++ b/test/realtime/signal_handler_test.exs @@ -4,7 +4,7 @@ defmodule Realtime.SignalHandlerTest do alias Realtime.SignalHandler defmodule FakeHandler do - def handle_event(:sigterm, _state), do: send(self(), :ok) + def handle_event(signal, _state), do: send(self(), signal) end setup do @@ -20,7 +20,36 @@ defmodule Realtime.SignalHandlerTest do assert capture_log(fn -> SignalHandler.handle_event(:sigterm, state) end) =~ "SignalHandler: :sigterm received" - assert_receive :ok + assert_receive :sigterm + end + + test "sets shutdown_in_progress on sigterm" do + {:ok, state} = SignalHandler.init({%{handler_mod: FakeHandler}, :ok}) + + capture_log(fn -> SignalHandler.handle_event(:sigterm, state) end) + + assert Application.get_env(:realtime, :shutdown_in_progress) == true + end + + test "does not set shutdown_in_progress on non-sigterm signals" do + Application.put_env(:realtime, :shutdown_in_progress, false) + {:ok, state} = SignalHandler.init({%{handler_mod: FakeHandler}, :ok}) + + capture_log(fn -> SignalHandler.handle_event(:sigusr1, state) end) + + refute Application.get_env(:realtime, :shutdown_in_progress) + end + end + + describe "gen_event callbacks" do + test "handle_info delegates to erl_signal_handler" do + {:ok, state} = SignalHandler.init({%{handler_mod: FakeHandler}, :ok}) + assert {:ok, _state} = SignalHandler.handle_info(:some_info, state) + end + + test "handle_call delegates to erl_signal_handler" do + {:ok, state} = SignalHandler.init({%{handler_mod: FakeHandler}, :ok}) + assert {:ok, _reply, _state} = SignalHandler.handle_call(:some_call, state) end end diff --git a/test/realtime/syn_handler_test.exs b/test/realtime/syn_handler_test.exs index 2b27cf322..35664f178 100644 --- a/test/realtime/syn_handler_test.exs +++ b/test/realtime/syn_handler_test.exs @@ -13,8 +13,15 @@ defmodule Realtime.SynHandlerTest do defmodule FakeConnect do use GenServer + def start_link([tenant_id, region, opts]) do + name = {Connect, tenant_id, %{conn: nil, region: region}} + gen_opts = [name: {:via, :syn, name}] + GenServer.start_link(FakeConnect, [tenant_id, opts], gen_opts) + end + def init([tenant_id, opts]) do - :syn.update_registry(Connect, tenant_id, fn _pid, meta -> %{meta | conn: "fake_conn"} end) + conn = Keyword.get(opts, :conn, "remote_conn") + :syn.update_registry(Connect, tenant_id, fn _pid, meta -> %{meta | conn: conn} end) if opts[:trap_exit], do: Process.flag(:trap_exit, true) @@ -28,125 +35,184 @@ defmodule Realtime.SynHandlerTest do Code.eval_quoted(@aux_mod) - defp assert_process_down(pid, reason \\ nil, timeout \\ 100) do - ref = Process.monitor(pid) + # > :"main@127.0.0.11" < :"atest@127.0.0.1" + # false + # iex(2)> :erlang.phash2("tenant123", 2) + # 0 + # iex(3)> :erlang.phash2("tenant999", 2) + # 1 + describe "integration test with a Connect conflict name=atest" do + setup do + {:ok, pid, node} = + Clustered.start_disconnected(@aux_mod, name: :atest, extra_config: [{:realtime, :region, "ap-southeast-2"}]) - if reason do - assert_receive {:DOWN, ^ref, :process, ^pid, ^reason}, timeout - else - assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, timeout + %{peer_pid: pid, node: node} + end + + @tag tenant_id: "tenant999" + test "tenant hash = 1", %{node: node, peer_pid: peer_pid, tenant_id: tenant_id} do + assert :erlang.phash2(tenant_id, 2) == 1 + local_pid = start_supervised!({FakeConnect, [tenant_id, "us-east-1", [conn: "local_conn"]]}) + {:ok, remote_pid} = :peer.call(peer_pid, FakeConnect, :start_link, [[tenant_id, "ap-southeast-2", []]]) + on_exit(fn -> Process.exit(remote_pid, :brutal_kill) end) + + log = + capture_log(fn -> + # Connect to peer node to cause a conflict on syn + true = Node.connect(node) + # Give some time for the conflict resolution to happen on the other node + Process.sleep(500) + + # Both nodes agree + assert {^remote_pid, %{region: "ap-southeast-2", conn: "remote_conn"}} = + :peer.call(peer_pid, :syn, :lookup, [Connect, tenant_id]) + + assert {^remote_pid, %{region: "ap-southeast-2", conn: "remote_conn"}} = :syn.lookup(Connect, tenant_id) + + assert :peer.call(peer_pid, Process, :alive?, [remote_pid]) + + refute Process.alive?(local_pid) + end) + + assert log =~ "stop local process: #{inspect(local_pid)}" + assert log =~ "Successfully stopped #{inspect(local_pid)}" + + assert log =~ + "Elixir.Realtime.Tenants.Connect terminated due to syn conflict resolution: \"#{tenant_id}\" #{inspect(local_pid)}" + end + + @tag tenant_id: "tenant123" + test "tenant hash = 0", %{node: node, peer_pid: peer_pid, tenant_id: tenant_id} do + assert :erlang.phash2(tenant_id, 2) == 0 + {:ok, remote_pid} = :peer.call(peer_pid, FakeConnect, :start_link, [[tenant_id, "ap-southeast-2", []]]) + local_pid = start_supervised!({FakeConnect, [tenant_id, "us-east-1", [conn: "local_conn"]]}) + on_exit(fn -> Process.exit(remote_pid, :kill) end) + + log = + capture_log(fn -> + # Connect to peer node to cause a conflict on syn + true = Node.connect(node) + # Give some time for the conflict resolution to happen on the other node + Process.sleep(500) + + # Both nodes agree + assert {^local_pid, %{region: "us-east-1", conn: "local_conn"}} = :syn.lookup(Connect, tenant_id) + + assert {^local_pid, %{region: "us-east-1", conn: "local_conn"}} = + :peer.call(peer_pid, :syn, :lookup, [Connect, tenant_id]) + + refute :peer.call(peer_pid, Process, :alive?, [remote_pid]) + + assert Process.alive?(local_pid) + end) + + assert log =~ "remote process will be stopped: #{inspect(remote_pid)}" end end - describe "integration test with a Connect conflict" do + # > :"main@127.0.0.11" < :"test@127.0.0.1" + # true + # iex(2)> :erlang.phash2("tenant123", 2) + # 0 + # iex(3)> :erlang.phash2("tenant999", 2) + # 1 + describe "integration test with a Connect conflict name=test" do setup do - ensure_connect_down("dev_tenant") - {:ok, pid, node} = Clustered.start_disconnected(@aux_mod, extra_config: [{:realtime, :region, "ap-southeast-2"}]) - Endpoint.subscribe("connect:dev_tenant") + {:ok, pid, node} = + Clustered.start_disconnected(@aux_mod, name: :test, extra_config: [{:realtime, :region, "ap-southeast-2"}]) + %{peer_pid: pid, node: node} end - test "local node started first", %{node: node, peer_pid: peer_pid} do - external_id = "dev_tenant" - # start connect locally first - {:ok, db_conn} = Connect.lookup_or_start_connection(external_id) - assert Connect.ready?(external_id) - connect = Connect.whereis(external_id) - assert node(connect) == node() - - # Now let's force the remote node to start the fake Connect process - name = {Connect, external_id, %{conn: nil, region: "ap-southeast-2"}} - opts = [name: {:via, :syn, name}] - {:ok, remote_pid} = :peer.call(peer_pid, GenServer, :start_link, [FakeConnect, [external_id, []], opts]) + @tag tenant_id: "tenant999" + test "tenant hash = 1", %{node: node, peer_pid: peer_pid, tenant_id: tenant_id} do + assert :erlang.phash2(tenant_id, 2) == 1 + Endpoint.subscribe("connect:#{tenant_id}") + local_pid = start_supervised!({FakeConnect, [tenant_id, "us-east-1", [conn: "local_conn"]]}) + + {:ok, remote_pid} = :peer.call(peer_pid, FakeConnect, :start_link, [[tenant_id, "ap-southeast-2", []]]) + on_exit(fn -> Process.exit(remote_pid, :brutal_kill) end) log = capture_log(fn -> - Endpoint.subscribe("connect:dev_tenant") # Connect to peer node to cause a conflict on syn true = Node.connect(node) # Give some time for the conflict resolution to happen on the other node Process.sleep(500) # Both nodes agree - assert {^connect, %{region: "us-east-1", conn: ^db_conn}} = :syn.lookup(Connect, external_id) + assert {^local_pid, %{region: "us-east-1", conn: "local_conn"}} = :syn.lookup(Connect, tenant_id) - assert {^connect, %{region: "us-east-1", conn: ^db_conn}} = - :peer.call(peer_pid, :syn, :lookup, [Connect, external_id]) + assert {^local_pid, %{region: "us-east-1", conn: "local_conn"}} = + :peer.call(peer_pid, :syn, :lookup, [Connect, tenant_id]) refute :peer.call(peer_pid, Process, :alive?, [remote_pid]) - assert Process.alive?(connect) + assert Process.alive?(local_pid) end) assert log =~ "remote process will be stopped: #{inspect(remote_pid)}" end - test "remote node started first", %{node: node, peer_pid: peer_pid} do - external_id = "dev_tenant" + @tag tenant_id: "tenant123" + test "tenant hash = 0", %{node: node, peer_pid: peer_pid, tenant_id: tenant_id} do + assert :erlang.phash2(tenant_id, 2) == 0 # Start remote process first - name = {Connect, external_id, %{conn: nil, region: "ap-southeast-2"}} - opts = [name: {:via, :syn, name}] - {:ok, remote_pid} = :peer.call(peer_pid, GenServer, :start_link, [FakeConnect, [external_id, []], opts]) + {:ok, remote_pid} = :peer.call(peer_pid, FakeConnect, :start_link, [[tenant_id, "ap-southeast-2", []]]) + on_exit(fn -> Process.exit(remote_pid, :kill) end) # start connect locally later - {:ok, _db_conn} = Connect.lookup_or_start_connection(external_id) - assert Connect.ready?(external_id) - connect = Connect.whereis(external_id) - assert node(connect) == node() + local_pid = start_supervised!({FakeConnect, [tenant_id, "us-east-1", [conn: "local_conn"]]}) log = capture_log(fn -> # Connect to peer node to cause a conflict on syn true = Node.connect(node) - assert_process_down(connect) - assert_receive %{event: "connect_down"} + # Give some time for the conflict resolution to happen on the other node + Process.sleep(500) # Both nodes agree - assert {^remote_pid, %{region: "ap-southeast-2", conn: "fake_conn"}} = - :peer.call(peer_pid, :syn, :lookup, [Connect, external_id]) + assert {^remote_pid, %{region: "ap-southeast-2", conn: "remote_conn"}} = + :peer.call(peer_pid, :syn, :lookup, [Connect, tenant_id]) - assert {^remote_pid, %{region: "ap-southeast-2", conn: "fake_conn"}} = :syn.lookup(Connect, external_id) + assert {^remote_pid, %{region: "ap-southeast-2", conn: "remote_conn"}} = :syn.lookup(Connect, tenant_id) assert :peer.call(peer_pid, Process, :alive?, [remote_pid]) - refute Process.alive?(connect) + refute Process.alive?(local_pid) end) - assert log =~ "stop local process: #{inspect(connect)}" - assert log =~ "Successfully stopped #{inspect(connect)}" + assert log =~ "stop local process: #{inspect(local_pid)}" + assert log =~ "Successfully stopped #{inspect(local_pid)}" assert log =~ - "Elixir.Realtime.Tenants.Connect terminated due to syn conflict resolution: \"dev_tenant\" #{inspect(connect)}" + "Elixir.Realtime.Tenants.Connect terminated due to syn conflict resolution: \"#{tenant_id}\" #{inspect(local_pid)}" end - test "remote node started first but timed out stopping", %{node: node, peer_pid: peer_pid} do - external_id = "dev_tenant" + @tag tenant_id: "tenant123" + test "tenant hash = 0 but timed out stopping", %{node: node, peer_pid: peer_pid, tenant_id: tenant_id} do + assert :erlang.phash2(tenant_id, 2) == 0 # Start remote process first - name = {Connect, external_id, %{conn: nil, region: "ap-southeast-2"}} - opts = [name: {:via, :syn, name}] - {:ok, remote_pid} = :peer.call(peer_pid, GenServer, :start_link, [FakeConnect, [external_id, []], opts]) - on_exit(fn -> Process.exit(remote_pid, :brutal_kill) end) + {:ok, remote_pid} = :peer.call(peer_pid, FakeConnect, :start_link, [[tenant_id, "ap-southeast-2", []]]) + + on_exit(fn -> Process.exit(remote_pid, :kill) end) - {:ok, local_pid} = - start_supervised(%{ - id: self(), - start: {GenServer, :start_link, [FakeConnect, [external_id, [trap_exit: true]], opts]} - }) + # start connect locally later + local_pid = start_supervised!({FakeConnect, [tenant_id, "us-east-1", [conn: "local_conn", trap_exit: true]]}) log = capture_log(fn -> # Connect to peer node to cause a conflict on syn true = Node.connect(node) assert_process_down(local_pid, :killed, 6000) - assert_receive %{event: "connect_down"} # Both nodes agree - assert {^remote_pid, %{region: "ap-southeast-2", conn: "fake_conn"}} = - :peer.call(peer_pid, :syn, :lookup, [Connect, external_id]) + assert {^remote_pid, %{region: "ap-southeast-2", conn: "remote_conn"}} = + :peer.call(peer_pid, :syn, :lookup, [Connect, tenant_id]) - assert {^remote_pid, %{region: "ap-southeast-2", conn: "fake_conn"}} = :syn.lookup(Connect, external_id) + assert {^remote_pid, %{region: "ap-southeast-2", conn: "remote_conn"}} = :syn.lookup(Connect, tenant_id) assert :peer.call(peer_pid, Process, :alive?, [remote_pid]) @@ -157,7 +223,34 @@ defmodule Realtime.SynHandlerTest do assert log =~ "Timed out while waiting for process #{inspect(local_pid)} to stop. Sending kill exit signal" assert log =~ - "Elixir.Realtime.Tenants.Connect terminated due to syn conflict resolution: \"dev_tenant\" #{inspect(local_pid)}" + "Elixir.Realtime.Tenants.Connect terminated due to syn conflict resolution: \"#{tenant_id}\" #{inspect(local_pid)}" + end + end + + describe "on_process_registered/5" do + test "emits telemetry event for process registration" do + pid = self() + meta = %{some: :meta} + reason = :normal + + # Attach a test handler to capture the telemetry event + test_pid = self() + handler_id = [:test, :syn_handler, :registered] + + :telemetry.attach( + handler_id, + [:syn, @mod, :registered], + fn event, measurements, metadata, _config -> + send(test_pid, {:telemetry_event, event, measurements, metadata}) + end, + nil + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + + assert SynHandler.on_process_registered(@mod, @name, pid, meta, reason) == :ok + + assert_receive {:telemetry_event, [:syn, @mod, :registered], %{}, %{name: @name}} end end @@ -166,34 +259,82 @@ defmodule Realtime.SynHandlerTest do RealtimeWeb.Endpoint.subscribe("#{@topic}:#{@name}") end + test "emits telemetry event for process unregistration" do + reason = :normal + pid = self() + + # Attach a test handler to capture the telemetry event + test_pid = self() + handler_id = [:test, :syn_handler, :unregistered] + + :telemetry.attach( + handler_id, + [:syn, @mod, :unregistered], + fn event, measurements, metadata, _config -> + send(test_pid, {:telemetry_event, event, measurements, metadata}) + end, + nil + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + + capture_log(fn -> + assert SynHandler.on_process_unregistered(@mod, @name, pid, %{}, reason) == :ok + end) + + assert_receive {:telemetry_event, [:syn, @mod, :unregistered], %{}, %{name: @name}} + + topic = "#{@topic}:#{@name}" + event = "#{@topic}_down" + assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: ^event, payload: %{reason: ^reason, pid: ^pid}} + end + test "it handles :syn_conflict_resolution reason" do reason = :syn_conflict_resolution + pid = self() log = capture_log(fn -> - assert SynHandler.on_process_unregistered(@mod, @name, self(), %{}, reason) == :ok + assert SynHandler.on_process_unregistered(@mod, @name, pid, %{}, reason) == :ok end) topic = "#{@topic}:#{@name}" event = "#{@topic}_down" assert log =~ "#{@mod} terminated due to syn conflict resolution: #{inspect(@name)} #{inspect(self())}" - assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: ^event, payload: nil} + assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: ^event, payload: %{reason: ^reason, pid: ^pid}} end test "it handles other reasons" do reason = :other_reason + pid = self() log = capture_log(fn -> - assert SynHandler.on_process_unregistered(@mod, @name, self(), %{}, reason) == :ok + assert SynHandler.on_process_unregistered(@mod, @name, pid, %{}, reason) == :ok end) topic = "#{@topic}:#{@name}" event = "#{@topic}_down" refute log =~ "#{@mod} terminated: #{inspect(@name)} #{node()}" - assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: ^event, payload: nil}, 500 + + assert_receive %Phoenix.Socket.Broadcast{ + topic: ^topic, + event: ^event, + payload: %{reason: ^reason, pid: ^pid} + }, + 500 + end + end + + defp assert_process_down(pid, reason, timeout) do + ref = Process.monitor(pid) + + if reason do + assert_receive {:DOWN, ^ref, :process, ^pid, ^reason}, timeout + else + assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, timeout end end end diff --git a/test/realtime/telemetry/logger_test.exs b/test/realtime/telemetry/logger_test.exs index 640cfc7e2..28ececf37 100644 --- a/test/realtime/telemetry/logger_test.exs +++ b/test/realtime/telemetry/logger_test.exs @@ -26,4 +26,10 @@ defmodule Realtime.Telemetry.LoggerTest do end) =~ "Billing metrics: [:realtime, :connections]" end end + + describe "handle_info/2" do + test "ignores unexpected messages" do + assert {:noreply, []} = TelemetryLogger.handle_info(:unexpected, []) + end + end end diff --git a/test/realtime/tenants/authorization_remote_test.exs b/test/realtime/tenants/authorization_remote_test.exs index 53efe44ec..8ecfd1fcd 100644 --- a/test/realtime/tenants/authorization_remote_test.exs +++ b/test/realtime/tenants/authorization_remote_test.exs @@ -1,13 +1,10 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do # async: false due to usage of Clustered - # Also using dev_tenant due to distributed test use RealtimeWeb.ConnCase, async: false use Mimic import ExUnit.CaptureLog - require Phoenix.ChannelTest - alias Realtime.Database alias Realtime.Tenants alias Realtime.Tenants.Authorization @@ -16,7 +13,7 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do alias Realtime.Tenants.Authorization.Policies.PresencePolicies alias Realtime.Tenants.Connect - setup [:rls_context] + setup [:remote_rls_context] describe "get_authorizations" do @tag role: "authenticated", @@ -78,8 +75,6 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do @tag role: "anon", policies: [] test "db process is down", context do - # Grab a remote pid that will not exist in the near future. erpc uses a new process to perform the call. - # Once it has returned the process is not alive anymore db_conn = :erpc.call(context.node, :erlang, :self, []) {:error, :increase_connection_pool} = @@ -100,8 +95,8 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do Authorization.get_read_authorizations(%Policies{}, pid, context.authorization_context) end - # Waiting for RateCounter to limit - Process.sleep(1100) + rate_counter = Realtime.Tenants.authorization_errors_per_second_rate(context.tenant) + RateCounterHelper.tick!(rate_counter) for _ <- 1..10 do {:error, :increase_connection_pool} = @@ -110,9 +105,6 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do end) assert log =~ "IncreaseConnectionPool: Too many database timeouts" - - # Only one log message should be emitted - # Splitting by the error message returns the error message and the rest of the log only assert length(String.split(log, "IncreaseConnectionPool: Too many database timeouts")) == 2 end @@ -127,8 +119,8 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do Authorization.get_write_authorizations(%Policies{}, pid, context.authorization_context) end - # Waiting for RateCounter to limit - Process.sleep(1100) + rate_counter = Realtime.Tenants.authorization_errors_per_second_rate(context.tenant) + RateCounterHelper.tick!(rate_counter) for _ <- 1..10 do {:error, :increase_connection_pool} = @@ -137,9 +129,6 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do end) assert log =~ "IncreaseConnectionPool: Too many database timeouts" - - # Only one log message should be emitted - # Splitting by the error message returns the error message and the rest of the log only assert length(String.split(log, "IncreaseConnectionPool: Too many database timeouts")) == 2 end end @@ -184,8 +173,8 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do end) Task.await_many([t1, t2], 20_000) - # Wait for RateCounter to log - Process.sleep(1000) + rate_counter = Realtime.Tenants.authorization_errors_per_second_rate(context.tenant) + RateCounterHelper.tick!(rate_counter) end) external_id = context.tenant.external_id @@ -236,12 +225,8 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do end end - defp rls_context(context) do - tenant = Realtime.Tenants.get_tenant_by_external_id("dev_tenant") - Connect.shutdown("dev_tenant") - # Waiting for :syn to unregister - Process.sleep(100) - Realtime.RateCounter.stop("dev_tenant") + defp remote_rls_context(context) do + tenant = Containers.checkout_tenant_unboxed(run_migrations: true) {:ok, local_db_conn} = Database.connect(tenant, "realtime_test", :stop) topic = random_string() @@ -249,26 +234,22 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do clean_table(local_db_conn, "realtime", "messages") claims = %{sub: random_string(), role: context.role, exp: Joken.current_time() + 1_000} - signer = Joken.Signer.create("HS256", "secret") - - jwt = Joken.generate_and_sign!(%{}, claims, signer) authorization_context = Authorization.build_authorization_params(%{ tenant_id: tenant.external_id, topic: topic, - jwt: jwt, claims: claims, headers: [{"header-1", "value-1"}], role: claims.role }) - Realtime.Tenants.Migrations.create_partitions(local_db_conn) + Realtime.Tenants.create_messages_partitions(local_db_conn) create_rls_policies(local_db_conn, context.policies, %{topic: topic}) {:ok, node} = Clustered.start() region = Tenants.region(tenant) - {:ok, db_conn} = :erpc.call(node, Connect, :connect, ["dev_tenant", region]) + {:ok, db_conn} = :erpc.call(node, Connect, :connect, [tenant.external_id, region]) assert node(db_conn) == node diff --git a/test/realtime/tenants/authorization_test.exs b/test/realtime/tenants/authorization_test.exs index 724e6e933..bf31dc9e9 100644 --- a/test/realtime/tenants/authorization_test.exs +++ b/test/realtime/tenants/authorization_test.exs @@ -2,19 +2,17 @@ defmodule Realtime.Tenants.AuthorizationTest do use RealtimeWeb.ConnCase, async: true use Mimic - require Phoenix.ChannelTest - import ExUnit.CaptureLog alias Realtime.Api.Message alias Realtime.Database - alias Realtime.Repo + alias Realtime.Tenants.Repo alias Realtime.Tenants.Authorization alias Realtime.Tenants.Authorization.Policies alias Realtime.Tenants.Authorization.Policies.BroadcastPolicies alias Realtime.Tenants.Authorization.Policies.PresencePolicies - setup [:rls_context] + setup [:checkout_tenant_and_connect, :rls_context] describe "get_authorizations/3" do @tag role: "authenticated", @@ -51,19 +49,34 @@ defmodule Realtime.Tenants.AuthorizationTest do @tag role: "authenticated", policies: [:read_matching_user_role] test "user role is exposed", context do - # policy role is checking for "authenticated" - # set_config is setting request.jwt.claim.role to authenticated as well assert {:ok, %Policies{broadcast: %BroadcastPolicies{read: true, write: nil}}} = Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context) authorization_context = %{context.authorization_context | role: "anon"} - # policy role is checking for "authenticated" - # set_config is setting request.jwt.claim.role to anon assert {:ok, %Policies{broadcast: %BroadcastPolicies{read: false, write: nil}}} = Authorization.get_read_authorizations(%Policies{}, context.db_conn, authorization_context) end + @tag role: "authenticated", + policies: [:authenticated_read_broadcast, :authenticated_write_broadcast] + test "skips presence RLS check when presence is disabled", context do + {:ok, policies} = + Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context, + presence_enabled?: false + ) + + {:ok, policies} = + Authorization.get_write_authorizations(policies, context.db_conn, context.authorization_context, + presence_enabled?: false + ) + + assert %Policies{ + broadcast: %BroadcastPolicies{read: true, write: true}, + presence: %PresencePolicies{read: false, write: false} + } == policies + end + @tag role: "anon", policies: [ :authenticated_read_broadcast_and_presence, @@ -105,9 +118,8 @@ defmodule Realtime.Tenants.AuthorizationTest do Authorization.get_read_authorizations(%Policies{}, pid, context.authorization_context) end - # Waiting for RateCounter to limit - Process.sleep(1100) - # The next auth requests will not call the database due to being rate limited + rate_counter = Realtime.Tenants.authorization_errors_per_second_rate(context.tenant) + RateCounterHelper.tick!(rate_counter) reject(&Database.transaction/4) for _ <- 1..10 do @@ -117,10 +129,7 @@ defmodule Realtime.Tenants.AuthorizationTest do end) assert log =~ "IncreaseConnectionPool: Too many database timeouts" - - # Only one log message should be emitted - # Splitting by the error message returns the error message and the rest of the log only - assert length(String.split(log, "IncreaseConnectionPool: Too many database timeouts")) == 2 + assert length(String.split(log, "IncreaseConnectionPool: Too many database timeouts")) <= 3 end @tag role: "anon", policies: [] @@ -135,9 +144,8 @@ defmodule Realtime.Tenants.AuthorizationTest do Authorization.get_write_authorizations(%Policies{}, pid, context.authorization_context) end - # Waiting for RateCounter to limit - Process.sleep(1100) - # The next auth requests will not call the database due to being rate limited + rate_counter = Realtime.Tenants.authorization_errors_per_second_rate(context.tenant) + RateCounterHelper.tick!(rate_counter) reject(&Database.transaction/4) for _ <- 1..10 do @@ -147,9 +155,6 @@ defmodule Realtime.Tenants.AuthorizationTest do end) assert log =~ "IncreaseConnectionPool: Too many database timeouts" - - # Only one log message should be emitted - # Splitting by the error message returns the error message and the rest of the log only assert length(String.split(log, "IncreaseConnectionPool: Too many database timeouts")) == 2 end end @@ -192,8 +197,8 @@ defmodule Realtime.Tenants.AuthorizationTest do end) Task.await_many([t1, t2], 20_000) - # Wait for RateCounter log - Process.sleep(1000) + rate_counter = Realtime.Tenants.authorization_errors_per_second_rate(context.tenant) + RateCounterHelper.tick!(rate_counter) end) external_id = context.tenant.external_id @@ -240,6 +245,101 @@ defmodule Realtime.Tenants.AuthorizationTest do end end + describe "database error classification" do + @tag role: "anon", policies: [] + test "invalid_parameter_value Postgrex error is classified as rls_policy_error", context do + stub(Database, :transaction, fn _, _, _, _ -> + {:error, + %Postgrex.Error{postgres: %{code: :invalid_parameter_value, message: "role \"super_admin\" does not exist"}}} + end) + + assert {:error, :rls_policy_error, %Postgrex.Error{}} = + Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context) + + assert {:error, :rls_policy_error, %Postgrex.Error{}} = + Authorization.get_write_authorizations(%Policies{}, context.db_conn, context.authorization_context) + end + + @tag role: "anon", policies: [] + test "query_canceled is classified as query_canceled", context do + query_canceled = %Postgrex.Error{ + postgres: %{code: :query_canceled, message: "canceling statement due to user request"} + } + + stub(Database, :transaction, fn _, _, _, _ -> + {:error, query_canceled} + end) + + assert {:error, :query_canceled, %Postgrex.Error{}} = + Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context) + + assert {:error, :query_canceled, %Postgrex.Error{}} = + Authorization.get_write_authorizations(%Policies{}, context.db_conn, context.authorization_context) + end + + @tag role: "anon", policies: [] + test "check_violation on messages is classified as missing_partition", context do + check_violation = %Postgrex.Error{ + postgres: %{ + code: :check_violation, + table: "messages", + message: "no partition of relation \"messages\" found for row" + } + } + + stub(Database, :transaction, fn _, _, _, _ -> + {:error, check_violation} + end) + + assert {:error, :missing_partition} = + Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context) + + assert {:error, :missing_partition} = + Authorization.get_write_authorizations(%Policies{}, context.db_conn, context.authorization_context) + end + + @tag role: "anon", policies: [] + test "DBConnection.ConnectionError is classified as tenant_database_unavailable", context do + stub(Database, :transaction, fn _, _, _, _ -> + {:error, %DBConnection.ConnectionError{message: "ssl recv: closed", severity: :error, reason: :error}} + end) + + assert {:error, :tenant_database_unavailable} = + Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context) + + assert {:error, :tenant_database_unavailable} = + Authorization.get_write_authorizations(%Policies{}, context.db_conn, context.authorization_context) + end + + @tag role: "anon", policies: [] + test "ssl recv: closed ConnectionError from Repo on read is classified as tenant_database_unavailable", context do + conn_error = %DBConnection.ConnectionError{message: "ssl recv: closed", severity: :error, reason: :closed} + stub(Repo, :insert_all_entries, fn _, _, _ -> {:error, conn_error} end) + + assert {:error, :tenant_database_unavailable} = + Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context) + end + + @tag role: "anon", policies: [] + test "ssl recv: closed ConnectionError from Repo on write is classified as tenant_database_unavailable", context do + conn_error = %DBConnection.ConnectionError{message: "ssl recv: closed", severity: :error, reason: :closed} + stub(Repo, :insert, fn _, _, _, _ -> {:error, conn_error} end) + + assert {:error, :tenant_database_unavailable} = + Authorization.get_write_authorizations(%Policies{}, context.db_conn, context.authorization_context) + end + + @tag role: "anon", policies: [] + test "ssl recv: closed ConnectionError from Repo.all on read is classified as tenant_database_unavailable", + context do + conn_error = %DBConnection.ConnectionError{message: "ssl recv: closed", severity: :error, reason: :closed} + stub(Repo, :all, fn _, _, _ -> {:error, conn_error} end) + + assert {:error, :tenant_database_unavailable} = + Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context) + end + end + describe "telemetry" do @tag role: "authenticated", policies: [ @@ -277,40 +377,6 @@ defmodule Realtime.Tenants.AuthorizationTest do end end - def rls_context(context) do - tenant = Containers.checkout_tenant(run_migrations: true) - # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) - - {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) - topic = context[:topic] || random_string() - - create_rls_policies(db_conn, context.policies, %{topic: topic, sub: context[:sub], role: context.role}) - - claims = %{"sub" => context[:sub] || random_string(), "role" => context.role, "exp" => Joken.current_time() + 1_000} - - authorization_context = - Authorization.build_authorization_params(%{ - tenant_id: tenant.external_id, - topic: topic, - claims: claims, - headers: [{"header-1", "value-1"}], - role: claims["role"], - sub: claims["sub"] - }) - - Realtime.Tenants.Migrations.create_partitions(db_conn) - - on_exit(fn -> Process.exit(db_conn, :kill) end) - - %{ - tenant: tenant, - topic: topic, - db_conn: db_conn, - authorization_context: authorization_context - } - end - defp update_db_pool_size(tenant, db_pool) do extension = hd(tenant.extensions) @@ -318,9 +384,8 @@ defmodule Realtime.Tenants.AuthorizationTest do extensions = [Map.from_struct(%{extension | :settings => settings})] - {:ok, tenant} = Realtime.Api.update_tenant(tenant, %{extensions: extensions}) + {:ok, tenant} = Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions}) - # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) end end diff --git a/test/realtime/tenants/batch_broadcast_test.exs b/test/realtime/tenants/batch_broadcast_test.exs new file mode 100644 index 000000000..b5f6d6ddc --- /dev/null +++ b/test/realtime/tenants/batch_broadcast_test.exs @@ -0,0 +1,539 @@ +defmodule Realtime.Tenants.BatchBroadcastTest do + use RealtimeWeb.ConnCase, async: true + use Mimic + + alias Realtime.Database + alias Realtime.GenCounter + alias Realtime.RateCounter + alias Realtime.Tenants + alias Realtime.Tenants.BatchBroadcast + alias Realtime.Tenants.Authorization + alias Realtime.Tenants.Authorization.Policies + alias Realtime.Tenants.Authorization.Policies.BroadcastPolicies + alias Realtime.Tenants.Connect + + alias RealtimeWeb.TenantBroadcaster + + setup do + tenant = Containers.checkout_tenant(run_migrations: true) + Realtime.Tenants.Cache.update_cache(tenant) + {:ok, tenant: tenant} + end + + describe "public message broadcasting" do + test "broadcasts multiple public messages successfully", %{tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + topic1 = random_string() + topic2 = random_string() + + messages = %{ + messages: [ + %{topic: topic1, payload: %{"data" => "test1"}, event: "event1"}, + %{topic: topic2, payload: %{"data" => "test2"}, event: "event2"}, + %{topic: topic1, payload: %{"data" => "test3"}, event: "event3"} + ] + } + + expect(GenCounter, :add, 3, fn ^broadcast_events_key -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, 3, fn _, _, _, _, _ -> :ok end) + + assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, false) + end + + test "public messages do not have private prefix in topic", %{tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + topic = random_string() + + messages = %{ + messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1"}] + } + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + + expect(TenantBroadcaster, :pubsub_broadcast, fn _, topic, _, _, _ -> + refute String.contains?(topic, "-private") + end) + + assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, false) + end + end + + describe "message ID metadata" do + test "includes message ID in metadata when provided", %{tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + topic = random_string() + + messages = %{ + messages: [%{id: "msg-123", topic: topic, payload: %{"data" => "test"}, event: "event1"}] + } + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + + expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, broadcast, _, _ -> + assert %Phoenix.Socket.Broadcast{ + payload: %{ + "payload" => %{"data" => "test"}, + "event" => "event1", + "type" => "broadcast", + "meta" => %{"id" => "msg-123"} + } + } = broadcast + end) + + assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, false) + end + end + + describe "super user broadcasting" do + test "bypasses authorization for private messages with super_user flag", %{tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + topic1 = random_string() + topic2 = random_string() + + messages = %{ + messages: [ + %{topic: topic1, payload: %{"data" => "test1"}, event: "event1", private: true}, + %{topic: topic2, payload: %{"data" => "test2"}, event: "event2", private: true} + ] + } + + expect(GenCounter, :add, 2, fn ^broadcast_events_key -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, 2, fn _, _, _, _, _ -> :ok end) + + assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, true) + end + + test "private messages have private prefix in topic", %{tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + topic = random_string() + + messages = %{ + messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1", private: true}] + } + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + + expect(TenantBroadcaster, :pubsub_broadcast, fn _, topic, _, _, _ -> + assert String.contains?(topic, "-private") + end) + + assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, true) + end + end + + describe "private message authorization" do + test "broadcasts private messages with valid authorization", %{tenant: tenant} do + topic = random_string() + sub = random_string() + role = "authenticated" + + auth_params = %{ + tenant_id: tenant.external_id, + topic: topic, + headers: [{"header-1", "value-1"}], + claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000}, + role: role, + sub: sub + } + + messages = %{messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1", private: true}]} + + broadcast_events_key = Tenants.events_per_second_key(tenant) + + expect(GenCounter, :add, 1, fn ^broadcast_events_key -> :ok end) + + Authorization + |> expect(:build_authorization_params, fn params -> params end) + |> expect(:get_write_authorizations, fn _, _ -> {:ok, %Policies{broadcast: %BroadcastPolicies{write: true}}} end) + + expect(TenantBroadcaster, :pubsub_broadcast, 1, fn _, _, _, _, _ -> :ok end) + + assert :ok = BatchBroadcast.broadcast(auth_params, tenant, messages, false) + end + + test "skips private messages without authorization", %{tenant: tenant} do + topic = random_string() + sub = random_string() + role = "anon" + + auth_params = %{ + tenant_id: tenant.external_id, + topic: topic, + headers: [{"header-1", "value-1"}], + claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000}, + role: role, + sub: sub + } + + Authorization + |> expect(:build_authorization_params, 1, fn params -> params end) + |> expect(:get_write_authorizations, 1, fn _, _ -> + {:ok, %Policies{broadcast: %BroadcastPolicies{write: false}}} + end) + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + messages = %{ + messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1", private: true}] + } + + assert :ok = BatchBroadcast.broadcast(auth_params, tenant, messages, false) + + assert calls(&TenantBroadcaster.pubsub_broadcast/5) == [] + end + + test "broadcasts only authorized topics in mixed authorization batch", %{tenant: tenant} do + topic = random_string() + sub = random_string() + role = "authenticated" + + auth_params = %{ + tenant_id: tenant.external_id, + headers: [{"header-1", "value-1"}], + claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000}, + role: role, + sub: sub + } + + messages = %{ + messages: [ + %{topic: topic, payload: %{"data" => "test1"}, event: "event1", private: true}, + %{topic: random_string(), payload: %{"data" => "test2"}, event: "event2", private: true} + ] + } + + broadcast_events_key = Tenants.events_per_second_key(tenant) + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + + Authorization + |> expect(:build_authorization_params, 2, fn params -> params end) + |> expect(:get_write_authorizations, 2, fn + _, %{topic: ^topic} -> %Policies{broadcast: %BroadcastPolicies{write: true}} + _, _ -> %Policies{broadcast: %BroadcastPolicies{write: false}} + end) + + # Only one topic will actually be broadcasted + expect(TenantBroadcaster, :pubsub_broadcast, 1, fn _, _, %Phoenix.Socket.Broadcast{topic: ^topic}, _, _ -> + :ok + end) + + assert :ok = BatchBroadcast.broadcast(auth_params, tenant, messages, false) + end + + test "groups messages by topic and checks authorization once per topic", %{tenant: tenant} do + topic_1 = random_string() + topic_2 = random_string() + sub = random_string() + role = "authenticated" + + auth_params = %{ + tenant_id: tenant.external_id, + headers: [{"header-1", "value-1"}], + claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000}, + role: role, + sub: sub + } + + messages = %{ + messages: [ + %{topic: topic_1, payload: %{"data" => "test1"}, event: "event1", private: true}, + %{topic: topic_2, payload: %{"data" => "test2"}, event: "event2", private: true}, + %{topic: topic_1, payload: %{"data" => "test3"}, event: "event3", private: true} + ] + } + + broadcast_events_key = Tenants.events_per_second_key(tenant) + + expect(GenCounter, :add, 3, fn ^broadcast_events_key -> :ok end) + + Authorization + |> expect(:build_authorization_params, 2, fn params -> params end) + |> expect(:get_write_authorizations, 2, fn _, _ -> + {:ok, %Policies{broadcast: %BroadcastPolicies{write: true}}} + end) + + expect(TenantBroadcaster, :pubsub_broadcast, 3, fn _, _, _, _, _ -> :ok end) + + assert :ok = BatchBroadcast.broadcast(auth_params, tenant, messages, false) + end + + test "handles missing auth params for private messages", %{tenant: tenant} do + events_per_second_rate = Tenants.events_per_second_rate(tenant) + + RateCounter + |> stub(:new, fn _ -> {:ok, nil} end) + |> stub(:get, fn ^events_per_second_rate -> {:ok, %RateCounter{avg: 0}} end) + + reject(&TenantBroadcaster.pubsub_broadcast/5) + reject(&Connect.lookup_or_start_connection/1) + + messages = %{ + messages: [%{topic: "topic1", payload: %{"data" => "test"}, event: "event1", private: true}] + } + + assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, false) + + assert calls(&TenantBroadcaster.pubsub_broadcast/5) == [] + end + end + + describe "mixed public and private messages" do + setup %{tenant: tenant} do + {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) + %{db_conn: db_conn} + end + + test "broadcasts both public and private messages together", %{tenant: tenant, db_conn: db_conn} do + topic = random_string() + sub = random_string() + role = "authenticated" + + create_rls_policies(db_conn, [:authenticated_write_broadcast], %{topic: topic}) + + auth_params = %{ + tenant_id: tenant.external_id, + topic: topic, + headers: [{"header-1", "value-1"}], + claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000}, + role: role, + sub: sub + } + + events_per_second_rate = Tenants.events_per_second_rate(tenant) + broadcast_events_key = Tenants.events_per_second_key(tenant) + + RateCounter + |> stub(:new, fn _ -> {:ok, nil} end) + |> stub(:get, fn + ^events_per_second_rate -> + {:ok, %RateCounter{avg: 0}} + + _ -> + {:ok, + %RateCounter{ + avg: 0, + limit: %{log: true, value: 10, measurement: :sum, triggered: false, log_fn: fn -> :ok end} + }} + end) + + expect(GenCounter, :add, 3, fn ^broadcast_events_key -> :ok end) + expect(Connect, :lookup_or_start_connection, fn _ -> {:ok, db_conn} end) + + Authorization + |> expect(:build_authorization_params, fn params -> params end) + |> expect(:get_write_authorizations, fn _, _ -> + {:ok, %Policies{broadcast: %BroadcastPolicies{write: true}}} + end) + + expect(TenantBroadcaster, :pubsub_broadcast, 3, fn _, _, _, _, _ -> :ok end) + + messages = %{ + messages: [ + %{topic: "public1", payload: %{"data" => "public"}, event: "event1", private: false}, + %{topic: topic, payload: %{"data" => "private"}, event: "event2", private: true}, + %{topic: "public2", payload: %{"data" => "public2"}, event: "event3"} + ] + } + + assert :ok = BatchBroadcast.broadcast(auth_params, tenant, messages, false) + + broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/5) + assert length(broadcast_calls) == 3 + end + end + + describe "Plug.Conn integration" do + test "accepts and converts Plug.Conn to auth params", %{tenant: tenant} do + topic = random_string() + broadcast_events_key = Tenants.events_per_second_key(tenant) + messages = %{messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1"}]} + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, 1, fn _, _, _, _, _ -> :ok end) + + conn = + build_conn() + |> Map.put(:assigns, %{ + claims: %{"sub" => "user123", "role" => "authenticated"}, + role: "authenticated", + sub: "user123" + }) + |> Map.put(:req_headers, [{"authorization", "Bearer token"}]) + + assert :ok = BatchBroadcast.broadcast(conn, tenant, messages, false) + end + end + + describe "message validation" do + test "returns changeset error when topic is missing", %{tenant: tenant} do + messages = %{messages: [%{payload: %{"data" => "test"}, event: "event1"}]} + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + result = BatchBroadcast.broadcast(nil, tenant, messages, false) + assert {:error, %Ecto.Changeset{valid?: false}} = result + end + + test "returns changeset error when payload is missing", %{tenant: tenant} do + topic = random_string() + messages = %{messages: [%{topic: topic, event: "event1"}]} + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + result = BatchBroadcast.broadcast(nil, tenant, messages, false) + assert {:error, %Ecto.Changeset{valid?: false}} = result + end + + test "returns changeset error when event is missing", %{tenant: tenant} do + topic = random_string() + messages = %{messages: [%{topic: topic, payload: %{"data" => "test"}}]} + + reject(&TenantBroadcaster.pubsub_broadcast/5) + result = BatchBroadcast.broadcast(nil, tenant, messages, false) + assert {:error, %Ecto.Changeset{valid?: false}} = result + end + + test "returns changeset error when messages array is empty", %{tenant: tenant} do + messages = %{messages: []} + reject(&TenantBroadcaster.pubsub_broadcast/5) + result = BatchBroadcast.broadcast(nil, tenant, messages, false) + assert {:error, %Ecto.Changeset{valid?: false}} = result + end + end + + describe "rate limiting" do + test "rejects broadcast when rate limit is exceeded", %{tenant: tenant} do + events_per_second_rate = Tenants.events_per_second_rate(tenant) + topic = random_string() + messages = %{messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1"}]} + + RateCounter + |> stub(:new, fn _ -> {:ok, nil} end) + |> stub(:get, fn ^events_per_second_rate -> {:ok, %RateCounter{avg: tenant.max_events_per_second + 1}} end) + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + result = BatchBroadcast.broadcast(nil, tenant, messages, false) + assert {:error, :too_many_requests, "You have exceeded your rate limit"} = result + end + + test "rejects broadcast when batch would exceed rate limit", %{tenant: tenant} do + events_per_second_rate = Tenants.events_per_second_rate(tenant) + + messages = %{ + messages: + Enum.map(1..10, fn _ -> + %{topic: random_string(), payload: %{"data" => "test"}, event: random_string()} + end) + } + + RateCounter + |> stub(:new, fn _ -> {:ok, nil} end) + |> stub(:get, fn ^events_per_second_rate -> + {:ok, %RateCounter{avg: tenant.max_events_per_second - 5}} + end) + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + result = BatchBroadcast.broadcast(nil, tenant, messages, false) + + assert {:error, :too_many_requests, "Too many messages to broadcast, please reduce the batch size"} = result + end + + test "allows broadcast at rate limit boundary", %{tenant: tenant} do + events_per_second_rate = Tenants.events_per_second_rate(tenant) + broadcast_events_key = Tenants.events_per_second_key(tenant) + current_rate = tenant.max_events_per_second - 2 + + messages = %{ + messages: [ + %{topic: random_string(), payload: %{"data" => "test1"}, event: "event1"}, + %{topic: random_string(), payload: %{"data" => "test2"}, event: "event2"} + ] + } + + RateCounter + |> stub(:new, fn _ -> {:ok, nil} end) + |> stub(:get, fn ^events_per_second_rate -> + {:ok, %RateCounter{avg: current_rate}} + end) + + expect(GenCounter, :add, 2, fn ^broadcast_events_key -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, 2, fn _, _, _, _, _ -> :ok end) + + assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, false) + end + + test "rejects broadcast when payload size exceeds tenant limit", %{tenant: tenant} do + messages = %{ + messages: [ + %{ + topic: random_string(), + payload: %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)}, + event: "event1" + } + ] + } + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + result = BatchBroadcast.broadcast(nil, tenant, messages, false) + + assert {:error, + %Ecto.Changeset{ + valid?: false, + changes: %{messages: [%{errors: [payload: {"Payload size exceeds tenant limit", []}]}]} + }} = result + end + end + + describe "error handling" do + test "returns error when tenant is nil" do + messages = %{messages: [%{topic: "topic1", payload: %{"data" => "test"}, event: "event1"}]} + assert {:error, :tenant_not_found} = BatchBroadcast.broadcast(nil, nil, messages, false) + end + + test "does not broadcast when tenant is suspended", %{tenant: tenant} do + tenant = %{tenant | suspend: true} + messages = %{messages: [%{topic: "topic1", payload: %{"data" => "test"}, event: "event1"}]} + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + assert {:error, :forbidden, "Tenant is suspended"} = BatchBroadcast.broadcast(nil, tenant, messages, false) + assert calls(&TenantBroadcaster.pubsub_broadcast/5) == [] + end + + test "gracefully handles database connection errors for private messages", %{tenant: tenant} do + topic = random_string() + sub = random_string() + role = "authenticated" + + auth_params = %{ + tenant_id: tenant.external_id, + headers: [{"header-1", "value-1"}], + claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000}, + role: role, + sub: sub + } + + events_per_second_rate = Tenants.events_per_second_rate(tenant) + + RateCounter + |> stub(:new, fn _ -> {:ok, nil} end) + |> stub(:get, fn ^events_per_second_rate -> {:ok, %RateCounter{avg: 0}} end) + + expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :connection_failed} end) + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + messages = %{ + messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1", private: true}] + } + + assert :ok = BatchBroadcast.broadcast(auth_params, tenant, messages, false) + + assert calls(&TenantBroadcaster.pubsub_broadcast/5) == [] + end + end +end diff --git a/test/realtime/tenants/cache_test.exs b/test/realtime/tenants/cache_test.exs index 1889c94ef..087e05f9f 100644 --- a/test/realtime/tenants/cache_test.exs +++ b/test/realtime/tenants/cache_test.exs @@ -1,24 +1,59 @@ defmodule Realtime.Tenants.CacheTest do - alias Realtime.Rpc - # async: false due to the usage of dev_realtime tenant use Realtime.DataCase, async: false alias Realtime.Api - alias Realtime.Tenants.Cache + alias Realtime.Rpc alias Realtime.Tenants + alias Realtime.Tenants.Cache setup do {:ok, tenant: tenant_fixture()} end + describe "fetch_tenant_by_external_id/1" do + test "returns {:ok, tenant} when tenant exists", %{tenant: tenant} do + assert {:ok, %Api.Tenant{external_id: external_id}} = + Cache.fetch_tenant_by_external_id(tenant.external_id) + + assert external_id == tenant.external_id + end + + test "returns cached result on subsequent calls", %{tenant: tenant} do + external_id = tenant.external_id + assert {:ok, %Api.Tenant{name: "tenant"}} = Cache.fetch_tenant_by_external_id(external_id) + + changeset = Api.Tenant.changeset(tenant, %{name: "new name"}) + Repo.update!(changeset) + + assert {:ok, %Api.Tenant{name: "tenant"}} = Cache.fetch_tenant_by_external_id(external_id) + end + + test "returns {:error, :tenant_not_found} when tenant does not exist" do + assert {:error, :tenant_not_found} = Cache.fetch_tenant_by_external_id("nonexistent-id") + end + + test "does not cache when tenant is not found" do + Cache.fetch_tenant_by_external_id("nonexistent-id") + assert {:ok, false} = Cachex.exists?(Cache, {:get_tenant_by_external_id, "nonexistent-id"}) + end + end + describe "get_tenant_by_external_id/1" do test "tenants cache returns a cached result", %{tenant: tenant} do external_id = tenant.external_id assert %Api.Tenant{name: "tenant"} = Cache.get_tenant_by_external_id(external_id) - Api.update_tenant(tenant, %{name: "new name"}) + + changeset = Api.Tenant.changeset(tenant, %{name: "new name"}) + Repo.update!(changeset) assert %Api.Tenant{name: "new name"} = Tenants.get_tenant_by_external_id(external_id) assert %Api.Tenant{name: "tenant"} = Cache.get_tenant_by_external_id(external_id) end + + test "does not cache when tenant is not found" do + assert Cache.get_tenant_by_external_id("not found") == nil + + assert Cachex.exists?(Cache, {:get_tenant_by_external_id, "not found"}) == {:ok, false} + end end describe "invalidate_tenant_cache/1" do @@ -38,43 +73,130 @@ defmodule Realtime.Tenants.CacheTest do end end + describe "update_cache/1" do + test "updates the cache given a tenant", %{tenant: tenant} do + external_id = tenant.external_id + assert %Api.Tenant{name: "tenant"} = Cache.get_tenant_by_external_id(external_id) + # Update a tenant + updated_tenant = %{tenant | name: "updated name"} + # Update cache + Cache.update_cache(updated_tenant) + assert %Api.Tenant{name: "updated name"} = Cache.get_tenant_by_external_id(external_id) + end + end + describe "distributed_invalidate_tenant_cache/1" do setup do {:ok, node} = Clustered.start() - %{node: node} + + tenant = + Ecto.Adapters.SQL.Sandbox.unboxed_run(Realtime.Repo, fn -> + tenant_fixture() + end) + + on_exit(fn -> + Ecto.Adapters.SQL.Sandbox.unboxed_run(Realtime.Repo, fn -> + Realtime.Api.delete_tenant_by_external_id(tenant.external_id) + end) + end) + + %{node: node, tenant: tenant} end - test "invalidates the cache given a tenant_id", %{node: node} do - external_id = "dev_tenant" - %Api.Tenant{name: expected_name} = tenant = Tenants.get_tenant_by_external_id(external_id) + test "invalidates the cache given a tenant_id", %{node: node, tenant: tenant} do + external_id = tenant.external_id + expected_name = tenant.name + dummy_name = random_string() + dummy_tenant = %{tenant | name: dummy_name} + + assert {:ok, true} = Cache.update_cache(dummy_tenant) + + assert {:ok, %Api.Tenant{name: ^dummy_name}} = + Cachex.get(Cache, {:get_tenant_by_external_id, external_id}) + + seed_remote_cache(node, external_id, dummy_tenant) + + assert :ok = Cache.distributed_invalidate_tenant_cache(external_id) + + assert_eventually(fn -> + %Api.Tenant{name: ^expected_name} = Cache.get_tenant_by_external_id(external_id) + + %Api.Tenant{name: ^expected_name} = + Rpc.enhanced_call(node, Cache, :get_tenant_by_external_id, [external_id]) + end) + end + end + + describe "global_cache_update/1" do + setup do + {:ok, node} = Clustered.start() + + tenant = + Ecto.Adapters.SQL.Sandbox.unboxed_run(Realtime.Repo, fn -> + tenant_fixture() + end) + + on_exit(fn -> + Ecto.Adapters.SQL.Sandbox.unboxed_run(Realtime.Repo, fn -> + Realtime.Api.delete_tenant_by_external_id(tenant.external_id) + end) + end) + + %{node: node, tenant: tenant} + end + test "update the cache given a tenant_id", %{node: node, tenant: tenant} do + external_id = tenant.external_id + expected_name = tenant.name dummy_name = random_string() + dummy_tenant = %{tenant | name: dummy_name} - # Ensure cache has the values - Cachex.put!( - Realtime.Tenants.Cache, - {{:get_tenant_by_external_id, 1}, [external_id]}, - {:cached, %{tenant | name: dummy_name}} - ) + assert {:ok, true} = Cache.update_cache(dummy_tenant) - Rpc.enhanced_call(node, Cachex, :put!, [ - Realtime.Tenants.Cache, - {{:get_tenant_by_external_id, 1}, [external_id]}, - {:cached, %{tenant | name: dummy_name}} - ]) + assert {:ok, %Api.Tenant{name: ^dummy_name}} = + Cachex.get(Cache, {:get_tenant_by_external_id, external_id}) - # Cache showing old value - assert %Api.Tenant{name: ^dummy_name} = Cache.get_tenant_by_external_id(external_id) - assert %Api.Tenant{name: ^dummy_name} = Rpc.enhanced_call(node, Cache, :get_tenant_by_external_id, [external_id]) + seed_remote_cache(node, external_id, dummy_tenant) - # Invalidate cache - assert true = Cache.distributed_invalidate_tenant_cache(external_id) + assert :ok = Cache.global_cache_update(tenant) - # Cache showing new value - assert %Api.Tenant{name: ^expected_name} = Cache.get_tenant_by_external_id(external_id) + assert_eventually(fn -> + {:ok, %Api.Tenant{name: ^expected_name}} = + Cachex.get(Cache, {:get_tenant_by_external_id, external_id}) - assert %Api.Tenant{name: ^expected_name} = - Rpc.enhanced_call(node, Cache, :get_tenant_by_external_id, [external_id]) + {:ok, %Api.Tenant{name: ^expected_name}} = + Rpc.enhanced_call(node, Cachex, :get, [Cache, {:get_tenant_by_external_id, external_id}]) + end) end end + + defp seed_remote_cache(node, external_id, tenant, attempts \\ 20) do + Rpc.enhanced_call(node, Cache, :update_cache, [tenant]) + + case Rpc.enhanced_call(node, Cachex, :get, [Cache, {:get_tenant_by_external_id, external_id}]) do + {:ok, %Api.Tenant{external_id: ^external_id, name: name}} when name == tenant.name -> + :ok + + _other when attempts > 0 -> + Process.sleep(50) + seed_remote_cache(node, external_id, tenant, attempts - 1) + + other -> + flunk("Failed to seed remote cache after retries, last result: #{inspect(other)}") + end + end + + defp assert_eventually(fun, attempts \\ 50, interval \\ 100) + + defp assert_eventually(fun, 0, _interval) do + fun.() + end + + defp assert_eventually(fun, attempts, interval) do + fun.() + rescue + _ -> + Process.sleep(interval) + assert_eventually(fun, attempts - 1, interval) + end end diff --git a/test/realtime/tenants/connect/get_tenant_test.exs b/test/realtime/tenants/connect/get_tenant_test.exs new file mode 100644 index 000000000..588e838f7 --- /dev/null +++ b/test/realtime/tenants/connect/get_tenant_test.exs @@ -0,0 +1,16 @@ +defmodule Realtime.Tenants.Connect.GetTenantTest do + use Realtime.DataCase, async: true + + alias Realtime.Tenants.Connect.GetTenant + + describe "run/1" do + test "returns tenant when found" do + tenant = Containers.checkout_tenant() + assert {:ok, %{tenant: %Realtime.Api.Tenant{}}} = GetTenant.run(%{tenant_id: tenant.external_id}) + end + + test "returns error when tenant not found" do + assert {:error, :tenant_not_found} = GetTenant.run(%{tenant_id: "nonexistent_tenant_id"}) + end + end +end diff --git a/test/realtime/tenants/connect/reconcile_migrations_test.exs b/test/realtime/tenants/connect/reconcile_migrations_test.exs new file mode 100644 index 000000000..04944db2c --- /dev/null +++ b/test/realtime/tenants/connect/reconcile_migrations_test.exs @@ -0,0 +1,46 @@ +defmodule Realtime.Tenants.Connect.ReconcileMigrationsTest do + use Realtime.DataCase, async: true + + alias Realtime.Api + alias Realtime.Tenants.Connect.ReconcileMigrations + alias Realtime.Tenants.Migrations + + setup do + tenant = Containers.checkout_tenant(run_migrations: true) + %{tenant: tenant} + end + + describe "run/1" do + test "does nothing when migrations_ran matches database count", %{tenant: tenant} do + acc = %{tenant: tenant, migrations_ran_on_database: tenant.migrations_ran} + + assert {:ok, %{tenant: returned_tenant}} = ReconcileMigrations.run(acc) + assert returned_tenant.migrations_ran == tenant.migrations_ran + end + + test "updates tenant when database has fewer migrations than cached count", %{tenant: tenant} do + stale_count = tenant.migrations_ran - 5 + acc = %{tenant: tenant, migrations_ran_on_database: stale_count} + + assert {:ok, %{tenant: updated_tenant}} = ReconcileMigrations.run(acc) + assert updated_tenant.migrations_ran == stale_count + end + + test "updates tenant when database has more migrations than cached count", %{tenant: tenant} do + {:ok, tenant} = + Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{migrations_ran: 0}) + + total = Enum.count(Migrations.migrations()) + acc = %{tenant: tenant, migrations_ran_on_database: total} + + assert {:ok, %{tenant: updated_tenant}} = ReconcileMigrations.run(acc) + assert updated_tenant.migrations_ran == total + end + + test "returns :tenant_not_found when tenant has been removed", %{tenant: tenant} do + assert Api.delete_tenant_by_external_id(tenant.external_id) + acc = %{tenant: tenant, migrations_ran_on_database: 11} + assert {:error, :tenant_not_found} = ReconcileMigrations.run(acc) + end + end +end diff --git a/test/realtime/tenants/connect/register_process_test.exs b/test/realtime/tenants/connect/register_process_test.exs index d4227996f..02cc33391 100644 --- a/test/realtime/tenants/connect/register_process_test.exs +++ b/test/realtime/tenants/connect/register_process_test.exs @@ -7,7 +7,7 @@ defmodule Realtime.Tenants.Connect.RegisterProcessTest do setup do tenant = Containers.checkout_tenant(run_migrations: true) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) {:ok, conn} = Database.connect(tenant, "realtime_test") %{tenant_id: tenant.external_id, db_conn_pid: conn} end diff --git a/test/realtime/tenants/connect_test.exs b/test/realtime/tenants/connect_test.exs index 290fb1c8d..0b12ff9bd 100644 --- a/test/realtime/tenants/connect_test.exs +++ b/test/realtime/tenants/connect_test.exs @@ -50,7 +50,50 @@ defmodule Realtime.Tenants.ConnectTest do end end + describe "list_tenants/0" do + test "lists all tenants with active connections", %{tenant: tenant1} do + tenant2 = Containers.checkout_tenant(run_migrations: true) + assert {:ok, _} = Connect.lookup_or_start_connection(tenant1.external_id) + assert {:ok, _} = Connect.lookup_or_start_connection(tenant2.external_id) + + list_tenants = Connect.list_tenants() |> MapSet.new() + tenants = MapSet.new([tenant1.external_id, tenant2.external_id]) + + assert MapSet.subset?(tenants, list_tenants) + end + end + describe "handle cold start" do + test "multiple processes connecting calling Connect.connect", %{tenant: tenant} do + parent = self() + + # Let's slow down Connect.connect so that multiple RPC calls are executed + stub(Connect, :connect, fn x, y, z -> + :timer.sleep(1000) + call_original(Connect, :connect, [x, y, z]) + end) + + connect = fn -> send(parent, Connect.lookup_or_start_connection(tenant.external_id)) end + # Let's call enough times to potentially trigger the Connect RateCounter + + for _ <- 1..50, do: spawn(connect) + + assert_receive({:ok, pid}, 2000) + + for _ <- 1..49, do: assert_receive({:ok, ^pid}) + + # Does not trigger rate limit as connections eventually succeeded + + {:ok, rate_counter} = + tenant.external_id + |> Tenants.connect_errors_per_second_rate() + |> Realtime.RateCounter.get() + + assert rate_counter.sum == 0 + assert rate_counter.avg == 0.0 + assert rate_counter.limit.triggered == false + end + test "multiple proccesses succeed together", %{tenant: tenant} do parent = self() @@ -78,12 +121,55 @@ defmodule Realtime.Tenants.ConnectTest do assert_receive {:ok, ^pid} end - test "more than 5 seconds passed error out", %{tenant: tenant} do + test "more than 15 seconds passed error out", %{tenant: tenant} do parent = self() # Let's slow down Connect starting expect(Database, :check_tenant_connection, fn t -> - :timer.sleep(5500) + Process.sleep(15500) + call_original(Database, :check_tenant_connection, [t]) + end) + + connect = fn -> send(parent, Connect.lookup_or_start_connection(tenant.external_id)) end + + spawn(connect) + spawn(connect) + + {:error, :initializing} = Connect.lookup_or_start_connection(tenant.external_id) + # The above call waited 15 seconds + assert_receive {:error, :initializing} + assert_receive {:error, :initializing} + + # This one will succeed + {:ok, _pid} = Connect.lookup_or_start_connection(tenant.external_id) + end + + test "too many db connections", %{tenant: tenant} do + extension = %{ + "type" => "postgres_cdc_rls", + "settings" => %{ + "db_host" => "127.0.0.1", + "db_name" => "postgres", + "db_user" => "supabase_realtime_admin", + "db_password" => "postgres", + "poll_interval" => 100, + "poll_max_changes" => 100, + "poll_max_record_bytes" => 1_048_576, + "region" => "us-east-1", + "ssl_enforced" => false, + "db_pool" => 100, + "subcriber_pool_size" => 100, + "subs_pool_size" => 100 + } + } + + {:ok, tenant} = update_extension(tenant, extension) + + parent = self() + + # Let's slow down Connect starting + expect(Database, :check_tenant_connection, fn t -> + :timer.sleep(1000) call_original(Database, :check_tenant_connection, [t]) end) @@ -97,12 +183,13 @@ defmodule Realtime.Tenants.ConnectTest do spawn(connect) spawn(connect) - {:error, :tenant_database_unavailable} = Connect.lookup_or_start_connection(tenant.external_id) + # This one should block and wait for the first Connect + {:error, :tenant_db_too_many_connections} = Connect.lookup_or_start_connection(tenant.external_id) - # Only one will succeed the others timed out waiting - assert_receive {:error, :tenant_database_unavailable} - assert_receive {:error, :tenant_database_unavailable} - assert_receive {:ok, _pid}, 7000 + assert_receive {:error, :tenant_db_too_many_connections} + assert_receive {:error, :tenant_db_too_many_connections} + assert_receive {:error, :tenant_db_too_many_connections} + refute_receive _any end end @@ -113,11 +200,9 @@ defmodule Realtime.Tenants.ConnectTest do log = capture_log(fn -> assert {:ok, db_conn} = Connect.lookup_or_start_connection(external_id, check_connect_region_interval: 100) - expect(Rebalancer, :check, 1, fn _, _, ^external_id -> {:error, :wrong_region} end) reject(&Rebalancer.check/3) - - assert_process_down(db_conn, 500, {:shutdown, :rebalancing}) + assert_process_down(db_conn, 1000, {:shutdown, :rebalancing}) end) assert log =~ "Rebalancing Tenant database connection" @@ -253,10 +338,9 @@ defmodule Realtime.Tenants.ConnectTest do {:ok, db_conn} = Connect.lookup_or_start_connection(external_id, check_connected_user_interval: 10) region = Tenants.region(tenant) assert {_pid, %{conn: ^db_conn, region: ^region}} = :syn.lookup(Connect, external_id) + Forum.Census.leave(:users, external_id, self()) Process.sleep(1000) - :syn.leave(:users, external_id, self()) - Process.sleep(1000) - assert :undefined = :syn.lookup(Connect, external_id) + refute Forum.Census.local_member?(:users, external_id, self()) refute Process.alive?(db_conn) Connect.shutdown(external_id) end @@ -267,37 +351,32 @@ defmodule Realtime.Tenants.ConnectTest do assert {:error, :tenant_suspended} = Connect.lookup_or_start_connection(tenant.external_id) end - test "handles tenant suspension and unsuspension in a reactive way", %{tenant: tenant} do - assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) - assert Connect.ready?(tenant.external_id) - - Realtime.Tenants.suspend_tenant_by_external_id(tenant.external_id) - assert_process_down(db_conn) - # Wait for syn to unregister and Cachex to be invalided - Process.sleep(500) - - assert {:error, :tenant_suspended} = Connect.lookup_or_start_connection(tenant.external_id) - refute Process.alive?(db_conn) - - Realtime.Tenants.unsuspend_tenant_by_external_id(tenant.external_id) - Process.sleep(50) - assert {:ok, _} = Connect.lookup_or_start_connection(tenant.external_id) - Connect.shutdown(tenant.external_id) - end - - test "handles tenant suspension only on targetted suspended user", %{tenant: tenant1} do - tenant2 = Containers.checkout_tenant(run_migrations: true) - - assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant1.external_id) + test "tenant not able to connect if database has not enough connections", %{ + tenant: tenant + } do + extension = %{ + "type" => "postgres_cdc_rls", + "settings" => %{ + "db_host" => "127.0.0.1", + "db_name" => "postgres", + "db_user" => "supabase_realtime_admin", + "db_password" => "postgres", + "poll_interval" => 100, + "poll_max_changes" => 100, + "poll_max_record_bytes" => 1_048_576, + "region" => "us-east-1", + "ssl_enforced" => false, + "db_pool" => 100, + "subcriber_pool_size" => 100, + "subs_pool_size" => 100 + } + } - log = - capture_log(fn -> - Realtime.Tenants.suspend_tenant_by_external_id(tenant2.external_id) - Process.sleep(50) - end) + {:ok, tenant} = update_extension(tenant, extension) - refute log =~ "Tenant was suspended" - assert Process.alive?(db_conn) + assert capture_log(fn -> + assert {:error, :tenant_db_too_many_connections} = Connect.lookup_or_start_connection(tenant.external_id) + end) =~ ~r/Only \d+ available connections\. At least \d+ connections are required/ end test "properly handles of failing calls by avoid creating too many connections", %{tenant: tenant} do @@ -306,7 +385,7 @@ defmodule Realtime.Tenants.ConnectTest do "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => "supabase_realtime_admin", "db_password" => "postgres", "poll_interval" => 100, "poll_max_changes" => 100, @@ -338,61 +417,137 @@ defmodule Realtime.Tenants.ConnectTest do refute Process.alive?(pid) end + test "reconciles migrations_ran when database count differs from cached value", %{tenant: tenant} do + total_migrations = Enum.count(Realtime.Tenants.Migrations.migrations()) + stale_count = tenant.migrations_ran - 5 + parent = self() + + expect(Database, :check_tenant_connection, fn t -> + {:ok, conn, _actual_count} = call_original(Database, :check_tenant_connection, [t]) + {:ok, conn, stale_count} + end) + + expect(Realtime.Tenants.Migrations, :run_migrations, fn tenant -> + send(parent, {:migrations_ran_at_run, tenant.migrations_ran}) + call_original(Realtime.Tenants.Migrations, :run_migrations, [tenant]) + end) + + assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + assert Connect.ready?(tenant.external_id) + + assert_receive {:migrations_ran_at_run, ^stale_count} + + updated_tenant = Tenants.get_tenant_by_external_id(tenant.external_id) + assert updated_tenant.migrations_ran == total_migrations + end + test "starts broadcast handler and does not fail on existing connection", %{tenant: tenant} do assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id) assert Connect.ready?(tenant.external_id) - replication_connection_before = ReplicationConnection.whereis(tenant.external_id) + replication_connection_before = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end) assert Process.alive?(replication_connection_before) + assert {:ok, replication_conn_pid_before} = assert_replication_status(tenant.external_id) + assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id) - replication_connection_after = ReplicationConnection.whereis(tenant.external_id) + replication_connection_after = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end) assert Process.alive?(replication_connection_after) assert replication_connection_before == replication_connection_after + + assert {:ok, replication_conn_pid_after} = assert_replication_status(tenant.external_id) + assert replication_conn_pid_before == replication_conn_pid_after end - test "on replication connection postgres pid being stopped, also kills the Connect module", %{tenant: tenant} do + test "on replication connection postgres pid being stopped, Connect module recovers it", %{tenant: tenant} do assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) assert Connect.ready?(tenant.external_id) - replication_connection_pid = ReplicationConnection.whereis(tenant.external_id) + replication_connection_pid = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end) + Process.monitor(replication_connection_pid) + assert Process.alive?(replication_connection_pid) pid = Connect.whereis(tenant.external_id) + assert {:ok, replication_conn_before} = assert_replication_status(tenant.external_id) + Postgrex.query!( db_conn, "SELECT pg_terminate_backend(pid) from pg_stat_activity where application_name='realtime_replication_connection'", [] ) - assert_process_down(replication_connection_pid) - assert_process_down(pid) + assert_receive {:DOWN, _, :process, ^replication_connection_pid, _} + + Process.sleep(100) + assert {:error, :not_connected} = Connect.replication_status(tenant.external_id) + + new_replication_connection_pid = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end, 60) + + assert replication_connection_pid != new_replication_connection_pid + assert Process.alive?(new_replication_connection_pid) + assert Process.alive?(pid) + + assert {:ok, replication_conn_after} = assert_replication_status(tenant.external_id, 60) + assert replication_conn_before != replication_conn_after end - test "on replication connection exit, also kills the Connect module", %{tenant: tenant} do + test "on replication connection exit, Connect module recovers it", %{tenant: tenant} do assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id) assert Connect.ready?(tenant.external_id) - replication_connection_pid = ReplicationConnection.whereis(tenant.external_id) + replication_connection_pid = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end) + Process.monitor(replication_connection_pid) assert Process.alive?(replication_connection_pid) pid = Connect.whereis(tenant.external_id) + + assert {:ok, replication_conn_before} = assert_replication_status(tenant.external_id) + Process.exit(replication_connection_pid, :kill) + assert_receive {:DOWN, _, :process, ^replication_connection_pid, _} - assert_process_down(replication_connection_pid) - assert_process_down(pid) + Process.sleep(1000) + assert {:error, :not_connected} = Connect.replication_status(tenant.external_id) + + new_replication_connection_pid = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end) + + assert replication_connection_pid != new_replication_connection_pid + assert Process.alive?(new_replication_connection_pid) + assert Process.alive?(pid) + + assert {:ok, replication_conn_after} = assert_replication_status(tenant.external_id, 60) + assert replication_conn_before != replication_conn_after + end + + test "defers and keeps the tenant alive when replication connection times out", %{tenant: tenant} do + expect(ReplicationConnection, :start, fn _tenant, _pid -> + {:error, :replication_connection_timeout} + end) + + log = + capture_log(fn -> + assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + pid = Connect.whereis(tenant.external_id) + assert wait_until(fn -> :sys.get_state(pid).replication_recovery_started_at != nil end) + refute_process_down(db_conn) + end) + + assert log =~ "ReplicationConnectionTimeout" + assert log =~ "Replication connection timed out during initialization" end test "handles max_wal_senders by logging the correct operational code", %{tenant: tenant} do - opts = tenant |> Database.from_tenant("realtime_test", :stop) |> Database.opts() + {:ok, settings} = Database.from_tenant(tenant, "realtime_test", :stop) + opts = Database.opts(settings) + parent = self() - # This creates a loop of errors that occupies all WAL senders and lets us test the error handling pids = for i <- 0..4 do replication_slot_opts = %PostgresReplication{ connection_opts: opts, - table: :all, + table: "test", output_plugin: "pgoutput", output_plugin_options: [proto_version: "1", publication_names: "test_#{i}_publication"], handler_module: Replication.TestHandler, @@ -402,6 +557,7 @@ defmodule Realtime.Tenants.ConnectTest do spawn(fn -> {:ok, pid} = PostgresReplication.start_link(replication_slot_opts) + send(parent, {:replication_ready, i}) receive do :stop -> Process.exit(pid, :kill) @@ -409,6 +565,8 @@ defmodule Realtime.Tenants.ConnectTest do end) end + for i <- 0..4, do: assert_receive({:replication_ready, ^i}, 5000) + on_exit(fn -> Enum.each(pids, &send(&1, :stop)) Process.sleep(2000) @@ -417,7 +575,9 @@ defmodule Realtime.Tenants.ConnectTest do log = capture_log(fn -> assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) - assert_process_down(db_conn) + pid = Connect.whereis(tenant.external_id) + assert wait_until(fn -> :sys.get_state(pid).replication_recovery_started_at != nil end) + refute_process_down(db_conn) end) assert log =~ "ReplicationMaxWalSendersReached" @@ -429,34 +589,62 @@ defmodule Realtime.Tenants.ConnectTest do assert capture_log(fn -> assert {:error, :rpc_error, _} = Connect.lookup_or_start_connection("tenant") end) =~ "project=tenant external_id=tenant [error] ErrorOnRpcCall" end - end - describe "shutdown/1" do - test "shutdowns all associated connections", %{tenant: tenant} do - assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) - assert Process.alive?(db_conn) - assert Connect.ready?(tenant.external_id) - connect_pid = Connect.whereis(tenant.external_id) - replication_connection_pid = ReplicationConnection.whereis(tenant.external_id) - assert Process.alive?(connect_pid) - assert Process.alive?(replication_connection_pid) + test "rate limit connect when too many connections against bad database", %{tenant: tenant} do + extension = %{ + "type" => "postgres_cdc_rls", + "settings" => %{ + "db_host" => "127.0.0.1", + "db_name" => "postgres", + "db_user" => "supabase_realtime_admin", + "db_password" => "postgres", + "poll_interval" => 100, + "poll_max_changes" => 100, + "poll_max_record_bytes" => 1_048_576, + "region" => "us-east-1", + "ssl_enforced" => true + } + } - Connect.shutdown(tenant.external_id) - assert_process_down(connect_pid) - assert_process_down(replication_connection_pid) + {:ok, tenant} = update_extension(tenant, extension) + + log = + capture_log(fn -> + res = + for _ <- 1..10 do + Process.sleep(250) + Connect.lookup_or_start_connection(tenant.external_id) + end + + assert Enum.any?(res, fn {_, res} -> res == :connect_rate_limit_reached end) + end) + + assert log =~ "DatabaseConnectionRateLimitReached: Too many connection attempts against the tenant database" end - test "if tenant does not exist, does nothing" do - assert :ok = Connect.shutdown("none") + test "rate limit connect will not trigger if connection is successful", %{tenant: tenant} do + log = + capture_log(fn -> + res = + for _ <- 1..20 do + Process.sleep(500) + Connect.lookup_or_start_connection(tenant.external_id) + end + + refute Enum.any?(res, fn {_, res} -> res == :tenant_db_too_many_connections end) + end) + + refute log =~ "DatabaseConnectionRateLimitReached: Too many connection attempts against the tenant database" end - test "tenant not able to connect if database has not enough connections", %{tenant: tenant} do + test "rate limit connect does not trigger for non-connection-attempt errors like db pool exhaustion", + %{tenant: tenant} do extension = %{ "type" => "postgres_cdc_rls", "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => "supabase_realtime_admin", "db_password" => "postgres", "poll_interval" => 100, "poll_max_changes" => 100, @@ -470,8 +658,206 @@ defmodule Realtime.Tenants.ConnectTest do } {:ok, tenant} = update_extension(tenant, extension) + parent = self() + + expect(Database, :check_tenant_connection, fn t -> + :timer.sleep(1000) + call_original(Database, :check_tenant_connection, [t]) + end) + + connect = fn -> send(parent, Connect.lookup_or_start_connection(tenant.external_id)) end + + spawn(connect) + :timer.sleep(100) + spawn(connect) + spawn(connect) + + assert {:error, :tenant_db_too_many_connections} = + Connect.lookup_or_start_connection(tenant.external_id) + + assert_receive {:error, :tenant_db_too_many_connections} + assert_receive {:error, :tenant_db_too_many_connections} + assert_receive {:error, :tenant_db_too_many_connections} + refute_receive _any - assert {:error, :tenant_db_too_many_connections} = Connect.lookup_or_start_connection(tenant.external_id) + # Only 1 call_external_node failure should count toward the rate limit. + rate_args = Tenants.connect_errors_per_second_rate(tenant.external_id) + assert Realtime.GenCounter.get(rate_args.id) == 1 + end + end + + describe "shutdown/1" do + test "shutdowns all associated connections", %{tenant: tenant} do + assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + assert Process.alive?(db_conn) + assert Connect.ready?(tenant.external_id) + connect_pid = Connect.whereis(tenant.external_id) + replication_connection_pid = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end) + assert Process.alive?(connect_pid) + assert Process.alive?(replication_connection_pid) + + assert {_, %{conn: ^db_conn}} = :syn.lookup(Connect, tenant.external_id) + assert {:ok, _replication_conn_pid} = assert_replication_status(tenant.external_id) + + Connect.shutdown(tenant.external_id) + assert_process_down(connect_pid) + assert_process_down(replication_connection_pid) + end + + test "if tenant does not exist, does nothing" do + assert :ok = Connect.shutdown("none") + end + end + + describe "backoff configuration" do + test "backoff is configured with correct min/max/type values", %{tenant: tenant} do + assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + pid = Connect.whereis(tenant.external_id) + state = :sys.get_state(pid) + assert state.backoff.min == :timer.seconds(5) + assert state.backoff.max == :timer.minutes(5) + assert state.backoff.type == :rand_exp + end + end + + describe "replication recovery" do + test "recovery reschedules without stopping when pg_stat_activity shows existing walsender", %{tenant: tenant} do + assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + assert Connect.ready?(tenant.external_id) + + pid = Connect.whereis(tenant.external_id) + + # The real replication connection is active, so pg_stat_activity returns num_rows: 1 naturally + send(pid, :recover_replication_connection) + Process.sleep(100) + + assert Process.alive?(pid) + end + + test "recovery stops when elapsed time exceeds 2-hour window", %{tenant: tenant} do + assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + assert Connect.ready?(tenant.external_id) + # Replication starts asynchronously; wait for it to settle so the async result handler + # doesn't clobber the state we inject below. + assert {:ok, _} = assert_replication_status(tenant.external_id) + + pid = Connect.whereis(tenant.external_id) + ref = Process.monitor(pid) + + past_ts = System.monotonic_time(:millisecond) - :timer.hours(3) + :sys.replace_state(pid, fn state -> %{state | replication_recovery_started_at: past_ts} end) + + log = + capture_log(fn -> + send(pid, :recover_replication_connection) + assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, 1000 + end) + + assert log =~ "Replication recovery window exceeded" + end + + test "recovery preserves replication_recovery_started_at across multiple crashes", %{tenant: tenant} do + assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + assert Connect.ready?(tenant.external_id) + # Replication starts asynchronously; wait for it to settle so the async result handler + # doesn't clobber the state we inject below. + assert {:ok, _} = assert_replication_status(tenant.external_id) + + pid = Connect.whereis(tenant.external_id) + original_ts = System.monotonic_time(:millisecond) - 1000 + + ref = make_ref() + + :sys.replace_state(pid, fn state -> + %{ + state + | replication_connection_reference: ref, + replication_connection_pid: self(), + replication_recovery_started_at: original_ts + } + end) + + send(pid, {:DOWN, ref, :process, self(), :simulated_crash}) + Process.sleep(100) + + state = :sys.get_state(pid) + assert state.replication_recovery_started_at == original_ts + + Connect.shutdown(tenant.external_id) + end + + test "recovery resets replication_recovery_started_at on successful reconnection", %{tenant: tenant} do + assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + assert Connect.ready?(tenant.external_id) + + pid = Connect.whereis(tenant.external_id) + + replication_pid = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end) + Process.monitor(replication_pid) + Process.exit(replication_pid, :kill) + assert_receive {:DOWN, _, :process, ^replication_pid, _}, 1000 + + Process.sleep(100) + assert {:error, :not_connected} = Connect.replication_status(tenant.external_id) + + assert {:ok, _} = assert_replication_status(tenant.external_id) + + state = :sys.get_state(pid) + assert state.replication_recovery_started_at == nil + assert Process.alive?(pid) + + Connect.shutdown(tenant.external_id) + end + + test "defers and recovers instead of terminating when slot is in use at startup", %{tenant: tenant} do + {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) + slot_name = ReplicationConnection.replication_slot_name("realtime", "messages") + + # Simulate a previous replication session still holding the slot during a + # restart/rebalance race so the initial replication start fails. + Postgrex.query!(db_conn, "SELECT pg_create_logical_replication_slot($1, 'test_decoding')", [slot_name]) + + log = + capture_log(fn -> + assert {:ok, _} = Connect.lookup_or_start_connection(tenant.external_id) + pid = Connect.whereis(tenant.external_id) + assert wait_until(fn -> :sys.get_state(pid).replication_recovery_started_at != nil end) + end) + + pid = Connect.whereis(tenant.external_id) + assert is_pid(pid) + assert log =~ "StartReplicationFailed" + + # Connect stays alive with the recovery window open instead of shutting down. + refute_process_down(pid) + state = :sys.get_state(pid) + assert state.replication_connection_pid == nil + assert state.replication_recovery_started_at != nil + + # Free the slot; the scheduled retry should reconnect on its own and clear + # the recovery window. + Postgrex.query!(db_conn, "SELECT pg_drop_replication_slot($1)", [slot_name]) + + assert {:ok, _} = assert_replication_status(tenant.external_id) + assert :sys.get_state(pid).replication_recovery_started_at == nil + + Connect.shutdown(tenant.external_id) + end + end + + describe "get_status/1 degraded state" do + test "returns {:ok, conn} when replication_conn is nil in syn", %{tenant: tenant} do + assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + assert Connect.ready?(tenant.external_id) + + tenant_id = tenant.external_id + + :syn.update_registry(Connect, tenant_id, fn _pid, meta -> %{meta | replication_conn: nil} end) + + assert {:ok, conn} = Connect.get_status(tenant_id) + assert is_pid(conn) + + Connect.shutdown(tenant_id) end end @@ -519,6 +905,48 @@ defmodule Realtime.Tenants.ConnectTest do put_in(extension, ["settings", "db_port"], db_port) ] - Realtime.Api.update_tenant(tenant, %{extensions: extensions}) + Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions}) + end + + defp assert_pid(call, attempts \\ 30) + + defp assert_pid(_call, 0) do + raise "Timeout waiting for pid" + end + + defp assert_pid(call, attempts) do + case call.() do + pid when is_pid(pid) -> + pid + + _ -> + Process.sleep(500) + assert_pid(call, attempts - 1) + end + end + + defp wait_until(fun, attempts \\ 50) + defp wait_until(_fun, 0), do: false + + defp wait_until(fun, attempts) do + if fun.() do + true + else + Process.sleep(50) + wait_until(fun, attempts - 1) + end + end + + defp assert_replication_status(tenant_id, attempts \\ 30) + + defp assert_replication_status(tenant_id, 0) do + Connect.replication_status(tenant_id) + end + + defp assert_replication_status(tenant_id, attempts) do + case Connect.replication_status(tenant_id) do + {:ok, _} = result -> result + _ -> Process.sleep(500) && assert_replication_status(tenant_id, attempts - 1) + end end end diff --git a/test/realtime/tenants/janitor/maintenance_task_test.exs b/test/realtime/tenants/janitor/maintenance_task_test.exs index f4c51436e..7b988e445 100644 --- a/test/realtime/tenants/janitor/maintenance_task_test.exs +++ b/test/realtime/tenants/janitor/maintenance_task_test.exs @@ -4,42 +4,71 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTaskTest do alias Realtime.Tenants.Janitor.MaintenanceTask alias Realtime.Api.Message alias Realtime.Database - alias Realtime.Repo + alias Realtime.Tenants.Repo setup do tenant = Containers.checkout_tenant(run_migrations: true) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) %{tenant: tenant} end - test "cleans messages older than 72 hours and creates partitions", %{tenant: tenant} do - utc_now = NaiveDateTime.utc_now() - limit = NaiveDateTime.add(utc_now, -72, :hour) + describe "run/1" do + setup %{tenant: tenant} do + {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) - messages = - for days <- -5..0 do - inserted_at = NaiveDateTime.add(utc_now, days, :day) - message_fixture(tenant, %{inserted_at: inserted_at}) - end - |> MapSet.new() + date_start = Date.utc_today() |> Date.add(-10) + date_end = Date.utc_today() + create_messages_partitions(conn, date_start, date_end) - to_keep = - messages - |> Enum.reject(&(NaiveDateTime.compare(limit, &1.inserted_at) == :gt)) - |> MapSet.new() + %{conn: conn} + end - assert MaintenanceTask.run(tenant.external_id) == :ok + test "cleans messages older than 72 hours", %{tenant: tenant, conn: conn} do + utc_now = NaiveDateTime.utc_now() + limit = NaiveDateTime.add(utc_now, -72, :hour) - {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) - {:ok, res} = Repo.all(conn, from(m in Message), Message) + messages = + for days <- -5..0 do + inserted_at = NaiveDateTime.add(utc_now, days, :day) + message_fixture(tenant, %{inserted_at: inserted_at}) + end + |> MapSet.new() - verify_partitions(conn) + to_keep = + messages + |> Enum.reject(&(NaiveDateTime.compare(NaiveDateTime.beginning_of_day(limit), &1.inserted_at) == :gt)) + |> MapSet.new() - current = MapSet.new(res) + assert MaintenanceTask.run(tenant.external_id) == :ok - assert MapSet.difference(current, to_keep) |> MapSet.size() == 0 + {:ok, res} = Repo.all(conn, from(m in Message), Message) + current = MapSet.new(res) + + assert MapSet.difference(current, to_keep) |> MapSet.size() == 0 + end + + test "creates the current messages partitions and drops the old ones", %{tenant: tenant, conn: conn} do + assert MaintenanceTask.run(tenant.external_id) == :ok + + today = Date.utc_today() + dates = Date.range(Date.add(today, -3), Date.add(today, 3)) + + %{rows: rows} = + Postgrex.query!( + conn, + "SELECT tablename from pg_catalog.pg_tables where schemaname = 'realtime' and tablename like 'messages_%'", + [] + ) + + partitions = MapSet.new(rows, fn [name] -> name end) + + expected_names = + MapSet.new(dates, fn date -> "messages_#{date |> Date.to_iso8601() |> String.replace("-", "_")}" end) + + assert MapSet.equal?(partitions, expected_names) + end end test "exits if fails to remove old messages" do @@ -49,7 +78,7 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTaskTest do "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => "supabase_realtime_admin", "db_password" => "postgres", "db_port" => "11111", "poll_interval" => 100, @@ -63,7 +92,7 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTaskTest do tenant = tenant_fixture(%{extensions: extensions}) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) Process.flag(:trap_exit, true) @@ -77,25 +106,4 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTaskTest do assert_receive {:EXIT, ^pid, :killed} assert_receive {:DOWN, ^ref, :process, ^pid, :killed} end - - defp verify_partitions(conn) do - today = Date.utc_today() - yesterday = Date.add(today, -1) - future = Date.add(today, 3) - dates = Date.range(yesterday, future) - - %{rows: rows} = - Postgrex.query!( - conn, - "SELECT tablename from pg_catalog.pg_tables where schemaname = 'realtime' and tablename like 'messages_%'", - [] - ) - - partitions = MapSet.new(rows, fn [name] -> name end) - - expected_names = - MapSet.new(dates, fn date -> "messages_#{date |> Date.to_iso8601() |> String.replace("-", "_")}" end) - - assert MapSet.equal?(partitions, expected_names) - end end diff --git a/test/realtime/tenants/janitor_test.exs b/test/realtime/tenants/janitor_test.exs index 4ac1a0eda..7ead28e97 100644 --- a/test/realtime/tenants/janitor_test.exs +++ b/test/realtime/tenants/janitor_test.exs @@ -6,9 +6,9 @@ defmodule Realtime.Tenants.JanitorTest do alias Realtime.Api.Message alias Realtime.Database - alias Realtime.Repo alias Realtime.Tenants.Janitor alias Realtime.Tenants.Connect + alias Realtime.Tenants.Repo setup do :ets.delete_all_objects(Connect) @@ -24,13 +24,21 @@ defmodule Realtime.Tenants.JanitorTest do Enum.map( [tenant1, tenant2], fn tenant -> - tenant = Repo.preload(tenant, :extensions) + tenant = Realtime.Repo.preload(tenant, :extensions) Connect.lookup_or_start_connection(tenant.external_id) Process.sleep(500) tenant end ) + date_start = Date.utc_today() |> Date.add(-10) + date_end = Date.utc_today() + + Enum.map(tenants, fn tenant -> + {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) + create_messages_partitions(conn, date_start, date_end) + end) + start_supervised!( {Task.Supervisor, name: Realtime.Tenants.Janitor.TaskSupervisor, max_children: 5, max_seconds: 500, max_restarts: 1} @@ -62,7 +70,7 @@ defmodule Realtime.Tenants.JanitorTest do to_keep = messages - |> Enum.reject(&(NaiveDateTime.compare(limit, &1.inserted_at) == :gt)) + |> Enum.reject(&(NaiveDateTime.compare(NaiveDateTime.beginning_of_day(limit), &1.inserted_at) == :gt)) |> MapSet.new() start_supervised!(Janitor) @@ -105,7 +113,7 @@ defmodule Realtime.Tenants.JanitorTest do to_keep = messages - |> Enum.reject(&(NaiveDateTime.compare(limit, &1.inserted_at) == :gt)) + |> Enum.reject(&(NaiveDateTime.compare(NaiveDateTime.beginning_of_day(limit), &1.inserted_at) == :gt)) |> MapSet.new() start_supervised!(Janitor) @@ -134,7 +142,7 @@ defmodule Realtime.Tenants.JanitorTest do "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => "supabase_realtime_admin", "db_password" => "postgres", "db_port" => "1111", "poll_interval" => 100, @@ -162,7 +170,7 @@ defmodule Realtime.Tenants.JanitorTest do defp verify_partitions(conn) do today = Date.utc_today() - yesterday = Date.add(today, -1) + yesterday = Date.add(today, -3) future = Date.add(today, 3) dates = Date.range(yesterday, future) diff --git a/test/realtime/tenants/migrations_test.exs b/test/realtime/tenants/migrations_test.exs index 60f290beb..1cf1bebbc 100644 --- a/test/realtime/tenants/migrations_test.exs +++ b/test/realtime/tenants/migrations_test.exs @@ -1,10 +1,17 @@ defmodule Realtime.Tenants.MigrationsTest do - alias Realtime.Tenants.Cache # Can't use async: true because Cachex does not work well with Ecto Sandbox use Realtime.DataCase, async: false + use Mimic + alias Realtime.Api + alias Realtime.Tenants.Cache alias Realtime.Tenants.Migrations + setup do + Cachex.clear(Realtime.FeatureFlags.Cache) + :ok + end + describe "run_migrations/1" do test "migrations for a given tenant only run once" do tenant = Containers.checkout_tenant() @@ -33,4 +40,151 @@ defmodule Realtime.Tenants.MigrationsTest do assert Migrations.run_migrations(tenant) == :noop end end + + describe "run_migrations_async/1" do + test "returns immediately and runs migrations in the background" do + tenant = Containers.checkout_tenant() + + assert Migrations.run_migrations_async(tenant) == :ok + + assert eventually(fn -> + Cache.get_tenant_by_external_id(tenant.external_id).migrations_ran == + Enum.count(Migrations.migrations()) + end) + end + + test "does not run if tenant has migrations_ran equal to count of all migrations" do + tenant = tenant_fixture(%{migrations_ran: Enum.count(Migrations.migrations())}) + assert Migrations.run_migrations_async(tenant) == :noop + end + end + + describe "run_migrations?/1" do + test "returns true if migrations_ran is lower than existing migrations" do + tenant = tenant_fixture(%{migrations_ran: 0}) + assert Migrations.run_migrations?(tenant) + + tenant = tenant_fixture(%{migrations_ran: Enum.count(Migrations.migrations()) - 1}) + assert Migrations.run_migrations?(tenant) + end + + test "returns false if migrations_ran is count of all migrations" do + tenant = tenant_fixture(%{migrations_ran: Enum.count(Migrations.migrations())}) + refute Migrations.run_migrations?(tenant) + end + end + + describe "migrations/1" do + test "excludes SetupSupabaseRealtimeAdmin when the feature flag is disabled" do + {:ok, _} = Api.upsert_feature_flag(%{name: "use_supabase_realtime_admin", enabled: false}) + + modules = Enum.map(Migrations.migrations(), fn {_v, m} -> m end) + refute Migrations.SetupSupabaseRealtimeAdmin in modules + end + + test "excludes SetupSupabaseRealtimeAdmin when the tenant override is disabled" do + tenant = Containers.checkout_tenant() + {:ok, _} = Api.upsert_feature_flag(%{name: "use_supabase_realtime_admin", enabled: true}) + {:ok, _} = Realtime.FeatureFlags.set_tenant_flag("use_supabase_realtime_admin", tenant.external_id, false) + + Process.sleep(100) + Cache.invalidate_tenant_cache(tenant.external_id) + + modules = Enum.map(Migrations.migrations(tenant.external_id), fn {_v, m} -> m end) + refute Migrations.SetupSupabaseRealtimeAdmin in modules + end + + test "includes SetupSupabaseRealtimeAdmin when the feature flag is enabled" do + {:ok, _} = Api.upsert_feature_flag(%{name: "use_supabase_realtime_admin", enabled: true}) + + modules = Enum.map(Migrations.migrations(), fn {_v, m} -> m end) + assert Migrations.SetupSupabaseRealtimeAdmin in modules + end + + test "includes SetupSupabaseRealtimeAdmin when the tenant override is enabled" do + tenant = Containers.checkout_tenant() + {:ok, _} = Api.upsert_feature_flag(%{name: "use_supabase_realtime_admin", enabled: false}) + {:ok, _} = Realtime.FeatureFlags.set_tenant_flag("use_supabase_realtime_admin", tenant.external_id, true) + + Process.sleep(100) + Cache.invalidate_tenant_cache(tenant.external_id) + + modules = Enum.map(Migrations.migrations(tenant.external_id), fn {_v, m} -> m end) + assert Migrations.SetupSupabaseRealtimeAdmin in modules + end + end + + describe "telemetry" do + setup :set_mimic_global + + setup do + events = [ + [:realtime, :tenants, :migrations, :start], + [:realtime, :tenants, :migrations, :stop], + [:realtime, :tenants, :migrations, :exception] + ] + + :telemetry.attach_many(__MODULE__, events, &__MODULE__.handle_telemetry/4, pid: self()) + on_exit(fn -> :telemetry.detach(__MODULE__) end) + + :ok + end + + test "emits start event metadata" do + tenant = Containers.checkout_tenant() + external_id = tenant.external_id + + assert Migrations.run_migrations(tenant) == :ok + + assert_receive {:telemetry, [:realtime, :tenants, :migrations, :start], %{system_time: _}, + %{external_id: ^external_id, hostname: hostname}} + + assert is_binary(hostname) + end + + test "emits stop event with metadata" do + tenant = Containers.checkout_tenant() + external_id = tenant.external_id + + assert Migrations.run_migrations(tenant) == :ok + + total = Enum.count(Migrations.migrations()) + + assert_receive {:telemetry, [:realtime, :tenants, :migrations, :stop], %{duration: duration}, + %{external_id: ^external_id, hostname: hostname, migrations_executed: ^total}} + + assert is_binary(hostname) + assert is_integer(duration) and duration >= 0 + end + + test "emits exception event tagged with postgrex error on postgres errors" do + tenant = Containers.checkout_tenant() + external_id = tenant.external_id + + error = %Postgrex.Error{postgres: %{code: :undefined_column}} + expect(Ecto.Migrator, :run, fn _, _, _, _ -> raise error end) + + Migrations.run_migrations(tenant) + + assert_receive {:telemetry, [:realtime, :tenants, :migrations, :exception], %{duration: _}, + %{external_id: ^external_id, error_code: :undefined_column, kind: :error, reason: ^error}} + end + + test "tags connection errors with connection_error code" do + tenant = Containers.checkout_tenant() + external_id = tenant.external_id + + error = %DBConnection.ConnectionError{message: "ssl send: closed"} + expect(Ecto.Migrator, :run, fn _, _, _, _ -> raise error end) + + Migrations.run_migrations(tenant) + + assert_receive {:telemetry, [:realtime, :tenants, :migrations, :exception], _, + %{external_id: ^external_id, error_code: :connection_error}} + end + end + + def handle_telemetry(event, measurements, metadata, pid: pid) do + send(pid, {:telemetry, event, measurements, metadata}) + end end diff --git a/test/realtime/tenants/rebalancer_test.exs b/test/realtime/tenants/rebalancer_test.exs index ac8e1ea36..d91e7e675 100644 --- a/test/realtime/tenants/rebalancer_test.exs +++ b/test/realtime/tenants/rebalancer_test.exs @@ -9,7 +9,7 @@ defmodule Realtime.Tenants.RebalancerTest do setup do tenant = Containers.checkout_tenant(run_migrations: true) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) %{tenant: tenant} end diff --git a/test/realtime/tenants/replication_connection/watchdog_test.exs b/test/realtime/tenants/replication_connection/watchdog_test.exs new file mode 100644 index 000000000..c122010e6 --- /dev/null +++ b/test/realtime/tenants/replication_connection/watchdog_test.exs @@ -0,0 +1,233 @@ +defmodule Realtime.Tenants.ReplicationConnection.WatchdogTest do + use ExUnit.Case, async: true + + use Mimic + + import ExUnit.CaptureLog + + alias Realtime.Database + alias Realtime.Tenants.Connect + alias Realtime.Tenants.ReplicationConnection.Watchdog + + defmodule FakeReplicationConnection do + def child_spec(opts) do + %{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}, type: :worker, restart: :temporary, shutdown: 500} + end + + def start_link(opts \\ []), do: :gen_statem.start_link(__MODULE__, opts, []) + + def callback_mode, do: :state_functions + + def init(opts) do + respond_to_health_checks = Keyword.get(opts, :respond_to_health_checks, true) + delay_ms = Keyword.get(opts, :delay_ms, 0) + + data = %{ + respond_to_health_checks: respond_to_health_checks, + delay_ms: delay_ms, + health_check_count: 0 + } + + {:ok, :idle, data} + end + + def idle({:call, from}, :health_check, %{respond_to_health_checks: true, delay_ms: delay_ms} = data) do + if delay_ms > 0 do + Process.sleep(delay_ms) + end + + :gen_statem.reply(from, :ok) + {:keep_state, %{data | health_check_count: data.health_check_count + 1}} + end + + def idle({:call, _from}, :health_check, %{respond_to_health_checks: false} = data) do + # Don't reply - this will cause a timeout + {:keep_state, %{data | health_check_count: data.health_check_count + 1}} + end + + def idle({:call, from}, :get_health_check_count, data) do + :gen_statem.reply(from, data.health_check_count) + {:keep_state, data} + end + + def idle({:call, from}, :set_no_respond, data) do + :gen_statem.reply(from, :ok) + {:keep_state, %{data | respond_to_health_checks: false}} + end + + def get_health_check_count(pid), do: :gen_statem.call(pid, :get_health_check_count) + + def set_no_respond(pid), do: :gen_statem.call(pid, :set_no_respond) + end + + test "performs periodic health checks successfully" do + fake_pid = start_link_supervised!(FakeReplicationConnection) + + watchdog_pid = + start_supervised!( + {Watchdog, parent_pid: fake_pid, tenant_id: "test-tenant", watchdog_interval: 50, watchdog_timeout: 100} + ) + + # Wait for at least 2 health check cycles + Process.sleep(150) + + assert Process.alive?(watchdog_pid) + assert Process.alive?(fake_pid) + + # Verify health checks were performed + count = FakeReplicationConnection.get_health_check_count(fake_pid) + assert count >= 2 + end + + describe "timeout handling" do + test "stops when health check times out" do + # Create a fake process that doesn't respond to health checks + fake_pid = start_supervised!({FakeReplicationConnection, respond_to_health_checks: false}) + + logs = + capture_log(fn -> + watchdog_pid = + start_supervised!( + {Watchdog, parent_pid: fake_pid, tenant_id: "test-tenant", watchdog_interval: 50, watchdog_timeout: 100} + ) + + ref = Process.monitor(watchdog_pid) + + # Wait for the first health check to timeout + assert_receive {:DOWN, ^ref, :process, ^watchdog_pid, :watchdog_timeout}, 500 + refute Process.alive?(watchdog_pid) + end) + + assert logs =~ "ReplicationConnectionWatchdogTimeout" + assert logs =~ "ReplicationConnection is not responding" + end + + test "stops immediately if health check takes longer than timeout" do + # Create a fake process with a 200ms delay + fake_pid = start_supervised!({FakeReplicationConnection, delay_ms: 200}) + + logs = + capture_log(fn -> + watchdog_pid = + start_supervised!( + {Watchdog, parent_pid: fake_pid, tenant_id: "timeout-test", watchdog_interval: 50, watchdog_timeout: 100} + ) + + ref = Process.monitor(watchdog_pid) + + # Should timeout because delay (200ms) > timeout (100ms) + assert_receive {:DOWN, ^ref, :process, ^watchdog_pid, :watchdog_timeout}, 500 + end) + + assert logs =~ "ReplicationConnectionWatchdogTimeout" + end + end + + describe "dynamic behavior changes" do + test "handles transition from healthy to timeout" do + # Start with responding, then stop responding + fake_pid = start_supervised!(FakeReplicationConnection) + + watchdog_pid = + start_supervised!( + {Watchdog, parent_pid: fake_pid, tenant_id: "test-tenant", watchdog_interval: 50, watchdog_timeout: 100} + ) + + # Wait for first successful health check + Process.sleep(80) + assert Process.alive?(watchdog_pid) + + ref = Process.monitor(watchdog_pid) + # Now make the fake process stop responding + FakeReplicationConnection.set_no_respond(fake_pid) + + logs = + capture_log(fn -> + # Should timeout on next health check + assert_receive {:DOWN, ^ref, :process, ^watchdog_pid, :watchdog_timeout}, 500 + end) + + assert logs =~ "ReplicationConnectionWatchdogTimeout" + end + end + + describe "slot lag monitoring" do + setup do + fake_pid = start_link_supervised!(FakeReplicationConnection) + %{fake_pid: fake_pid} + end + + test "continues when slot lag is below threshold", %{fake_pid: fake_pid} do + stub(Connect, :get_status, fn _tenant_id -> {:ok, :fake_conn} end) + stub(Database, :check_replication_slot_lag, fn _conn, _slot -> :ok end) + + watchdog_pid = + start_supervised!( + {Watchdog, + parent_pid: fake_pid, + tenant_id: "lag-test", + watchdog_interval: 50, + watchdog_timeout: 100, + replication_slot_name: "test_slot"} + ) + + Mimic.allow(Connect, self(), watchdog_pid) + Mimic.allow(Database, self(), watchdog_pid) + + Process.sleep(120) + + assert Process.alive?(watchdog_pid) + end + + test "stops with :slot_lag_too_high when lag exceeds threshold", %{fake_pid: fake_pid} do + stub(Connect, :get_status, fn _tenant_id -> {:ok, :fake_conn} end) + stub(Database, :check_replication_slot_lag, fn _conn, _slot -> {:error, :lag_too_high} end) + + logs = + capture_log(fn -> + watchdog_pid = + start_supervised!( + {Watchdog, + parent_pid: fake_pid, + tenant_id: "lag-test", + watchdog_interval: 50, + watchdog_timeout: 100, + replication_slot_name: "test_slot"} + ) + + Mimic.allow(Connect, self(), watchdog_pid) + Mimic.allow(Database, self(), watchdog_pid) + + ref = Process.monitor(watchdog_pid) + assert_receive {:DOWN, ^ref, :process, ^watchdog_pid, :slot_lag_too_high}, 500 + end) + + assert logs =~ "ReplicationSlotLagTooHigh" + end + + test "continues when DB connection is unavailable (graceful degradation)", %{fake_pid: fake_pid} do + stub(Connect, :get_status, fn _tenant_id -> {:error, :tenant_database_unavailable} end) + + logs = + capture_log(fn -> + watchdog_pid = + start_supervised!( + {Watchdog, + parent_pid: fake_pid, + tenant_id: "lag-test", + watchdog_interval: 50, + watchdog_timeout: 100, + replication_slot_name: "test_slot"} + ) + + Mimic.allow(Connect, self(), watchdog_pid) + + Process.sleep(120) + + assert Process.alive?(watchdog_pid) + end) + + assert logs =~ "ReplicationSlotLagCheckSkipped" + end + end +end diff --git a/test/realtime/tenants/replication_connection_test.exs b/test/realtime/tenants/replication_connection_test.exs index 783270313..2661e4537 100644 --- a/test/realtime/tenants/replication_connection_test.exs +++ b/test/realtime/tenants/replication_connection_test.exs @@ -1,16 +1,19 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do # Async false due to tweaking application env use Realtime.DataCase, async: false - use Mimic - setup :set_mimic_global import ExUnit.CaptureLog alias Realtime.Api.Message alias Realtime.Database + alias Realtime.GenCounter + alias Realtime.RateCounter alias Realtime.Tenants alias Realtime.Tenants.ReplicationConnection alias RealtimeWeb.Endpoint + alias Realtime.Tenants.Repo + + @replication_slot_name "supabase_realtime_messages_replication_slot_test" setup do slot = Application.get_env(:realtime, :slot_name_suffix) @@ -20,11 +23,10 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do tenant = Containers.checkout_tenant(run_migrations: true) {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) - name = "supabase_realtime_messages_replication_slot_test" - Postgrex.query(db_conn, "SELECT pg_drop_replication_slot($1)", [name]) - Process.exit(db_conn, :normal) + Integrations.setup_postgres_changes(db_conn) + Postgrex.query(db_conn, "SELECT pg_drop_replication_slot($1)", [@replication_slot_name]) - %{tenant: tenant} + %{tenant: tenant, db_conn: db_conn} end describe "temporary process" do @@ -44,6 +46,36 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do end end + describe "watchdog kills unresponsive replication" do + setup do + replication_watchdog_interval = Application.get_env(:realtime, :replication_watchdog_interval) + replication_watchdog_timeout = Application.get_env(:realtime, :replication_watchdog_timeout) + + on_exit(fn -> + Application.put_env(:realtime, :replication_watchdog_interval, replication_watchdog_interval) + Application.put_env(:realtime, :replication_watchdog_timeout, replication_watchdog_timeout) + end) + + Application.put_env(:realtime, :replication_watchdog_interval, 100) + Application.put_env(:realtime, :replication_watchdog_timeout, 100) + end + + test "watchdog kills replication connection that is not responding to health checks", %{tenant: tenant} do + assert {:ok, pid} = ReplicationConnection.start(tenant, self()) + + log = + capture_log(fn -> + # Let's make it not reply to health checks + :sys.suspend(pid) + + reason = assert_process_down(pid, 400) + assert reason == :watchdog_timeout + end) + + assert log =~ "ReplicationConnectionWatchdogTimeout" + end + end + describe "replication" do test "fails if tenant connection is invalid" do tenant = @@ -54,7 +86,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => "supabase_realtime_admin", "db_password" => "postgres", "db_port" => "9001", "poll_interval" => 100, @@ -70,7 +102,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do assert {:error, _} = ReplicationConnection.start(tenant, self()) end - test "starts a handler for the tenant and broadcasts", %{tenant: tenant} do + test "starts a handler for the tenant and broadcasts", %{tenant: tenant, db_conn: db_conn} do start_link_supervised!( {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}}, restart: :transient @@ -98,8 +130,8 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do payload = %{ "event" => "INSERT", + "meta" => %{"id" => row.id}, "payload" => %{ - "id" => row.id, "value" => value }, "type" => "broadcast" @@ -121,8 +153,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do }) end - {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) - {:ok, _} = Realtime.Repo.insert_all_entries(db_conn, messages, Message) + {:ok, _} = Repo.insert_all_entries(db_conn, messages, Message) messages_received = for _ <- 1..total_messages, into: [] do @@ -139,8 +170,8 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do "event" => "broadcast", "payload" => %{ "event" => "INSERT", + "meta" => %{"id" => _id}, "payload" => %{ - "id" => _, "value" => ^value } }, @@ -153,6 +184,195 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do end end + test "starts a handler for the tenant and broadcasts to public channel", %{tenant: tenant, db_conn: db_conn} do + start_link_supervised!( + {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}}, + restart: :transient + ) + + topic = random_string() + tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, true) + subscribe(tenant_topic, topic) + + total_messages = 5 + # Works with one insert per transaction + for _ <- 1..total_messages do + value = random_string() + + row = + message_fixture(tenant, %{ + "topic" => topic, + "private" => false, + "event" => "INSERT", + "payload" => %{"value" => value} + }) + + assert_receive {:socket_push, :text, data} + message = data |> IO.iodata_to_binary() |> Jason.decode!() + + payload = %{ + "event" => "INSERT", + "meta" => %{"id" => row.id}, + "payload" => %{ + "value" => value + }, + "type" => "broadcast" + } + + assert message == %{"event" => "broadcast", "payload" => payload, "ref" => nil, "topic" => topic} + end + + Process.sleep(500) + # Works with batch inserts + messages = + for _ <- 1..total_messages do + Message.changeset(%Message{}, %{ + "topic" => topic, + "private" => false, + "event" => "INSERT", + "extension" => "broadcast", + "payload" => %{"value" => random_string()} + }) + end + + {:ok, _} = Repo.insert_all_entries(db_conn, messages, Message) + + messages_received = + for _ <- 1..total_messages, into: [] do + assert_receive {:socket_push, :text, data} + data |> IO.iodata_to_binary() |> Jason.decode!() + end + + for row <- messages do + assert Enum.count(messages_received, fn message_received -> + value = row |> Map.from_struct() |> get_in([:changes, :payload, "value"]) + + match?( + %{ + "event" => "broadcast", + "payload" => %{ + "event" => "INSERT", + "meta" => %{"id" => _id}, + "payload" => %{ + "value" => ^value + } + }, + "ref" => nil, + "topic" => ^topic + }, + message_received + ) + end) == 1 + end + end + + test "replicates binary with exactly 16 bytes to test UUID conversion error", %{tenant: tenant} do + start_link_supervised!( + {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}}, + restart: :transient + ) + + topic = "db:job_scheduler" + tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false) + subscribe(tenant_topic, topic) + payload = %{"value" => random_string()} + + row = + message_fixture(tenant, %{ + "topic" => topic, + "private" => true, + "event" => "UPDATE", + "extension" => "broadcast", + "payload" => payload + }) + + row_id = row.id + + assert_receive {:socket_push, :text, data}, 2000 + message = data |> IO.iodata_to_binary() |> Jason.decode!() + + assert %{ + "event" => "broadcast", + "payload" => %{ + "event" => "UPDATE", + "meta" => %{"id" => ^row_id}, + "payload" => received_payload, + "type" => "broadcast" + }, + "ref" => nil, + "topic" => ^topic + } = message + + assert received_payload == payload + end + + test "should not process unsupported relations", %{tenant: tenant, db_conn: db_conn} do + # update + queries = [ + "DROP TABLE IF EXISTS public.test", + """ + CREATE TABLE "public"."test" ( + "id" int4 NOT NULL default nextval('test_id_seq'::regclass), + "details" text, + PRIMARY KEY ("id")); + """ + ] + + Postgrex.transaction(db_conn, fn conn -> + Enum.each(queries, &Postgrex.query!(conn, &1, [])) + end) + + logs = + capture_log(fn -> + start_link_supervised!( + {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}}, + restart: :transient + ) + + assert_replication_started(db_conn, @replication_slot_name) + assert_publication_contains_only_messages(db_conn, "supabase_realtime_messages_publication") + + # Add table to publication to test the error handling + Postgrex.query!(db_conn, "ALTER PUBLICATION supabase_realtime_messages_publication ADD TABLE public.test", []) + %{rows: [[_id]]} = Postgrex.query!(db_conn, "insert into test (details) values ('test') returning id", []) + + topic = "db:job_scheduler" + tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false) + subscribe(tenant_topic, topic) + payload = %{"value" => random_string()} + + row = + message_fixture(tenant, %{ + "topic" => topic, + "private" => true, + "event" => "UPDATE", + "extension" => "broadcast", + "payload" => payload + }) + + row_id = row.id + + assert_receive {:socket_push, :text, data}, 2000 + message = data |> IO.iodata_to_binary() |> Jason.decode!() + + assert %{ + "event" => "broadcast", + "payload" => %{ + "event" => "UPDATE", + "meta" => %{"id" => ^row_id}, + "payload" => received_payload, + "type" => "broadcast" + }, + "ref" => nil, + "topic" => ^topic + } = message + + assert received_payload == payload + end) + + assert logs =~ "Unexpected relation on schema 'public' and table 'test'" + end + test "monitored pid stopping brings down ReplicationConnection ", %{tenant: tenant} do monitored_pid = spawn(fn -> @@ -198,13 +418,72 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do "payload" => %{"value" => "something"} }) - refute_receive %Phoenix.Socket.Broadcast{}, 500 + refute_receive _any, 500 end) assert logs =~ "UnableToBroadcastChanges" end - test "payload without id", %{tenant: tenant} do + test "message that exceeds payload size is not broadcast and logs error", %{tenant: tenant} do + logs = + capture_log(fn -> + start_supervised!( + {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}}, + restart: :transient + ) + + topic = random_string() + tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false) + assert :ok = Endpoint.subscribe(tenant_topic) + + message_fixture(tenant, %{ + "event" => random_string(), + "topic" => topic, + "private" => true, + "payload" => %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)} + }) + + refute_receive _any, 500 + end) + + assert logs =~ "UnableToBroadcastChanges: :payload_size_exceeded" + end + + test "message is not broadcast and logs error when rate limit is exceeded", %{tenant: tenant} do + events_per_second_rate = Tenants.events_per_second_rate(tenant) + + # Start with a clean rate counter and push it well above the limit so the + # avg stays over the threshold for the full duration of the test. + RateCounterHelper.stop(tenant.external_id) + {:ok, _} = RateCounter.new(events_per_second_rate) + GenCounter.add(events_per_second_rate.id, tenant.max_events_per_second * 60 + 1) + {:ok, %{limit: %{triggered: true}}} = RateCounterHelper.tick!(events_per_second_rate) + + logs = + capture_log(fn -> + start_supervised!( + {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}}, + restart: :transient + ) + + topic = random_string() + tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false) + assert :ok = Endpoint.subscribe(tenant_topic) + + message_fixture(tenant, %{ + "event" => "INSERT", + "topic" => topic, + "private" => true, + "payload" => %{"value" => random_string()} + }) + + refute_receive _any, 500 + end) + + assert logs =~ "UnableToBroadcastChanges: :too_many_requests" + end + + test "payload without id", %{tenant: tenant, db_conn: db_conn} do start_link_supervised!( {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}}, restart: :transient @@ -214,33 +493,141 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false) subscribe(tenant_topic, topic) - fixture = - message_fixture(tenant, %{ - "topic" => topic, - "private" => true, - "event" => "INSERT", - "payload" => %{"value" => "something"} - }) + value = "something" + event = "INSERT" + + Postgrex.query!( + db_conn, + "SELECT realtime.send (json_build_object ('value', $1 :: text)::jsonb, $2 :: text, $3 :: text, TRUE::bool);", + [value, event, topic] + ) + + {:ok, [%{id: id}]} = Repo.all(db_conn, from(m in Message), Message) assert_receive {:socket_push, :text, data}, 500 message = data |> IO.iodata_to_binary() |> Jason.decode!() assert %{ "event" => "broadcast", - "payload" => %{"event" => "INSERT", "payload" => payload, "type" => "broadcast"}, + "payload" => %{ + "event" => "INSERT", + "meta" => %{"id" => ^id}, + "payload" => payload, + "type" => "broadcast" + }, "ref" => nil, "topic" => ^topic } = message - id = fixture.id - assert payload == %{ "value" => "something", "id" => id } end - test "payload including id", %{tenant: tenant} do + test "binary payload is replicated as UserBroadcast with binary encoding", %{tenant: tenant, db_conn: db_conn} do + start_link_supervised!( + {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}}, + restart: :transient + ) + + topic = random_string() + tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false) + assert :ok = Endpoint.subscribe(tenant_topic) + + Realtime.Tenants.create_messages_partitions(db_conn) + + binary = <<0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF>> + event = "INSERT" + + Postgrex.query!( + db_conn, + "SELECT realtime.send_binary($1::bytea, $2::text, $3::text, TRUE::bool);", + [binary, event, topic] + ) + + assert_receive %RealtimeWeb.Socket.UserBroadcast{ + user_event: ^event, + user_payload_encoding: :binary, + user_payload: ^binary, + metadata: %{"id" => _id} + }, + 500 + end + + test "binary payload that exceeds payload size is not broadcast and logs error", %{tenant: tenant, db_conn: db_conn} do + logs = + capture_log(fn -> + start_supervised!( + {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}}, + restart: :transient + ) + + topic = random_string() + tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false) + assert :ok = Endpoint.subscribe(tenant_topic) + + Realtime.Tenants.create_messages_partitions(db_conn) + + binary = :binary.copy(<<0>>, tenant.max_payload_size_in_kb * 1000 + 1000) + event = "INSERT" + + Postgrex.query!( + db_conn, + "SELECT realtime.send_binary($1::bytea, $2::text, $3::text, TRUE::bool);", + [binary, event, topic] + ) + + refute_receive _any, 500 + end) + + assert logs =~ "UnableToBroadcastChanges: :payload_size_exceeded" + end + + test "empty binary payload is replicated as UserBroadcast with binary encoding", %{tenant: tenant, db_conn: db_conn} do + start_link_supervised!( + {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}}, + restart: :transient + ) + + topic = random_string() + tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false) + assert :ok = Endpoint.subscribe(tenant_topic) + + Realtime.Tenants.create_messages_partitions(db_conn) + + event = "INSERT" + + Postgrex.query!( + db_conn, + "SELECT realtime.send_binary($1::bytea, $2::text, $3::text, TRUE::bool);", + [<<>>, event, topic] + ) + + assert_receive %RealtimeWeb.Socket.UserBroadcast{ + user_event: ^event, + user_payload_encoding: :binary, + user_payload: <<>>, + metadata: %{"id" => _id} + }, + 500 + end + + test "rejects insert with both payload and binary_payload set", %{db_conn: db_conn} do + Realtime.Tenants.create_messages_partitions(db_conn) + + assert {:error, %Postgrex.Error{postgres: %{code: :check_violation, constraint: "messages_payload_exclusive"}}} = + Postgrex.query( + db_conn, + """ + INSERT INTO realtime.messages (payload, binary_payload, event, topic, private, extension) + VALUES ($1::jsonb, $2::bytea, 'evt', $3::text, false, 'broadcast') + """, + [%{"value" => "x"}, <<1, 2, 3>>, random_string()] + ) + end + + test "payload including id", %{tenant: tenant, db_conn: db_conn} do start_link_supervised!( {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}}, restart: :transient @@ -250,21 +637,29 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false) subscribe(tenant_topic, topic) - payload = %{"value" => "something", "id" => "123456"} + id = "123456" + value = "something" + event = "INSERT" - message_fixture(tenant, %{ - "topic" => topic, - "private" => true, - "event" => "INSERT", - "payload" => payload - }) + Postgrex.query!( + db_conn, + "SELECT realtime.send (json_build_object ('value', $1 :: text, 'id', $2 :: text)::jsonb, $3 :: text, $4 :: text, TRUE::bool);", + [value, id, event, topic] + ) + + {:ok, [%{id: message_id}]} = Repo.all(db_conn, from(m in Message), Message) assert_receive {:socket_push, :text, data}, 500 message = data |> IO.iodata_to_binary() |> Jason.decode!() assert %{ "event" => "broadcast", - "payload" => %{"event" => "INSERT", "payload" => ^payload, "type" => "broadcast"}, + "payload" => %{ + "meta" => %{"id" => ^message_id}, + "event" => "INSERT", + "payload" => %{"value" => "something", "id" => ^id}, + "type" => "broadcast" + }, "ref" => nil, "topic" => ^topic } = message @@ -272,27 +667,23 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do test "fails on existing replication slot", %{tenant: tenant} do {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) - name = "supabase_realtime_messages_replication_slot_test" + name = @replication_slot_name Postgrex.query!(db_conn, "SELECT pg_create_logical_replication_slot($1, 'test_decoding')", [name]) - assert {:error, {:shutdown, "Temporary Replication slot already exists and in use"}} = + assert {:error, {:shutdown, :replication_slot_in_use}} = ReplicationConnection.start(tenant, self()) Postgrex.query!(db_conn, "SELECT pg_drop_replication_slot($1)", [name]) end test "times out when init takes too long", %{tenant: tenant} do - expect(ReplicationConnection, :init, 1, fn arg -> - :timer.sleep(1000) - call_original(ReplicationConnection, :init, [arg]) - end) - - {:error, :timeout} = ReplicationConnection.start(tenant, self(), 100) + assert {:error, :replication_connection_timeout} = ReplicationConnection.start(tenant, self(), 0) end test "handle standby connections exceeds max_wal_senders", %{tenant: tenant} do - opts = Database.from_tenant(tenant, "realtime_test", :stop) |> Database.opts() + {:ok, settings} = Database.from_tenant(tenant, "realtime_test", :stop) + opts = Database.opts(settings) parent = self() # This creates a loop of errors that occupies all WAL senders and lets us test the error handling @@ -301,7 +692,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do replication_slot_opts = %PostgresReplication{ connection_opts: opts, - table: :all, + table: "test", output_plugin: "pgoutput", output_plugin_options: [proto_version: "1", publication_names: "test_#{i}_publication"], handler_module: Replication.TestHandler, @@ -331,11 +722,185 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do assert {:error, :max_wal_senders_reached} = ReplicationConnection.start(tenant, self()) end + + test "handles WAL pressure gracefully", %{tenant: tenant} do + {:ok, replication_pid} = ReplicationConnection.start(tenant, self()) + + {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) + on_exit(fn -> Process.exit(conn, :normal) end) + + large_payload = String.duplicate("x", 10 * 1024 * 1024) + + for i <- 1..5 do + message_fixture_with_conn(tenant, conn, %{ + "topic" => "stress_#{i}", + "private" => true, + "event" => "INSERT", + "payload" => %{"data" => large_payload} + }) + end + + assert Process.alive?(replication_pid) + end + end + + describe "publication validation steps" do + test "if proper tables are included, starts replication", %{tenant: tenant, db_conn: db_conn} do + publication_name = "supabase_realtime_messages_publication" + + Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", []) + Postgrex.query!(db_conn, "CREATE PUBLICATION #{publication_name} FOR TABLE realtime.messages", []) + + logs = + capture_log(fn -> + {:ok, pid} = ReplicationConnection.start(tenant, self()) + + assert_replication_started(db_conn, @replication_slot_name) + assert Process.alive?(pid) + assert_publication_contains_only_messages(db_conn, publication_name) + + Process.exit(pid, :shutdown) + end) + + refute logs =~ "Recreating" + end + + test "disconnects when the publication cannot be created", %{tenant: tenant, db_conn: db_conn} do + publication_name = "supabase_realtime_messages_publication" + + # No publication yet (forces the CREATE PUBLICATION path) and no table to publish, + # so `CREATE PUBLICATION ... FOR TABLE realtime.messages` fails with undefined_table. + Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", []) + Postgrex.query!(db_conn, "DROP TABLE IF EXISTS realtime.messages CASCADE", []) + + capture_log(fn -> + assert {:error, "Error creating publication:" <> _} = ReplicationConnection.start(tenant, self()) + end) + end + + test "disconnects when the publication cannot be recreated", %{tenant: tenant, db_conn: db_conn} do + publication_name = "supabase_realtime_messages_publication" + + # Publication exists but with the wrong table, so validation triggers the + # `DROP ...; CREATE ...` recreate path. With realtime.messages gone, the CREATE half + # of that multi-statement fails, exercising the list-of-results error branch. + Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", []) + Postgrex.query!(db_conn, "CREATE TABLE IF NOT EXISTS public.wrong_table (id int)", []) + Postgrex.query!(db_conn, "CREATE PUBLICATION #{publication_name} FOR TABLE public.wrong_table", []) + Postgrex.query!(db_conn, "DROP TABLE IF EXISTS realtime.messages CASCADE", []) + + logs = + capture_log(fn -> + assert {:error, "Error creating publication:" <> _} = ReplicationConnection.start(tenant, self()) + end) + + assert logs =~ "Recreating" + end + + test "if includes unexpected tables, recreates publication", %{tenant: tenant, db_conn: db_conn} do + publication_name = "supabase_realtime_messages_publication" + + Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", []) + Postgrex.query!(db_conn, "CREATE TABLE IF NOT EXISTS public.wrong_table (id int)", []) + Postgrex.query!(db_conn, "CREATE PUBLICATION #{publication_name} FOR TABLE public.wrong_table", []) + + logs = + capture_log(fn -> + {:ok, pid} = ReplicationConnection.start(tenant, self()) + + assert_replication_started(db_conn, @replication_slot_name) + assert Process.alive?(pid) + assert_publication_contains_only_messages(db_conn, publication_name) + + Process.exit(pid, :shutdown) + end) + + assert logs =~ "Recreating" + end + + test "recreates publication if it has no tables", %{tenant: tenant, db_conn: db_conn} do + publication_name = "supabase_realtime_messages_publication" + + Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", []) + Postgrex.query!(db_conn, "CREATE PUBLICATION #{publication_name}", []) + + logs = + capture_log(fn -> + {:ok, pid} = ReplicationConnection.start(tenant, self()) + + assert_replication_started(db_conn, @replication_slot_name) + assert Process.alive?(pid) + assert_publication_contains_only_messages(db_conn, publication_name) + + Process.exit(pid, :shutdown) + end) + + assert logs =~ "Recreating" + end + + test "recreates publication if it has expected tables and unexpected tables under same publication", %{ + tenant: tenant, + db_conn: db_conn + } do + publication_name = "supabase_realtime_messages_publication" + + Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", []) + Postgrex.query!(db_conn, "CREATE TABLE IF NOT EXISTS public.extra_table (id int)", []) + + Postgrex.query!( + db_conn, + "CREATE PUBLICATION #{publication_name} FOR TABLE realtime.messages, public.extra_table", + [] + ) + + logs = + capture_log(fn -> + {:ok, pid} = ReplicationConnection.start(tenant, self()) + + assert_replication_started(db_conn, @replication_slot_name) + assert Process.alive?(pid) + assert_publication_contains_only_messages(db_conn, publication_name) + + Process.exit(pid, :shutdown) + end) + + assert logs =~ "Recreating" + end + end + + describe "handle_result/2 for step :start_replication_slot" do + test "returns disconnect when error has postgres map with message" do + error = %Postgrex.Error{ + postgres: %{ + code: :undefined_table, + message: "relation \"realtime.messages\" does not exist" + } + } + + state = %ReplicationConnection{step: :start_replication_slot} + + assert {:disconnect, "Error starting replication: relation \"realtime.messages\" does not exist"} = + ReplicationConnection.handle_result(error, state) + end + + test "returns disconnect when error has top-level message and no postgres map" do + error = %Postgrex.Error{message: "connection closed"} + state = %ReplicationConnection{step: :start_replication_slot} + + assert {:disconnect, "Error starting replication: connection closed"} = + ReplicationConnection.handle_result(error, state) + end + + test "returns disconnect when results list contains a Postgrex.Error" do + error = %Postgrex.Error{message: "something went wrong"} + state = %ReplicationConnection{step: :start_replication_slot} + + assert {:disconnect, "Error starting replication: something went wrong"} = + ReplicationConnection.handle_result([error], state) + end end describe "whereis/1" do - @tag skip: - "We are using a GenServer wrapper so the pid returned is not the same as the ReplicationConnection for now" test "returns pid if exists", %{tenant: tenant} do {:ok, pid} = ReplicationConnection.start(tenant, self()) assert ReplicationConnection.whereis(tenant.external_id) == pid @@ -349,6 +914,39 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {event, measures, metadata}) + describe "handle_data/2 for KeepAlive" do + test "always sends standby_status when reply is :later" do + wal_end = 1_000_000 + # KeepAlive binary: ?k + wal_end(64) + clock(64) + reply(8), reply=0 means :later + keep_alive = <> + state = %ReplicationConnection{tenant_id: "test", step: :streaming} + + assert {:noreply, message, ^state} = ReplicationConnection.handle_data(keep_alive, state) + + assert [<>] = message + assert received == wal_end + 1 + assert flushed == wal_end + 1 + assert applied == wal_end + 1 + # :later maps to reply byte 0 + assert reply_byte == 0 + end + + test "sends standby_status when reply is :now" do + wal_end = 2_000_000 + keep_alive = <> + state = %ReplicationConnection{tenant_id: "test", step: :streaming} + + assert {:noreply, message, ^state} = ReplicationConnection.handle_data(keep_alive, state) + + assert [<>] = message + assert received == wal_end + 1 + assert flushed == wal_end + 1 + assert applied == wal_end + 1 + # :now maps to reply byte 1 + assert reply_byte == 1 + end + end + describe "telemetry events" do setup do :telemetry.detach(__MODULE__) @@ -378,7 +976,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do "payload" => %{"value" => random_string()} }) - assert_receive {:socket_push, :text, data} + assert_receive {:socket_push, :text, data}, 500 message = data |> IO.iodata_to_binary() |> Jason.decode!() assert %{"event" => "broadcast", "payload" => _, "ref" => nil, "topic" => ^topic} = message @@ -407,6 +1005,62 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do defp assert_process_down(pid, timeout \\ 100) do ref = Process.monitor(pid) - assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, timeout + assert_receive {:DOWN, ^ref, :process, ^pid, reason}, timeout + reason + end + + defp message_fixture_with_conn(_tenant, conn, override) do + create_attrs = %{ + "topic" => random_string(), + "extension" => "broadcast" + } + + override = override |> Enum.map(fn {k, v} -> {"#{k}", v} end) |> Map.new() + + {:ok, message} = + create_attrs + |> Map.merge(override) + |> TenantConnection.create_message(conn) + + message + end + + defp assert_publication_contains_only_messages(db_conn, publication_name) do + %{rows: rows} = + Postgrex.query!( + db_conn, + "SELECT schemaname, tablename FROM pg_publication_tables WHERE pubname = $1", + [publication_name] + ) + + valid_tables = + Enum.all?(rows, fn [schema, table] -> + schema == "realtime" and (table == "messages" or String.starts_with?(table, "messages_")) + end) + + assert valid_tables, "Expected only realtime.messages or its partitions, got: #{inspect(rows)}" + end + + defp assert_replication_started(db_conn, slot_name, retries \\ 10, interval_ms \\ 10) do + case check_replication_status(db_conn, slot_name, retries, interval_ms) do + :ok -> :ok + :error -> flunk("Replication slot #{slot_name} did not become active") + end + end + + defp check_replication_status(_db_conn, _slot_name, 0, _interval_ms), do: :error + + defp check_replication_status(db_conn, slot_name, retries_remaining, interval_ms) do + %{rows: rows} = + Postgrex.query!(db_conn, "SELECT active FROM pg_replication_slots WHERE slot_name = $1", [slot_name]) + + case rows do + [[true]] -> + :ok + + _ -> + Process.sleep(interval_ms) + check_replication_status(db_conn, slot_name, retries_remaining - 1, interval_ms) + end end end diff --git a/test/realtime/repo_test.exs b/test/realtime/tenants/repo_test.exs similarity index 99% rename from test/realtime/repo_test.exs rename to test/realtime/tenants/repo_test.exs index 7d6841b01..697274494 100644 --- a/test/realtime/repo_test.exs +++ b/test/realtime/tenants/repo_test.exs @@ -1,10 +1,10 @@ -defmodule Realtime.RepoTest do +defmodule Realtime.Tenants.RepoTest do use Realtime.DataCase, async: true import Ecto.Query alias Realtime.Api.Message - alias Realtime.Repo + alias Realtime.Tenants.Repo alias Realtime.Database setup do diff --git a/test/realtime/tenants/schema_test.exs b/test/realtime/tenants/schema_test.exs new file mode 100644 index 000000000..e224d4842 --- /dev/null +++ b/test/realtime/tenants/schema_test.exs @@ -0,0 +1,344 @@ +defmodule Realtime.Tenants.SchemaTest do + @moduledoc false + + use Realtime.DataCase, async: false + alias Realtime.Database + + setup do + tenant = Containers.checkout_tenant(run_migrations: true) + {:ok, settings} = Database.from_tenant(tenant, "realtime_test", :stop) + + opts = settings |> Map.from_struct() |> Keyword.new() + + # simulate postgres dashboard role + {:ok, conn} = opts |> Keyword.put(:username, "postgres") |> Postgrex.start_link() + {:ok, superuser_conn} = opts |> Keyword.put(:username, "supabase_admin") |> Postgrex.start_link() + {:ok, realtime_conn} = opts |> Keyword.put(:username, "supabase_realtime_admin") |> Postgrex.start_link() + + %{conn: conn, superuser_conn: superuser_conn, realtime_conn: realtime_conn, settings: settings} + end + + describe "restrictions" do + @describetag :requires_supautils_policy_grants + + test "deny create trigger on realtime.messages", %{conn: conn} do + Postgrex.query!( + conn, + "CREATE OR REPLACE FUNCTION public.dummy_function() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql", + [] + ) + + assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} = + Postgrex.query( + conn, + "CREATE TRIGGER messages_trigger BEFORE INSERT ON realtime.messages FOR EACH ROW EXECUTE FUNCTION public.dummy_function()", + [] + ) + end + + test "deny create trigger on realtime.schema_migrations", %{conn: conn} do + Postgrex.query!( + conn, + "CREATE OR REPLACE FUNCTION public.dummy_function() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql", + [] + ) + + assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} = + Postgrex.query( + conn, + "CREATE TRIGGER schema_migrations_trigger BEFORE INSERT ON realtime.schema_migrations FOR EACH ROW EXECUTE FUNCTION public.dummy_function()", + [] + ) + end + + test "deny create trigger on realtime.subscription", %{conn: conn} do + Postgrex.query!( + conn, + """ + CREATE OR REPLACE FUNCTION public.test_function() RETURNS trigger + LANGUAGE plpgsql SECURITY INVOKER AS $$ + BEGIN + RETURN NEW; + END $$ + """, + [] + ) + + assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} = + Postgrex.query( + conn, + "CREATE TRIGGER test_trigger AFTER INSERT OR UPDATE OR DELETE ON realtime.subscription FOR EACH ROW EXECUTE FUNCTION public.test_function()", + [] + ) + end + + test "supabase_realtime_admin cannot grant super to postgres", %{realtime_conn: realtime_conn} do + assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} = + Postgrex.query(realtime_conn, "ALTER ROLE postgres WITH SUPERUSER", []) + end + + test "deny alter function owner to postgres", %{conn: conn} do + assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} = + Postgrex.query( + conn, + "ALTER FUNCTION realtime.send(jsonb, text, text, boolean) OWNER TO postgres", + [] + ) + end + + test "deny create on realtime schema", %{conn: conn} do + assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} = + Postgrex.query(conn, "CREATE TABLE realtime.new_table (id int)", []) + end + + test "postgres is not a member of supabase_realtime_admin", %{conn: conn} do + assert %Postgrex.Result{rows: [[false]]} = + Postgrex.query!(conn, "SELECT pg_has_role('postgres', 'supabase_realtime_admin', 'MEMBER')", []) + end + + test "postgres cannot modify realtime.schema_migrations", %{conn: conn} do + assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} = + Postgrex.query( + conn, + "INSERT INTO realtime.schema_migrations (version, inserted_at) VALUES (0, now())", + [] + ) + + assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} = + Postgrex.query(conn, "DELETE FROM realtime.schema_migrations", []) + end + + test "postgres cannot create policy on realtime.schema_migrations", %{conn: conn} do + assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} = + Postgrex.query( + conn, + "CREATE POLICY sm_policy ON realtime.schema_migrations FOR SELECT TO authenticated USING (true)", + [] + ) + end + end + + describe "privileges" do + test "postgres can grant USAGE on schema realtime to a custom role", %{conn: conn} do + Postgrex.query!(conn, "CREATE ROLE role_test", []) + + assert {:ok, _} = Postgrex.query(conn, "GRANT USAGE ON SCHEMA realtime TO role_test", []) + + assert %Postgrex.Result{rows: [[true]]} = + Postgrex.query!(conn, "SELECT has_schema_privilege('role_test', 'realtime', 'USAGE')", []) + + Postgrex.query!(conn, "REVOKE USAGE ON SCHEMA realtime FROM role_test", []) + Postgrex.query!(conn, "DROP ROLE role_test", []) + end + + test "supabase_realtime_admin can create a role", %{realtime_conn: realtime_conn} do + role = "role_realtime_admin_create_#{System.unique_integer([:positive])}" + + assert {:ok, _} = Postgrex.query(realtime_conn, "CREATE ROLE #{role}", []) + + assert %Postgrex.Result{rows: [[true]]} = + Postgrex.query!(realtime_conn, "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", [role]) + end + + test "supabase_realtime_admin has NOINHERIT", %{realtime_conn: realtime_conn} do + assert %Postgrex.Result{rows: [[false]]} = + Postgrex.query!( + realtime_conn, + "SELECT rolinherit FROM pg_roles WHERE rolname = 'supabase_realtime_admin'", + [] + ) + end + + test "supabase_realtime_admin can SET ROLE to granted roles", %{realtime_conn: realtime_conn} do + for role <- ~w(anon authenticated service_role) do + assert {:ok, _} = Postgrex.query(realtime_conn, "SET ROLE #{role}", []) + Postgrex.query!(realtime_conn, "RESET ROLE", []) + end + end + + test "supabase_realtime_admin can drop a role", %{realtime_conn: realtime_conn} do + role = "role_realtime_admin_drop_#{System.unique_integer([:positive])}" + Postgrex.query!(realtime_conn, "CREATE ROLE #{role}", []) + + assert {:ok, _} = Postgrex.query(realtime_conn, "DROP ROLE #{role}", []) + + assert %Postgrex.Result{rows: [[false]]} = + Postgrex.query!(realtime_conn, "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", [role]) + end + + test "insert into realtime.messages", %{conn: conn} do + assert {:ok, %Postgrex.Result{num_rows: 1}} = + Postgrex.query( + conn, + "INSERT INTO realtime.messages (payload, event, topic, private, extension) VALUES ($1, $2, $3, $4, $5)", + [%{"hello" => "world"}, "test_event", "test_topic", false, "broadcast"] + ) + end + end + + describe "ownership" do + test "all objects in the realtime schema are owned by supabase_realtime_admin", %{superuser_conn: conn} do + query = """ + SELECT format('table %I.%I', n.nspname, c.relname), r.rolname FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_roles r ON r.oid = c.relowner + WHERE n.nspname = 'realtime' AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f') + AND c.relname <> 'schema_migrations' + AND r.rolname <> 'supabase_realtime_admin' + UNION ALL + SELECT format('function %I.%I', n.nspname, p.proname), r.rolname FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + JOIN pg_roles r ON r.oid = p.proowner + WHERE n.nspname = 'realtime' AND r.rolname <> 'supabase_realtime_admin' + UNION ALL + SELECT format('type %I.%I', n.nspname, t.typname), r.rolname FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + JOIN pg_roles r ON r.oid = t.typowner + WHERE n.nspname = 'realtime' AND t.typtype IN ('b', 'd', 'e', 'r', 'm') + AND t.typname <> '_schema_migrations' + AND r.rolname <> 'supabase_realtime_admin' + """ + + %Postgrex.Result{rows: offenders} = Postgrex.query!(conn, query, []) + + assert offenders == [], + "realtime objects not owned by supabase_realtime_admin (add `ALTER ... OWNER TO supabase_realtime_admin` to the migration):\n" <> + Enum.map_join(offenders, "\n", fn [object, owner] -> " - #{object} (owned by #{owner})" end) + end + + test "realtime schema is owned by supabase_admin", %{superuser_conn: conn} do + assert %Postgrex.Result{rows: [["supabase_admin"]]} = + Postgrex.query!( + conn, + "SELECT r.rolname FROM pg_namespace n JOIN pg_roles r ON r.oid = n.nspowner WHERE n.nspname = 'realtime'", + [] + ) + end + end + + describe "realtime.messages policy grants" do + test "create and drop SELECT policy", %{conn: conn} do + assert {:ok, _} = + Postgrex.query( + conn, + "CREATE POLICY messages_policy_select_test ON realtime.messages FOR SELECT TO authenticated USING (true)", + [] + ) + + assert {:ok, _} = Postgrex.query(conn, "DROP POLICY messages_policy_select_test ON realtime.messages", []) + end + + test "create and drop INSERT policy", %{conn: conn} do + assert {:ok, _} = + Postgrex.query( + conn, + "CREATE POLICY messages_policy_insert_test ON realtime.messages FOR INSERT TO authenticated WITH CHECK (true)", + [] + ) + + assert {:ok, _} = Postgrex.query(conn, "DROP POLICY messages_policy_insert_test ON realtime.messages", []) + end + + test "create and drop FOR ALL policy", %{conn: conn} do + assert {:ok, _} = + Postgrex.query( + conn, + "CREATE POLICY messages_policy ON realtime.messages FOR ALL TO authenticated USING (true) WITH CHECK (true)", + [] + ) + + assert {:ok, _} = Postgrex.query(conn, "DROP POLICY messages_policy ON realtime.messages", []) + end + + test "alter existing policy", %{conn: conn} do + Postgrex.query!( + conn, + "CREATE POLICY messages_policy_alter_test ON realtime.messages FOR SELECT TO authenticated USING (true)", + [] + ) + + assert {:ok, _} = + Postgrex.query( + conn, + "ALTER POLICY messages_policy_alter_test ON realtime.messages USING (auth.role() = 'authenticated')", + [] + ) + + Postgrex.query!(conn, "DROP POLICY messages_policy_alter_test ON realtime.messages", []) + end + end + + describe "realtime.subscription policy grants" do + test "create and drop SELECT policy", %{conn: conn} do + assert {:ok, _} = + Postgrex.query( + conn, + "CREATE POLICY subscription_policy_select ON realtime.subscription FOR SELECT TO authenticated USING (true)", + [] + ) + + assert {:ok, _} = Postgrex.query(conn, "DROP POLICY subscription_policy_select ON realtime.subscription", []) + end + + test "create and drop INSERT policy", %{conn: conn} do + assert {:ok, _} = + Postgrex.query( + conn, + "CREATE POLICY subscription_policy_insert ON realtime.subscription FOR INSERT TO authenticated WITH CHECK (true)", + [] + ) + + assert {:ok, _} = Postgrex.query(conn, "DROP POLICY subscription_policy_insert ON realtime.subscription", []) + end + + test "create and drop UPDATE policy", %{conn: conn} do + assert {:ok, _} = + Postgrex.query( + conn, + "CREATE POLICY subscription_policy_update ON realtime.subscription FOR UPDATE TO authenticated USING (true)", + [] + ) + + assert {:ok, _} = Postgrex.query(conn, "DROP POLICY subscription_policy_update ON realtime.subscription", []) + end + + test "create and drop DELETE policy", %{conn: conn} do + assert {:ok, _} = + Postgrex.query( + conn, + "CREATE POLICY subscription_policy_delete ON realtime.subscription FOR DELETE TO authenticated USING (true)", + [] + ) + + assert {:ok, _} = Postgrex.query(conn, "DROP POLICY subscription_policy_delete ON realtime.subscription", []) + end + + test "create and drop FOR ALL policy", %{conn: conn} do + assert {:ok, _} = + Postgrex.query( + conn, + "CREATE POLICY subscription_policy_all ON realtime.subscription FOR ALL TO authenticated USING (true) WITH CHECK (true)", + [] + ) + + assert {:ok, _} = Postgrex.query(conn, "DROP POLICY subscription_policy_all ON realtime.subscription", []) + end + + test "alter existing policy", %{conn: conn} do + Postgrex.query!( + conn, + "CREATE POLICY subscription_policy_alter_test ON realtime.subscription FOR SELECT TO authenticated USING (true)", + [] + ) + + assert {:ok, _} = + Postgrex.query( + conn, + "ALTER POLICY subscription_policy_alter_test ON realtime.subscription USING (auth.role() = 'authenticated')", + [] + ) + + Postgrex.query!(conn, "DROP POLICY subscription_policy_alter_test ON realtime.subscription", []) + end + end +end diff --git a/test/realtime/tenants/single_broadcast_test.exs b/test/realtime/tenants/single_broadcast_test.exs new file mode 100644 index 000000000..4fbf83c4c --- /dev/null +++ b/test/realtime/tenants/single_broadcast_test.exs @@ -0,0 +1,438 @@ +defmodule Realtime.Tenants.SingleBroadcastTest do + use RealtimeWeb.ConnCase, async: true + use Mimic + + alias Realtime.Database + alias Realtime.GenCounter + alias Realtime.RateCounter + alias Realtime.Tenants + alias Realtime.Tenants.SingleBroadcast + alias Realtime.Tenants.Authorization + alias Realtime.Tenants.Authorization.Policies + alias Realtime.Tenants.Authorization.Policies.BroadcastPolicies + alias Realtime.Tenants.Connect + + alias RealtimeWeb.TenantBroadcaster + alias RealtimeWeb.Socket.UserBroadcast + + setup do + tenant = Containers.checkout_tenant(run_migrations: true) + Realtime.Tenants.Cache.update_cache(tenant) + {:ok, tenant: tenant} + end + + describe "JSON public message broadcasting" do + test "broadcasts JSON public message successfully", %{tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + topic = random_string() + tenant_topic = Tenants.tenant_topic(tenant.external_id, topic) + event = "test-event" + payload = %{"text" => "hello", "user" => "alice"} + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + + expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, broadcast, _, _ -> + assert %UserBroadcast{ + topic: ^tenant_topic, + user_event: ^event, + user_payload: json, + user_payload_encoding: :json, + metadata: nil + } = broadcast + + assert IO.iodata_to_binary(json) == Jason.encode!(payload) + + :ok + end) + + assert :ok = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, event, false, payload, :json) + end + + test "public messages do not have private prefix in topic", %{tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + topic = random_string() + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + + expect(TenantBroadcaster, :pubsub_broadcast, fn _, tenant_topic, _, _, _ -> + refute String.contains?(tenant_topic, "-private") + :ok + end) + + assert :ok = + SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, %{"data" => "test"}, :json) + end + + test "JSON payload can be empty map", %{tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + topic = random_string() + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, _, _, _ -> :ok end) + + assert :ok = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, %{}, :json) + end + end + + describe "Binary public message broadcasting" do + test "broadcasts binary message successfully", %{tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + topic = random_string() + tenant_topic = Tenants.tenant_topic(tenant.external_id, topic) + event = "binary-event" + binary = <<1, 2, 3, 4, 5>> + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + + expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, broadcast, _, _ -> + assert %UserBroadcast{ + topic: ^tenant_topic, + user_event: ^event, + user_payload: ^binary, + user_payload_encoding: :binary, + metadata: nil + } = broadcast + + :ok + end) + + assert :ok = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, event, false, binary, :binary) + end + + test "binary payload can be empty", %{tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + topic = random_string() + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, _, _, _ -> :ok end) + + assert :ok = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, <<>>, :binary) + end + + test "handles large binary payloads within limit", %{tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + topic = random_string() + # Create binary well under the limit to account for erlang term overhead + # The max is in KB (1000 bytes per KB), plus 500 byte padding + binary = :crypto.strong_rand_bytes(tenant.max_payload_size_in_kb * 1000 - 100) + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, _, _, _ -> :ok end) + + assert :ok = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, binary, :binary) + end + end + + describe "JSON private message authorization" do + test "broadcasts private JSON message with valid authorization", %{tenant: tenant} do + topic = random_string() + sub = random_string() + role = "authenticated" + payload = %{"secret" => "data"} + + auth_params = + Authorization.build_authorization_params(%{ + tenant_id: tenant.external_id, + headers: [{"header-1", "value-1"}], + claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000}, + role: role, + sub: sub + }) + + broadcast_events_key = Tenants.events_per_second_key(tenant) + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + + expect(Authorization, :get_write_authorizations, fn _, _ -> + {:ok, %Policies{broadcast: %BroadcastPolicies{write: true}}} + end) + + expect(TenantBroadcaster, :pubsub_broadcast, fn _, tenant_topic, _, _, _ -> + assert String.contains?(tenant_topic, "-private") + :ok + end) + + assert :ok = SingleBroadcast.broadcast(auth_params, tenant, topic, "event", true, payload, :json) + end + + test "skips private JSON message without authorization", %{tenant: tenant} do + topic = random_string() + sub = random_string() + role = "anon" + + auth_params = + Authorization.build_authorization_params(%{ + tenant_id: tenant.external_id, + headers: [{"header-1", "value-1"}], + claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000}, + role: role, + sub: sub + }) + + expect(Authorization, :get_write_authorizations, fn _, _ -> + {:ok, %Policies{broadcast: %BroadcastPolicies{write: false}}} + end) + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + assert {:error, :forbidden, "Unauthorized"} = + SingleBroadcast.broadcast(auth_params, tenant, topic, "event", true, %{"data" => "test"}, :json) + + assert calls(&TenantBroadcaster.pubsub_broadcast/5) == [] + end + end + + describe "Binary private message authorization" do + test "broadcasts private binary message with valid authorization", %{tenant: tenant} do + topic = random_string() + sub = random_string() + role = "authenticated" + binary = <<255, 254, 253>> + + auth_params = + Authorization.build_authorization_params(%{ + tenant_id: tenant.external_id, + headers: [{"header-1", "value-1"}], + claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000}, + role: role, + sub: sub + }) + + broadcast_events_key = Tenants.events_per_second_key(tenant) + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + + expect(Authorization, :get_write_authorizations, fn _, _ -> + {:ok, %Policies{broadcast: %BroadcastPolicies{write: true}}} + end) + + expect(TenantBroadcaster, :pubsub_broadcast, fn _, tenant_topic, broadcast, _, _ -> + assert String.contains?(tenant_topic, "-private") + + assert %UserBroadcast{ + user_payload: ^binary, + user_payload_encoding: :binary + } = broadcast + + :ok + end) + + assert :ok = SingleBroadcast.broadcast(auth_params, tenant, topic, "event", true, binary, :binary) + end + + test "skips private binary message without authorization", %{tenant: tenant} do + topic = random_string() + sub = random_string() + role = "anon" + + auth_params = + Authorization.build_authorization_params(%{ + tenant_id: tenant.external_id, + headers: [{"header-1", "value-1"}], + claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000}, + role: role, + sub: sub + }) + + expect(Authorization, :get_write_authorizations, fn _, _ -> + {:ok, %Policies{broadcast: %BroadcastPolicies{write: false}}} + end) + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + assert {:error, :forbidden, "Unauthorized"} = + SingleBroadcast.broadcast(auth_params, tenant, topic, "event", true, <<1, 2, 3>>, :binary) + + assert calls(&TenantBroadcaster.pubsub_broadcast/5) == [] + end + end + + describe "message validation" do + test "returns changeset error when topic is empty", %{tenant: tenant} do + reject(&TenantBroadcaster.pubsub_broadcast/5) + + result = SingleBroadcast.broadcast(%Authorization{}, tenant, "", "event", false, %{"data" => "test"}, :json) + assert {:error, %Ecto.Changeset{valid?: false}} = result + end + + test "returns changeset error when event is empty", %{tenant: tenant} do + topic = random_string() + reject(&TenantBroadcaster.pubsub_broadcast/5) + + result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "", false, %{"data" => "test"}, :json) + assert {:error, %Ecto.Changeset{valid?: false}} = result + end + + test "returns changeset error when JSON payload is nil", %{tenant: tenant} do + topic = random_string() + reject(&TenantBroadcaster.pubsub_broadcast/5) + + result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, nil, :json) + assert {:error, %Ecto.Changeset{valid?: false}} = result + end + + test "returns changeset error when binary payload is nil", %{tenant: tenant} do + topic = random_string() + reject(&TenantBroadcaster.pubsub_broadcast/5) + + result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, nil, :binary) + assert {:error, %Ecto.Changeset{valid?: false}} = result + end + end + + describe "suspended tenant" do + test "does not broadcast when tenant is suspended", %{tenant: tenant} do + tenant = %{tenant | suspend: true} + topic = random_string() + reject(&TenantBroadcaster.pubsub_broadcast/5) + + result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, %{"data" => "test"}, :json) + assert {:error, :forbidden, "Tenant is suspended"} = result + assert calls(&TenantBroadcaster.pubsub_broadcast/5) == [] + end + end + + describe "rate limiting" do + test "rejects broadcast when rate limit is exceeded", %{tenant: tenant} do + events_per_second_rate = Tenants.events_per_second_rate(tenant) + topic = random_string() + + RateCounter + |> stub(:new, fn _ -> {:ok, nil} end) + |> stub(:get, fn ^events_per_second_rate -> {:ok, %RateCounter{avg: tenant.max_events_per_second + 1}} end) + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, %{"data" => "test"}, :json) + assert {:error, :too_many_requests, "You have exceeded your rate limit"} = result + end + + test "allows broadcast at rate limit boundary", %{tenant: tenant} do + events_per_second_rate = Tenants.events_per_second_rate(tenant) + broadcast_events_key = Tenants.events_per_second_key(tenant) + current_rate = tenant.max_events_per_second - 1 + + RateCounter + |> stub(:new, fn _ -> {:ok, nil} end) + |> stub(:get, fn ^events_per_second_rate -> + {:ok, %RateCounter{avg: current_rate}} + end) + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, _, _, _ -> :ok end) + + assert :ok = + SingleBroadcast.broadcast( + %Authorization{}, + tenant, + random_string(), + "event", + false, + %{"data" => "test"}, + :json + ) + end + + test "rejects JSON payload when size exceeds tenant limit", %{tenant: tenant} do + topic = random_string() + large_payload = %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)} + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, large_payload, :json) + + assert {:error, %Ecto.Changeset{valid?: false, errors: errors}} = result + assert {:payload, {"Payload size exceeds tenant limit", []}} in errors + end + + test "rejects binary payload when size exceeds tenant limit", %{tenant: tenant} do + topic = random_string() + large_binary = :crypto.strong_rand_bytes(tenant.max_payload_size_in_kb * 1024 + 1) + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, large_binary, :binary) + + assert {:error, %Ecto.Changeset{valid?: false, errors: errors}} = result + assert {:payload, {"Payload size exceeds tenant limit", []}} in errors + end + end + + describe "error handling" do + test "database connection errors for private messages returns error", %{tenant: tenant} do + topic = random_string() + sub = random_string() + role = "authenticated" + + auth_params = + Authorization.build_authorization_params(%{ + tenant_id: tenant.external_id, + headers: [{"header-1", "value-1"}], + claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000}, + role: role, + sub: sub + }) + + events_per_second_rate = Tenants.events_per_second_rate(tenant) + + RateCounter + |> stub(:new, fn _ -> {:ok, nil} end) + |> stub(:get, fn ^events_per_second_rate -> {:ok, %RateCounter{avg: 0}} end) + + expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :tenant_database_unavailable} end) + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + assert {:error, :unprocessable_entity, "Tenant database unavailable"} = + SingleBroadcast.broadcast(auth_params, tenant, topic, "event", true, %{"data" => "test"}, :json) + + assert calls(&TenantBroadcaster.pubsub_broadcast/5) == [] + end + end + + describe "integration with RLS policies" do + setup %{tenant: tenant} do + {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) + %{db_conn: db_conn} + end + + test "broadcasts private JSON message when RLS policy allows", %{tenant: tenant, db_conn: db_conn} do + topic = random_string() + sub = random_string() + role = "authenticated" + + create_rls_policies(db_conn, [:authenticated_write_broadcast], %{topic: topic}) + + auth_params = + Authorization.build_authorization_params(%{ + tenant_id: tenant.external_id, + headers: [{"header-1", "value-1"}], + claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000}, + role: role, + sub: sub + }) + + events_per_second_rate = Tenants.events_per_second_rate(tenant) + broadcast_events_key = Tenants.events_per_second_key(tenant) + + RateCounter + |> stub(:new, fn _ -> {:ok, nil} end) + |> stub(:get, fn + ^events_per_second_rate -> {:ok, %RateCounter{avg: 0}} + _ -> {:ok, %RateCounter{avg: 0}} + end) + + expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end) + expect(Connect, :lookup_or_start_connection, fn _ -> {:ok, db_conn} end) + + expect(Authorization, :get_write_authorizations, fn _, _ -> + {:ok, %Policies{broadcast: %BroadcastPolicies{write: true}}} + end) + + expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, _, _, _ -> :ok end) + + assert :ok = + SingleBroadcast.broadcast(auth_params, tenant, topic, "event", true, %{"secret" => "data"}, :json) + end + end +end diff --git a/test/realtime/tenants_test.exs b/test/realtime/tenants_test.exs index aefe0b86c..708654827 100644 --- a/test/realtime/tenants_test.exs +++ b/test/realtime/tenants_test.exs @@ -1,8 +1,8 @@ defmodule Realtime.TenantsTest do # async: false due to cache usage - alias Realtime.Tenants.Migrations use Realtime.DataCase, async: false + alias Realtime.Database alias Realtime.GenCounter alias Realtime.Tenants doctest Realtime.Tenants @@ -32,93 +32,6 @@ defmodule Realtime.TenantsTest do end end - describe "suspend_tenant_by_external_id/1" do - setup do - tenant = tenant_fixture() - :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id) - %{tenant: tenant} - end - - test "sets suspend flag to true and publishes message", %{tenant: %{external_id: external_id}} do - {:ok, tenant} = Tenants.suspend_tenant_by_external_id(external_id) - assert tenant.suspend == true - assert_receive :suspend_tenant, 500 - end - - test "does not publish message if if not targetted tenant", %{tenant: tenant} do - Tenants.suspend_tenant_by_external_id(tenant_fixture().external_id) - tenant = Repo.reload!(tenant) - assert tenant.suspend == false - refute_receive :suspend_tenant, 500 - end - end - - describe "unsuspend_tenant_by_external_id/1" do - setup do - tenant = tenant_fixture(%{suspend: true}) - :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id) - %{tenant: tenant} - end - - test "sets suspend flag to false and publishes message", %{tenant: tenant} do - {:ok, tenant} = Tenants.unsuspend_tenant_by_external_id(tenant.external_id) - assert tenant.suspend == false - assert_receive :unsuspend_tenant, 500 - end - - test "does not publish message if not targetted tenant", %{tenant: tenant} do - Tenants.unsuspend_tenant_by_external_id(tenant_fixture().external_id) - tenant = Repo.reload!(tenant) - assert tenant.suspend == true - refute_receive :unsuspend_tenant, 500 - end - end - - describe "run_migrations?/1" do - test "returns true if migrations_ran is lower than existing migrations" do - tenant = tenant_fixture(%{migrations_ran: 0}) - assert Tenants.run_migrations?(tenant) - - tenant = tenant_fixture(%{migrations_ran: Enum.count(Migrations.migrations()) - 1}) - assert Tenants.run_migrations?(tenant) - end - - test "returns false if migrations_ran is count of all migrations" do - tenant = tenant_fixture(%{migrations_ran: Enum.count(Migrations.migrations())}) - refute Tenants.run_migrations?(tenant) - end - end - - describe "update_migrations_ran/1" do - test "updates migrations_ran to the count of all migrations" do - tenant = tenant_fixture(%{migrations_ran: 0}) - Tenants.update_migrations_ran(tenant.external_id, 1) - tenant = Repo.reload!(tenant) - assert tenant.migrations_ran == 1 - end - end - - describe "broadcast_operation_event/2" do - setup do - tenant = tenant_fixture() - :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id) - %{tenant: tenant} - end - - test "broadcasts events to the targetted tenant", %{tenant: tenant} do - events = [ - :suspend_tenant, - :unsuspend_tenant, - :disconnect - ] - - for event <- events do - Tenants.broadcast_operation_event(event, tenant.external_id) - assert_receive ^event - end - end - end - describe "region/1" do test "returns the region of the tenant" do attrs = %{ @@ -130,7 +43,7 @@ defmodule Realtime.TenantsTest do "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => "supabase_realtime_admin", "db_password" => "postgres", "db_port" => "#{port()}", "poll_interval" => 100, @@ -165,4 +78,21 @@ defmodule Realtime.TenantsTest do assert Tenants.region(tenant) == nil end end + + describe "create_messages_partitions/1" do + test "running twice keeps the same partitions" do + tenant = Containers.checkout_tenant(run_migrations: true) + {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) + + assert :ok = Tenants.create_messages_partitions(conn) + assert :ok = Tenants.create_messages_partitions(conn) + + assert {:ok, %{rows: [[5]]}} = + Postgrex.query( + conn, + "SELECT count(*) FROM pg_inherits WHERE inhparent = 'realtime.messages'::regclass", + [] + ) + end + end end diff --git a/test/realtime/user_counter_test.exs b/test/realtime/user_counter_test.exs deleted file mode 100644 index d93529764..000000000 --- a/test/realtime/user_counter_test.exs +++ /dev/null @@ -1,74 +0,0 @@ -defmodule Realtime.UsersCounterTest do - use Realtime.DataCase, async: false - alias Realtime.UsersCounter - alias Realtime.Rpc - - describe "add/1" do - test "starts counter for tenant" do - assert UsersCounter.add(self(), random_string()) == :ok - end - end - - @aux_mod (quote do - defmodule Aux do - def ping(), - do: - spawn(fn -> - Process.sleep(3000) - :pong - end) - end - end) - - Code.eval_quoted(@aux_mod) - - describe "tenant_users/1" do - test "returns count of connected clients for tenant on cluster node" do - tenant_id = random_string() - expected = generate_load(tenant_id) - Process.sleep(1000) - assert UsersCounter.tenant_users(tenant_id) == expected - end - end - - describe "tenant_users/2" do - test "returns count of connected clients for tenant on target cluster" do - tenant_id = random_string() - generate_load(tenant_id) - {:ok, node} = Clustered.start(@aux_mod) - pid = Rpc.call(node, Aux, :ping, []) - UsersCounter.add(pid, tenant_id) - assert UsersCounter.tenant_users(node, tenant_id) == 1 - end - end - - defp generate_load(tenant_id, nodes \\ 2, processes \\ 2) do - for i <- 1..nodes do - # Avoid port collision - extra_config = [ - {:gen_rpc, :tcp_server_port, 15970 + i} - ] - - {:ok, node} = Clustered.start(@aux_mod, extra_config: extra_config, phoenix_port: 4012 + i) - - for _ <- 1..processes do - pid = Rpc.call(node, Aux, :ping, []) - - for _ <- 1..10 do - # replicate same pid added multiple times concurrently - Task.start(fn -> - UsersCounter.add(pid, tenant_id) - end) - - # noisy neighbors to test handling of bigger loads on concurrent calls - Task.start(fn -> - pid = Rpc.call(node, Aux, :ping, []) - UsersCounter.add(pid, random_string()) - end) - end - end - end - - nodes * processes - end -end diff --git a/test/realtime/users_counter_test.exs b/test/realtime/users_counter_test.exs new file mode 100644 index 000000000..8972b6de0 --- /dev/null +++ b/test/realtime/users_counter_test.exs @@ -0,0 +1,155 @@ +defmodule Realtime.UsersCounterTest do + use Realtime.DataCase, async: false + alias Realtime.UsersCounter + alias Realtime.Rpc + + setup_all do + tenant_id = random_string() + count = generate_load(tenant_id) + + %{tenant_id: tenant_id, count: count, nodes: Node.list()} + end + + describe "already_counted?/2" do + test "returns true if pid already counted for tenant", %{tenant_id: tenant_id} do + pid = self() + assert UsersCounter.add(pid, tenant_id) == :ok + assert UsersCounter.already_counted?(pid, tenant_id) == true + end + + test "returns false if pid not counted for tenant" do + assert UsersCounter.already_counted?(self(), random_string()) == false + end + end + + describe "add/1" do + test "starts counter for tenant" do + assert UsersCounter.add(self(), random_string()) == :ok + end + end + + describe "local_tenants/0" do + test "returns list of tenant ids with local connections" do + tenant_id = random_string() + assert UsersCounter.add(self(), tenant_id) == :ok + + tenants = UsersCounter.local_tenants() + assert is_list(tenants) + assert tenant_id in tenants + end + end + + @aux_mod (quote do + defmodule Aux do + def ping() do + spawn(fn -> Process.sleep(:infinity) end) + end + + def join(pid, group) do + UsersCounter.add(pid, group) + end + end + end) + + Code.eval_quoted(@aux_mod) + + describe "tenant_counts/0" do + test "map of tenant and number of users", %{tenant_id: tenant_id, count: expected} do + assert UsersCounter.add(self(), tenant_id) == :ok + Process.sleep(1000) + counts = UsersCounter.tenant_counts() + + assert counts[tenant_id] == expected + 1 + assert map_size(counts) >= 61 + + counts = Forum.Census.local_member_counts(:users) + + assert counts[tenant_id] == 1 + assert map_size(counts) >= 1 + + counts = Forum.Census.member_counts(:users) + + assert counts[tenant_id] == expected + 1 + assert map_size(counts) >= 61 + end + end + + describe "local_tenant_counts/0" do + test "map of tenant and number of users for local node only", %{tenant_id: tenant_id} do + assert UsersCounter.add(self(), tenant_id) == :ok + + my_counts = UsersCounter.local_tenant_counts() + # Only one connection from this test process on this node + assert my_counts == %{tenant_id => 1} + end + end + + describe "tenant_users/1" do + test "returns count of connected clients for tenant on cluster node", %{tenant_id: tenant_id, count: expected} do + Process.sleep(1000) + assert UsersCounter.tenant_users(tenant_id) == expected + end + end + + defp generate_load(tenant_id) do + processes = 2 + + gen_rpc_port = Application.fetch_env!(:gen_rpc, :tcp_server_port) + + nodes = %{ + node() => gen_rpc_port, + :"us_node@127.0.0.1" => 16980, + :"ap2_nodeX@127.0.0.1" => 16981, + :"ap2_nodeY@127.0.0.1" => 16982 + } + + regions = %{ + :"us_node@127.0.0.1" => "us-east-1", + :"ap2_nodeX@127.0.0.1" => "ap-southeast-2", + :"ap2_nodeY@127.0.0.1" => "ap-southeast-2" + } + + on_exit(fn -> Application.put_env(:gen_rpc, :client_config_per_node, {:internal, %{}}) end) + Application.put_env(:gen_rpc, :client_config_per_node, {:internal, nodes}) + + nodes + |> Enum.filter(fn {node, _port} -> node != Node.self() end) + |> Enum.with_index(1) + |> Enum.each(fn {{node, gen_rpc_port}, i} -> + # Avoid port collision + extra_config = [ + {:gen_rpc, :tcp_server_port, gen_rpc_port}, + {:gen_rpc, :client_config_per_node, {:internal, nodes}}, + {:realtime, :users_scope_broadcast_interval_in_ms, 100}, + {:realtime, :region, regions[node]} + ] + + node_name = + node + |> to_string() + |> String.split("@") + |> hd() + |> String.to_atom() + + {:ok, node} = Clustered.start(@aux_mod, name: node_name, extra_config: extra_config, phoenix_port: 4012 + i) + + for _ <- 1..processes do + pid = Rpc.call(node, Aux, :ping, []) + + for _ <- 1..10 do + # replicate same pid added multiple times concurrently + Task.start(fn -> + Rpc.call(node, Aux, :join, [pid, tenant_id]) + end) + + # noisy neighbors to test handling of bigger loads on concurrent calls + Task.start(fn -> + Rpc.call(node, Aux, :join, [pid, random_string()]) + end) + end + end + end) + + 3 * processes + end +end diff --git a/test/realtime_web/channels/auth/jwt_verification_test.exs b/test/realtime_web/channels/auth/jwt_verification_test.exs index b6255ee1f..47e90f8e4 100644 --- a/test/realtime_web/channels/auth/jwt_verification_test.exs +++ b/test/realtime_web/channels/auth/jwt_verification_test.exs @@ -148,6 +148,99 @@ defmodule RealtimeWeb.JwtVerificationTest do assert {:ok, _claims} = JwtVerification.verify(token, @jwt_secret, nil) end + test "rejects token with expired exp encoded as a string" do + signer = Joken.Signer.create(@alg, @jwt_secret) + + Mock.freeze() + current_time = Mock.current_time() + claim_val = to_string(current_time - 60) + + token = + Joken.generate_and_sign!( + %{"exp" => %Joken.Claim{generate: fn -> claim_val end}}, + %{}, + signer + ) + + assert {:error, [message: ^current_time, claim: "exp", claim_val: ^claim_val]} = + JwtVerification.verify(token, @jwt_secret, nil) + end + + test "rejects token with future exp encoded as a string" do + signer = Joken.Signer.create(@alg, @jwt_secret) + + Mock.freeze() + current_time = Mock.current_time() + claim_val = to_string(current_time + 1000) + + token = + Joken.generate_and_sign!( + %{"exp" => %Joken.Claim{generate: fn -> claim_val end}}, + %{}, + signer + ) + + assert {:error, [message: ^current_time, claim: "exp", claim_val: ^claim_val]} = + JwtVerification.verify(token, @jwt_secret, nil) + end + + test "rejects token with iat encoded as a string" do + signer = Joken.Signer.create(@alg, @jwt_secret) + + Mock.freeze() + current_time = Mock.current_time() + claim_val = to_string(current_time) + + token = + Joken.generate_and_sign!( + %{ + "exp" => %Joken.Claim{generate: fn -> current_time + 1000 end}, + "iat" => %Joken.Claim{generate: fn -> claim_val end} + }, + %{}, + signer + ) + + assert {:error, [message: "Invalid token", claim: "iat", claim_val: ^claim_val]} = + JwtVerification.verify(token, @jwt_secret, nil) + end + + test "accepts token with numeric exp and iat" do + signer = Joken.Signer.create(@alg, @jwt_secret) + + Mock.freeze() + current_time = Mock.current_time() + + token = + Joken.generate_and_sign!( + %{ + "exp" => %Joken.Claim{generate: fn -> current_time + 1000 end}, + "iat" => %Joken.Claim{generate: fn -> current_time end} + }, + %{}, + signer + ) + + assert {:ok, _claims} = JwtVerification.verify(token, @jwt_secret, nil) + end + + test "accepts token with exp but without iat" do + signer = Joken.Signer.create(@alg, @jwt_secret) + + Mock.freeze() + current_time = Mock.current_time() + + token = + Joken.generate_and_sign!( + %{"exp" => %Joken.Claim{generate: fn -> current_time + 1000 end}}, + %{}, + signer + ) + + assert {:ok, claims} = JwtVerification.verify(token, @jwt_secret, nil) + refute Map.has_key?(claims, "iat") + end + test "when token claims match expected claims from :jwt_claim_validators config" do Application.put_env(:realtime, :jwt_claim_validators, %{ "iss" => "Tester", @@ -376,5 +469,62 @@ defmodule RealtimeWeb.JwtVerificationTest do assert {:error, :error_generating_signer} = JwtVerification.verify(token, jwt_secret, jwks) end + + test "using Ed25519 JWK" do + # Generate Ed25519 key pair + {pub, priv} = :crypto.generate_key(:eddsa, :ed25519) + + jwk = %{ + "kty" => "OKP", + "crv" => "Ed25519", + "x" => Base.url_encode64(pub, padding: false), + "d" => Base.url_encode64(priv, padding: false), + "kid" => "ed-key-1" + } + + jwks = %{"keys" => [jwk]} + + signer = Joken.Signer.create("Ed25519", jwk, %{"kid" => "ed-key-1"}) + + Mock.freeze() + current_time = Mock.current_time() + + token = + Joken.generate_and_sign!( + %{"exp" => %Joken.Claim{generate: fn -> current_time + 100 end}}, + %{}, + signer + ) + + assert {:ok, _claims} = JwtVerification.verify(token, @jwt_secret, jwks) + end + + test "returns error for unsupported algorithm with kid and jwks" do + header = Base.url_encode64(Jason.encode!(%{"alg" => "PS256", "kid" => "key-1"}), padding: false) + claims = Base.url_encode64(Jason.encode!(%{"exp" => 9_999_999_999}), padding: false) + token = "#{header}.#{claims}.signature" + + jwks = %{"keys" => [%{"kty" => "RSA", "kid" => "key-1"}]} + + assert {:error, _} = JwtVerification.verify(token, @jwt_secret, jwks) + end + + test "falls back to jwt_secret when HS256 kid has no matching JWK" do + Mock.freeze() + current_time = Mock.current_time() + + signer = Joken.Signer.create("HS256", @jwt_secret) + + token = + Joken.generate_and_sign!( + %{"exp" => %Joken.Claim{generate: fn -> current_time + 100 end}}, + %{}, + signer + ) + + jwks = %{"keys" => [%{"kty" => "oct", "kid" => "wrong-kid"}]} + + assert {:ok, _claims} = JwtVerification.verify(token, @jwt_secret, jwks) + end end end diff --git a/test/realtime_web/channels/payloads/flexible_boolean_test.exs b/test/realtime_web/channels/payloads/flexible_boolean_test.exs new file mode 100644 index 000000000..cb0704ab4 --- /dev/null +++ b/test/realtime_web/channels/payloads/flexible_boolean_test.exs @@ -0,0 +1,72 @@ +defmodule RealtimeWeb.Channels.Payloads.FlexibleBooleanTest do + use ExUnit.Case, async: true + + alias RealtimeWeb.Channels.Payloads.FlexibleBoolean + + describe "type/0" do + test "returns :boolean" do + assert FlexibleBoolean.type() == :boolean + end + end + + describe "cast/1" do + test "casts boolean true as-is" do + assert FlexibleBoolean.cast(true) == {:ok, true} + end + + test "casts boolean false as-is" do + assert FlexibleBoolean.cast(false) == {:ok, false} + end + + test "casts string 'true' in any case to boolean true" do + assert FlexibleBoolean.cast("true") == {:ok, true} + assert FlexibleBoolean.cast("True") == {:ok, true} + assert FlexibleBoolean.cast("TRUE") == {:ok, true} + assert FlexibleBoolean.cast("tRuE") == {:ok, true} + end + + test "casts string 'false' in any case to boolean false" do + assert FlexibleBoolean.cast("false") == {:ok, false} + assert FlexibleBoolean.cast("False") == {:ok, false} + assert FlexibleBoolean.cast("FALSE") == {:ok, false} + assert FlexibleBoolean.cast("fAlSe") == {:ok, false} + end + + test "returns error for invalid string values" do + assert FlexibleBoolean.cast("test") == :error + assert FlexibleBoolean.cast("yes") == :error + assert FlexibleBoolean.cast("no") == :error + assert FlexibleBoolean.cast("1") == :error + assert FlexibleBoolean.cast("0") == :error + assert FlexibleBoolean.cast("") == :error + end + + test "returns error for non-boolean, non-string values" do + assert FlexibleBoolean.cast(1) == :error + assert FlexibleBoolean.cast(0) == :error + assert FlexibleBoolean.cast(nil) == :error + assert FlexibleBoolean.cast(%{}) == :error + assert FlexibleBoolean.cast([]) == :error + end + end + + describe "load/1" do + test "loads boolean values" do + assert FlexibleBoolean.load(true) == {:ok, true} + assert FlexibleBoolean.load(false) == {:ok, false} + end + end + + describe "dump/1" do + test "dumps boolean values" do + assert FlexibleBoolean.dump(true) == {:ok, true} + assert FlexibleBoolean.dump(false) == {:ok, false} + end + + test "returns error for non-boolean values" do + assert FlexibleBoolean.dump("true") == :error + assert FlexibleBoolean.dump(1) == :error + assert FlexibleBoolean.dump(nil) == :error + end + end +end diff --git a/test/realtime_web/channels/payloads/join_test.exs b/test/realtime_web/channels/payloads/join_test.exs index 32bf1b397..6c025b9bd 100644 --- a/test/realtime_web/channels/payloads/join_test.exs +++ b/test/realtime_web/channels/payloads/join_test.exs @@ -6,6 +6,7 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do alias RealtimeWeb.Channels.Payloads.Join alias RealtimeWeb.Channels.Payloads.Config alias RealtimeWeb.Channels.Payloads.Broadcast + alias RealtimeWeb.Channels.Payloads.Broadcast.Replay alias RealtimeWeb.Channels.Payloads.Presence alias RealtimeWeb.Channels.Payloads.PostgresChange @@ -17,7 +18,7 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do config = %{ "config" => %{ "private" => false, - "broadcast" => %{"ack" => false, "self" => false}, + "broadcast" => %{"ack" => false, "self" => false, "replay" => %{"since" => 1, "limit" => 10}}, "presence" => %{"enabled" => true, "key" => key}, "postgres_changes" => [ %{"event" => "INSERT", "schema" => "public", "table" => "users", "filter" => "id=eq.1"}, @@ -37,8 +38,9 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do postgres_changes: postgres_changes } = config - assert %Broadcast{ack: false, self: false} = broadcast + assert %Broadcast{ack: false, self: false, replay: replay} = broadcast assert %Presence{enabled: true, key: ^key} = presence + assert %Replay{since: 1, limit: 10} = replay assert [ %PostgresChange{event: "INSERT", schema: "public", table: "users", filter: "id=eq.1"}, @@ -56,6 +58,25 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do assert is_binary(key) end + test "presence key can be number" do + config = %{"config" => %{"presence" => %{"enabled" => true, "key" => 123}}} + + assert {:ok, %Join{config: %Config{presence: %Presence{key: key}}}} = Join.validate(config) + + assert key == 123 + end + + test "invalid replay" do + config = %{"config" => %{"broadcast" => %{"replay" => 123}}} + + assert { + :error, + :invalid_join_payload, + %{config: %{broadcast: %{replay: ["unable to parse, expected a map"]}}} + } = + Join.validate(config) + end + test "missing enabled presence defaults to true" do config = %{"config" => %{"presence" => %{}}} @@ -92,5 +113,202 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do user_token: ["unable to parse, expected string"] } end + + test "handles postgres changes with nil value in array as empty array" do + config = %{"config" => %{"postgres_changes" => [nil]}} + + assert {:ok, %Join{config: %Config{postgres_changes: []}}} = Join.validate(config) + end + + test "handles postgres changes as nil as empty array" do + config = %{"config" => %{"postgres_changes" => nil}} + + assert {:ok, %Join{config: %Config{postgres_changes: []}}} = Join.validate(config) + end + + test "accepts string 'true' for boolean fields" do + config = %{ + "config" => %{ + "private" => "true", + "broadcast" => %{"ack" => "true", "self" => "true"}, + "presence" => %{"enabled" => "true"} + } + } + + assert {:ok, %Join{config: config_result}} = Join.validate(config) + + assert %Config{ + private: true, + broadcast: %Broadcast{ack: true, self: true}, + presence: %Presence{enabled: true} + } = config_result + end + + test "accepts string 'True' for boolean fields" do + config = %{ + "config" => %{ + "private" => "True", + "broadcast" => %{"ack" => "True", "self" => "True"}, + "presence" => %{"enabled" => "True"} + } + } + + assert {:ok, %Join{config: config_result}} = Join.validate(config) + + assert %Config{ + private: true, + broadcast: %Broadcast{ack: true, self: true}, + presence: %Presence{enabled: true} + } = config_result + end + + test "accepts string 'false' for boolean fields" do + config = %{ + "config" => %{ + "private" => "false", + "broadcast" => %{"ack" => "false", "self" => "false"}, + "presence" => %{"enabled" => "false"} + } + } + + assert {:ok, %Join{config: config_result}} = Join.validate(config) + + assert %Config{ + private: false, + broadcast: %Broadcast{ack: false, self: false}, + presence: %Presence{enabled: false} + } = config_result + end + + test "accepts string 'False' for boolean fields" do + config = %{ + "config" => %{ + "private" => "False", + "broadcast" => %{"ack" => "False", "self" => "False"}, + "presence" => %{"enabled" => "False"} + } + } + + assert {:ok, %Join{config: config_result}} = Join.validate(config) + + assert %Config{ + private: false, + broadcast: %Broadcast{ack: false, self: false}, + presence: %Presence{enabled: false} + } = config_result + end + + test "rejects invalid boolean strings" do + config = %{ + "config" => %{ + "private" => "yes", + "broadcast" => %{"ack" => "a", "self" => "b"}, + "presence" => %{"enabled" => "no"} + } + } + + assert {:error, :invalid_join_payload, errors} = Join.validate(config) + + assert errors == %{ + config: %{ + private: ["unable to parse, expected boolean"], + broadcast: %{ + ack: ["unable to parse, expected boolean"], + self: ["unable to parse, expected boolean"] + }, + presence: %{enabled: ["unable to parse, expected boolean"]} + } + } + end + end + + describe "presence_enabled?/1" do + test "returns enabled value from config" do + join = %Join{config: %Config{presence: %Presence{enabled: false}}} + refute Join.presence_enabled?(join) + + join = %Join{config: %Config{presence: %Presence{enabled: true}}} + assert Join.presence_enabled?(join) + end + + test "defaults to true when config is nil" do + assert Join.presence_enabled?(%Join{config: nil}) + end + + test "defaults to true for non-Join struct" do + assert Join.presence_enabled?(nil) + end + end + + describe "presence_key/1" do + test "returns UUID when key is empty string" do + join = %Join{config: %Config{presence: %Presence{key: ""}}} + key = Join.presence_key(join) + assert is_binary(key) + assert key != "" + end + + test "returns the configured key" do + join = %Join{config: %Config{presence: %Presence{key: "my_key"}}} + assert Join.presence_key(join) == "my_key" + end + + test "returns UUID for non-matching struct" do + key = Join.presence_key(%Join{config: nil}) + assert is_binary(key) + assert key != "" + end + end + + describe "ack_broadcast?/1" do + test "returns ack value from config" do + join = %Join{config: %Config{broadcast: %Broadcast{ack: true}}} + assert Join.ack_broadcast?(join) + + join = %Join{config: %Config{broadcast: %Broadcast{ack: false}}} + refute Join.ack_broadcast?(join) + end + + test "defaults to false when config is nil" do + refute Join.ack_broadcast?(%Join{config: nil}) + end + end + + describe "self_broadcast?/1" do + test "returns self value from config" do + join = %Join{config: %Config{broadcast: %Broadcast{self: true}}} + assert Join.self_broadcast?(join) + + join = %Join{config: %Config{broadcast: %Broadcast{self: false}}} + refute Join.self_broadcast?(join) + end + + test "defaults to false when config is nil" do + refute Join.self_broadcast?(%Join{config: nil}) + end + end + + describe "private?/1" do + test "returns private value from config" do + join = %Join{config: %Config{private: true}} + assert Join.private?(join) + + join = %Join{config: %Config{private: false}} + refute Join.private?(join) + end + + test "defaults to false when config is nil" do + refute Join.private?(%Join{config: nil}) + end + end + + describe "error_message/2" do + test "returns message with type when type is present" do + assert Join.error_message(:field, type: :string) == "unable to parse, expected string" + end + + test "returns generic message when type is not present" do + assert Join.error_message(:field, []) == "unable to parse" + end end end diff --git a/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs b/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs index 2cd7005df..3b6065d9d 100644 --- a/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs +++ b/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs @@ -1,5 +1,8 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do - use Realtime.DataCase, async: true + use Realtime.DataCase, + async: true, + parameterize: [%{serializer: Phoenix.Socket.V1.JSONSerializer}, %{serializer: RealtimeWeb.Socket.V2Serializer}] + use Mimic import Generators @@ -17,26 +20,27 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do setup [:initiate_tenant] + @payload %{"a" => "b"} + describe "handle/3" do - test "with write true policy, user is able to send message", %{topic: topic, tenant: tenant, db_conn: db_conn} do + test "with write true policy, user is able to send message", + %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do socket = socket_fixture(tenant, topic, policies: %Policies{broadcast: %BroadcastPolicies{write: true}}) for _ <- 1..100, reduce: socket do socket -> - {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket) + {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket) socket end - Process.sleep(120) - for _ <- 1..100 do topic = "realtime:#{topic}" assert_receive {:socket_push, :text, data} - message = data |> IO.iodata_to_binary() |> Jason.decode!() - assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic} + + assert Jason.decode!(data) == message(serializer, topic, @payload) end - {:ok, %{avg: avg, bucket: buckets}} = RateCounter.get(Tenants.events_per_second_rate(tenant)) + {:ok, %{avg: avg, bucket: buckets}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant)) assert Enum.sum(buckets) == 100 assert avg > 0 end @@ -50,40 +54,37 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do socket end - Process.sleep(120) - refute_received _any - {:ok, %{avg: avg}} = RateCounter.get(Tenants.events_per_second_rate(tenant)) + {:ok, %{avg: avg}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant)) assert avg == 0.0 end @tag policies: [:authenticated_read_broadcast, :authenticated_write_broadcast] - test "with nil policy but valid user, is able to send message", %{topic: topic, tenant: tenant, db_conn: db_conn} do + test "with nil policy but valid user, is able to send message", + %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do socket = socket_fixture(tenant, topic) for _ <- 1..100, reduce: socket do socket -> - {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket) + {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket) socket end - Process.sleep(120) - for _ <- 1..100 do topic = "realtime:#{topic}" assert_received {:socket_push, :text, data} - message = data |> IO.iodata_to_binary() |> Jason.decode!() - assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic} + assert Jason.decode!(data) == message(serializer, topic, @payload) end - {:ok, %{avg: avg, bucket: buckets}} = RateCounter.get(Tenants.events_per_second_rate(tenant)) + {:ok, %{avg: avg, bucket: buckets}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant)) assert Enum.sum(buckets) == 100 assert avg > 0.0 end @tag policies: [:authenticated_read_matching_user_sub, :authenticated_write_matching_user_sub], sub: UUID.generate() - test "with valid sub, is able to send message", %{topic: topic, tenant: tenant, db_conn: db_conn, sub: sub} do + test "with valid sub, is able to send message", + %{topic: topic, tenant: tenant, db_conn: db_conn, sub: sub, serializer: serializer} do socket = socket_fixture(tenant, topic, policies: %Policies{broadcast: %BroadcastPolicies{write: nil, read: true}}, @@ -92,17 +93,14 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do for _ <- 1..100, reduce: socket do socket -> - {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket) + {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket) socket end - Process.sleep(120) - for _ <- 1..100 do topic = "realtime:#{topic}" assert_received {:socket_push, :text, data} - message = data |> IO.iodata_to_binary() |> Jason.decode!() - assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic} + assert Jason.decode!(data) == message(serializer, topic, @payload) end end @@ -120,13 +118,12 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do socket end - Process.sleep(120) - - refute_received {:socket_push, :text, _} + refute_receive {:socket_push, :text, _}, 120 end @tag policies: [:read_matching_user_role, :write_matching_user_role], role: "anon" - test "with valid role, is able to send message", %{topic: topic, tenant: tenant, db_conn: db_conn} do + test "with valid role, is able to send message", + %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do socket = socket_fixture(tenant, topic, policies: %Policies{broadcast: %BroadcastPolicies{write: nil, read: true}}, @@ -135,17 +132,14 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do for _ <- 1..100, reduce: socket do socket -> - {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket) + {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket) socket end - Process.sleep(120) - for _ <- 1..100 do topic = "realtime:#{topic}" assert_received {:socket_push, :text, data} - message = data |> IO.iodata_to_binary() |> Jason.decode!() - assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic} + assert Jason.decode!(data) == message(serializer, topic, @payload) end end @@ -163,9 +157,7 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do socket end - Process.sleep(120) - - refute_received {:socket_push, :text, _} + refute_receive {:socket_push, :text, _}, 120 end test "with nil policy and invalid user, won't send message", %{topic: topic, tenant: tenant, db_conn: db_conn} do @@ -177,16 +169,15 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do socket end - Process.sleep(120) - refute_received _any - {:ok, %{avg: avg}} = RateCounter.get(Tenants.events_per_second_rate(tenant)) + {:ok, %{avg: avg}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant)) assert avg == 0.0 end @tag policies: [:authenticated_read_broadcast, :authenticated_write_broadcast] - test "validation only runs once on nil and valid policies", %{topic: topic, tenant: tenant, db_conn: db_conn} do + test "validation only runs once on nil and valid policies", + %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do socket = socket_fixture(tenant, topic) expect(Authorization, :get_write_authorizations, 1, fn conn, db_conn, auth_context -> @@ -197,15 +188,14 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do for _ <- 1..100, reduce: socket do socket -> - {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket) + {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket) socket end for _ <- 1..100 do topic = "realtime:#{topic}" assert_receive {:socket_push, :text, data} - message = data |> IO.iodata_to_binary() |> Jason.decode!() - assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic} + assert Jason.decode!(data) == message(serializer, topic, @payload) end end @@ -222,12 +212,10 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do socket end - Process.sleep(100) - - refute_received _ + refute_receive _, 100 end - test "no ack still sends message", %{topic: topic, tenant: tenant, db_conn: db_conn} do + test "no ack still sends message", %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do socket = socket_fixture(tenant, topic, policies: %Policies{broadcast: %BroadcastPolicies{write: true}}, @@ -236,7 +224,7 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do for _ <- 1..100, reduce: socket do socket -> - {:noreply, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket) + {:noreply, socket} = BroadcastHandler.handle(@payload, db_conn, socket) socket end @@ -245,56 +233,142 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do for _ <- 1..100 do topic = "realtime:#{topic}" assert_received {:socket_push, :text, data} - message = data |> IO.iodata_to_binary() |> Jason.decode!() - assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic} + assert Jason.decode!(data) == message(serializer, topic, @payload) end end - test "public channels are able to send messages", %{topic: topic, tenant: tenant, db_conn: db_conn} do + test "public channels are able to send messages", + %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do socket = socket_fixture(tenant, topic, private?: false, policies: nil) for _ <- 1..100, reduce: socket do socket -> - {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket) + {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket) socket end - Process.sleep(120) - for _ <- 1..100 do topic = "realtime:#{topic}" assert_received {:socket_push, :text, data} - message = data |> IO.iodata_to_binary() |> Jason.decode!() - assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic} + assert Jason.decode!(data) == message(serializer, topic, @payload) end - {:ok, %{avg: avg, bucket: buckets}} = RateCounter.get(Tenants.events_per_second_rate(tenant)) + {:ok, %{avg: avg, bucket: buckets}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant)) assert Enum.sum(buckets) == 100 assert avg > 0.0 end - test "public channels are able to send messages and ack", %{topic: topic, tenant: tenant, db_conn: db_conn} do + test "public channels are able to send messages and ack", + %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do socket = socket_fixture(tenant, topic, private?: false, policies: nil) for _ <- 1..100, reduce: socket do socket -> - {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket) + {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket) socket end for _ <- 1..100 do topic = "realtime:#{topic}" assert_receive {:socket_push, :text, data} - message = data |> IO.iodata_to_binary() |> Jason.decode!() - assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic} + assert Jason.decode!(data) == message(serializer, topic, @payload) end - Process.sleep(120) - {:ok, %{avg: avg, bucket: buckets}} = RateCounter.get(Tenants.events_per_second_rate(tenant)) + {:ok, %{avg: avg, bucket: buckets}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant)) assert Enum.sum(buckets) == 100 assert avg > 0.0 end + test "V2 json UserBroadcastPush", %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do + socket = socket_fixture(tenant, topic, private?: false, policies: nil) + + user_broadcast_payload = %{"a" => "b"} + json_encoded_user_broadcast_payload = Jason.encode!(user_broadcast_payload) + + {:reply, :ok, _socket} = + BroadcastHandler.handle({"event123", :json, json_encoded_user_broadcast_payload, %{}}, db_conn, socket) + + topic = "realtime:#{topic}" + assert_receive {:socket_push, code, data} + + if serializer == RealtimeWeb.Socket.V2Serializer do + assert code == :binary + + assert data == + << + # user broadcast = 4 + 4::size(8), + # topic_size + byte_size(topic), + # user_event_size + byte_size("event123"), + # metadata_size + 0, + # json encoding + 1::size(8), + topic::binary, + "event123" + >> <> json_encoded_user_broadcast_payload + else + assert code == :text + + assert Jason.decode!(data) == + message(serializer, topic, %{ + "event" => "event123", + "payload" => user_broadcast_payload, + "type" => "broadcast" + }) + end + end + + test "V2 binary UserBroadcastPush", %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do + socket = socket_fixture(tenant, topic, private?: false, policies: nil) + + user_broadcast_payload = <<123, 456, 789>> + + {:reply, :ok, _socket} = + BroadcastHandler.handle({"event123", :binary, user_broadcast_payload, %{}}, db_conn, socket) + + topic = "realtime:#{topic}" + + if serializer == RealtimeWeb.Socket.V2Serializer do + assert_receive {:socket_push, :binary, data} + + assert data == + << + # user broadcast = 4 + 4::size(8), + # topic_size + byte_size(topic), + # user_event_size + byte_size("event123"), + # metadata_size + 0, + # binary encoding + 0::size(8), + topic::binary, + "event123" + >> <> user_broadcast_payload + else + # Can't receive binary payloads on V1 serializer + refute_receive {:socket_push, _code, _data} + end + end + + test "increase_connection_pool from write authorization does not log UnableToSetPolicies", + %{topic: topic, tenant: tenant, db_conn: db_conn} do + socket = socket_fixture(tenant, topic) + + stub(Authorization, :get_write_authorizations, fn _, _, _ -> {:error, :increase_connection_pool} end) + + log = + capture_log(fn -> + {:noreply, _socket} = BroadcastHandler.handle(%{}, db_conn, socket) + end) + + refute log =~ "UnableToSetPolicies" + end + @tag policies: [:broken_write_presence] test "handle failing rls policy", %{topic: topic, tenant: tenant, db_conn: db_conn} do socket = socket_fixture(tenant, topic) @@ -303,14 +377,81 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do capture_log(fn -> {:noreply, _socket} = BroadcastHandler.handle(%{}, db_conn, socket) - # Enough for the RateCounter to calculate the last bucket - refute_received _, 1200 + {:ok, %{avg: avg}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant)) + assert avg == 0.0 + + refute_receive _, 200 end) assert log =~ "RlsPolicyError" + end - {:ok, %{avg: avg}} = RateCounter.get(Tenants.events_per_second_rate(tenant)) - assert avg == 0.0 + test "handle payload size excedding limits in private channels", %{topic: topic, tenant: tenant, db_conn: db_conn} do + socket = + socket_fixture(tenant, topic, + policies: %Policies{broadcast: %BroadcastPolicies{write: true}}, + ack_broadcast: false + ) + + assert {:noreply, _} = + BroadcastHandler.handle( + %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)}, + db_conn, + socket + ) + + refute_receive {:socket_push, :text, _}, 120 + end + + test "handle payload size excedding limits in public channels", %{topic: topic, tenant: tenant, db_conn: db_conn} do + socket = socket_fixture(tenant, topic, ack_broadcast: false, private?: false) + + assert {:noreply, _} = + BroadcastHandler.handle( + %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)}, + db_conn, + socket + ) + + refute_receive {:socket_push, :text, _}, 120 + end + + test "handle payload size excedding limits in private channel and if ack it will receive error", %{ + topic: topic, + tenant: tenant, + db_conn: db_conn + } do + socket = + socket_fixture(tenant, topic, + policies: %Policies{broadcast: %BroadcastPolicies{write: true}}, + ack_broadcast: true + ) + + assert {:reply, {:error, :payload_size_exceeded}, _} = + BroadcastHandler.handle( + %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)}, + db_conn, + socket + ) + + refute_receive {:socket_push, :text, _}, 120 + end + + test "handle payload size excedding limits in public channels and if ack it will receive error", %{ + topic: topic, + tenant: tenant, + db_conn: db_conn + } do + socket = socket_fixture(tenant, topic, ack_broadcast: true, private?: false) + + assert {:reply, {:error, :payload_size_exceeded}, _} = + BroadcastHandler.handle( + %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)}, + db_conn, + socket + ) + + refute_receive {:socket_push, :text, _}, 120 end end @@ -318,7 +459,7 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do tenant = Containers.checkout_tenant(run_migrations: true) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) rate = Tenants.events_per_second_rate(tenant) RateCounter.new(rate, tick: 100) @@ -331,7 +472,7 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do fastlane = RealtimeWeb.RealtimeChannel.MessageDispatcher.fastlane_metadata( self(), - Phoenix.Socket.V1.JSONSerializer, + context.serializer, "realtime:#{topic}", :warning, "tenant_id" @@ -389,4 +530,10 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do } } end + + defp message(RealtimeWeb.Socket.V2Serializer, topic, payload), do: [nil, nil, topic, "broadcast", payload] + + defp message(Phoenix.Socket.V1.JSONSerializer, topic, payload) do + %{"event" => "broadcast", "payload" => payload, "ref" => nil, "topic" => topic} + end end diff --git a/test/realtime_web/channels/realtime_channel/logging_test.exs b/test/realtime_web/channels/realtime_channel/logging_test.exs index 92634daef..e96f2edcc 100644 --- a/test/realtime_web/channels/realtime_channel/logging_test.exs +++ b/test/realtime_web/channels/realtime_channel/logging_test.exs @@ -1,5 +1,5 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do - # async: false due to changes in Logger levels + # async: false due to changes in Logger levels and shared Cachex state use Realtime.DataCase, async: false import ExUnit.CaptureLog alias RealtimeWeb.RealtimeChannel.Logging @@ -11,6 +11,7 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do level = Logger.level() Logger.configure(level: :info) tenant = tenant_fixture() + Cachex.clear(Realtime.LogThrottle) on_exit(fn -> :telemetry.detach(__MODULE__) @@ -37,6 +38,7 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do assert log =~ "sub=#{sub}" assert log =~ "exp=#{exp}" assert log =~ "iss=#{iss}" + assert log =~ "error_code=TestError" end end @@ -57,20 +59,25 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do assert log =~ "sub=#{sub}" assert log =~ "exp=#{exp}" assert log =~ "iss=#{iss}" + assert log =~ "error_code=TestWarning" end end - describe "maybe_log_error/3" do + describe "maybe_log_error/4" do test "logs error message when log_level is less or equal to error" do log_levels = [:debug, :info, :warning, :error] for log_level <- log_levels do socket = %{assigns: %{log_level: log_level, tenant: random_string(), access_token: "test_token"}} - assert capture_log(fn -> - assert Logging.maybe_log_error(socket, "TestCode", "test message") == - {:error, %{reason: "TestCode: test message"}} - end) =~ "TestCode: test message" + log = + capture_log(fn -> + assert Logging.maybe_log_error(socket, "TestCode", "test message") == + {:error, %{reason: "TestCode: test message"}} + end) + + assert log =~ "TestCode: test message" + assert log =~ "error_code=TestCode" assert capture_log(fn -> assert Logging.maybe_log_error(socket, "TestCode", %{a: "b"}) == @@ -96,18 +103,21 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do end end - describe "maybe_log_warning/3" do - test "logs error message when log_level is less or equal to warning" do + describe "maybe_log_warning/4" do + test "logs warning message when log_level is less or equal to warning" do log_levels = [:debug, :info, :warning] for log_level <- log_levels do socket = %{assigns: %{log_level: log_level, tenant: random_string(), access_token: "test_token"}} - assert capture_log(fn -> - assert Logging.maybe_log_warning(socket, "TestCode", "test message") == - {:error, %{reason: "TestCode: test message"}} - end) =~ - "TestCode: test message" + log = + capture_log(fn -> + assert Logging.maybe_log_warning(socket, "TestCode", "test message") == + {:error, %{reason: "TestCode: test message"}} + end) + + assert log =~ "TestCode: test message" + assert log =~ "error_code=TestCode" assert capture_log(fn -> assert Logging.maybe_log_warning(socket, "TestCode", %{a: "b"}) == @@ -151,20 +161,19 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do end end - test "emits telemetry for system errors" do - socket = %{assigns: %{log_level: :error, tenant: random_string(), access_token: "test_token"}} - - for error <- Logging.system_errors() do - assert Logging.maybe_log_error(socket, error, "test error") == - {:error, %{reason: "#{error}: test error"}} + test "emits telemetry for errors with tenant metadata" do + tenant_id = random_string() + socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}} + error = "TestError" - assert_receive {[:realtime, :channel, :error], %{code: ^error}, %{code: ^error}} - end + assert Logging.maybe_log_error(socket, error, "test error") == {:error, %{reason: "#{error}: test error"}} + assert_receive {[:realtime, :channel, :error], %{count: 1}, %{code: ^error, tenant: ^tenant_id}} - assert Logging.maybe_log_error(socket, "TestError", "test error") == - {:error, %{reason: "TestError: test error"}} + assert Logging.maybe_log_warning(socket, error, "test error") == {:error, %{reason: "#{error}: test error"}} + refute_receive {[:realtime, :channel, :error], %{count: 1}, %{code: ^error, tenant: ^tenant_id}} - refute_receive {[:realtime, :channel, :error], :_, :_} + assert Logging.maybe_log_info(socket, "test error") == :ok + refute_receive {[:realtime, :channel, :error], %{count: 1}, %{code: ^error, tenant: ^tenant_id}} end test "logs include JWT claims in metadata", %{tenant: tenant} do @@ -186,4 +195,85 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do log = capture_log(fn -> Logging.maybe_log_error(socket, "TestError", "test error") end) assert log =~ tenant_id end + + describe "throttle option" do + test "logs exactly max_count times within the window but always emits telemetry" do + tenant_id = random_string() + socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}} + + logs = + capture_log(fn -> + for _ <- 1..5 do + Logging.maybe_log_error(socket, "ThrottleCode", "msg", throttle: {3, :timer.seconds(60)}) + end + end) + + assert logs |> String.split("ThrottleCode: msg") |> length() == 4 + + for _ <- 1..5 do + assert_receive {[:realtime, :channel, :error], %{count: 1}, %{code: "ThrottleCode", tenant: ^tenant_id}} + end + end + + test "still returns {:error, reason} even when throttled" do + tenant_id = random_string() + socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}} + + for _ <- 1..5 do + assert Logging.maybe_log_error(socket, "ThrottleCode", "msg", throttle: {2, :timer.seconds(60)}) == + {:error, %{reason: "ThrottleCode: msg"}} + end + end + + test "resets after the window expires" do + tenant_id = random_string() + socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}} + + logs_before = + capture_log(fn -> + for _ <- 1..3, do: Logging.maybe_log_error(socket, "WindowCode", "msg", throttle: {2, 200}) + end) + + assert logs_before |> String.split("WindowCode: msg") |> length() == 3 + + Process.sleep(400) + + logs_after = + capture_log(fn -> + for _ <- 1..3, do: Logging.maybe_log_error(socket, "WindowCode", "msg", throttle: {2, 200}) + end) + + assert logs_after |> String.split("WindowCode: msg") |> length() == 3 + end + + test "different tenant+code pairs have independent counters" do + socket_a = %{assigns: %{log_level: :error, tenant: random_string(), access_token: "t"}} + socket_b = %{assigns: %{log_level: :error, tenant: random_string(), access_token: "t"}} + + logs = + capture_log(fn -> + for _ <- 1..3 do + Logging.maybe_log_error(socket_a, "CodeA", "msg", throttle: {2, :timer.seconds(60)}) + Logging.maybe_log_error(socket_b, "CodeB", "msg", throttle: {2, :timer.seconds(60)}) + end + end) + + assert logs |> String.split("CodeA: msg") |> length() == 3 + assert logs |> String.split("CodeB: msg") |> length() == 3 + end + + test "callers do not exceed max_count" do + tenant_id = random_string() + socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}} + + logs = + capture_log(fn -> + for _ <- 1..20 do + Logging.maybe_log_error(socket, "ConcurrentCode", "msg", throttle: {5, :timer.seconds(60)}) + end + end) + + assert logs |> String.split("ConcurrentCode: msg") |> length() == 6 + end + end end diff --git a/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs b/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs index 7a9e2eb25..1f1101bc2 100644 --- a/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs +++ b/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs @@ -4,7 +4,10 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do import ExUnit.CaptureLog alias Phoenix.Socket.Broadcast + alias Phoenix.Socket.V1 alias RealtimeWeb.RealtimeChannel.MessageDispatcher + alias RealtimeWeb.Socket.UserBroadcast + alias RealtimeWeb.Socket.V2Serializer defmodule TestSerializer do def fastlane!(msg) do @@ -16,18 +19,35 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do describe "fastlane_metadata/5" do test "info level" do assert MessageDispatcher.fastlane_metadata(self(), Serializer, "realtime:topic", :info, "tenant_id") == - {:realtime_channel_fastlane, self(), Serializer, "realtime:topic", {:log, "tenant_id"}} + {:rc_fastlane, self(), Serializer, "realtime:topic", :info, "tenant_id", MapSet.new()} end test "non-info level" do assert MessageDispatcher.fastlane_metadata(self(), Serializer, "realtime:topic", :warning, "tenant_id") == - {:realtime_channel_fastlane, self(), Serializer, "realtime:topic"} + {:rc_fastlane, self(), Serializer, "realtime:topic", :warning, "tenant_id", MapSet.new()} + end + + test "replayed message ids" do + assert MessageDispatcher.fastlane_metadata( + self(), + Serializer, + "realtime:topic", + :warning, + "tenant_id", + MapSet.new([1]) + ) == + {:rc_fastlane, self(), Serializer, "realtime:topic", :warning, "tenant_id", MapSet.new([1])} end end describe "dispatch/3" do setup do - {:ok, _pid} = Agent.start_link(fn -> 0 end, name: TestSerializer) + {:ok, _pid} = + start_supervised(%{ + id: TestSerializer, + start: {Agent, :start_link, [fn -> 0 end, [name: TestSerializer]]} + }) + :ok end @@ -50,12 +70,11 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do from_pid = :erlang.list_to_pid(~c'<0.2.1>') subscribers = [ - {subscriber_pid, {:realtime_channel_fastlane, self(), TestSerializer, "realtime:topic", {:log, "tenant123"}}}, - {subscriber_pid, {:realtime_channel_fastlane, self(), TestSerializer, "realtime:topic"}} + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant123", MapSet.new()}} ] msg = %Broadcast{topic: "some:other:topic", event: "event", payload: %{data: "test"}} - require Logger log = capture_log(fn -> @@ -75,6 +94,224 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do refute_receive _any end + test "dispatches 'presence_diff' messages to fastlane subscribers" do + parent = self() + + subscriber_pid = + spawn(fn -> + loop = fn loop -> + receive do + msg -> + send(parent, {:subscriber, msg}) + loop.(loop) + end + end + + loop.(loop) + end) + + from_pid = :erlang.list_to_pid(~c'<0.2.1>') + + subscribers = [ + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant456", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant456", MapSet.new()}} + ] + + msg = %Broadcast{topic: "some:other:topic", event: "presence_diff", payload: %{data: "test"}} + + log = + capture_log(fn -> + assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok + end) + + assert log =~ "Received message on realtime:topic with payload: #{inspect(msg, pretty: true)}" + + assert_receive {:encoded, %Broadcast{event: "presence_diff", payload: %{data: "test"}, topic: "realtime:topic"}} + assert_receive {:encoded, %Broadcast{event: "presence_diff", payload: %{data: "test"}, topic: "realtime:topic"}} + + assert Agent.get(TestSerializer, & &1) == 1 + + assert Realtime.GenCounter.get(Realtime.Tenants.presence_events_per_second_key("tenant456")) == 2 + + refute_receive _any + end + + test "does not dispatch messages to fastlane subscribers if they already replayed it" do + parent = self() + + subscriber_pid = + spawn(fn -> + loop = fn loop -> + receive do + msg -> + send(parent, {:subscriber, msg}) + loop.(loop) + end + end + + loop.(loop) + end) + + from_pid = :erlang.list_to_pid(~c'<0.2.1>') + replaeyd_message_ids = MapSet.new(["123"]) + + subscribers = [ + {subscriber_pid, + {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant123", replaeyd_message_ids}}, + {subscriber_pid, + {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant123", replaeyd_message_ids}} + ] + + msg = %Broadcast{ + topic: "some:other:topic", + event: "event", + payload: %{"data" => "test", "meta" => %{"id" => "123"}} + } + + assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok + + assert Agent.get(TestSerializer, & &1) == 0 + + refute_receive _any + end + + test "does not dispatch UserBroadcast to fastlane subscribers if they already replayed it" do + parent = self() + + subscriber_pid = + spawn(fn -> + loop = fn loop -> + receive do + msg -> + send(parent, {:subscriber, msg}) + loop.(loop) + end + end + + loop.(loop) + end) + + from_pid = :erlang.list_to_pid(~c'<0.2.1>') + replayed_message_ids = MapSet.new(["abc"]) + + subscribers = [ + {subscriber_pid, + {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant123", replayed_message_ids}}, + {subscriber_pid, + {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant123", replayed_message_ids}} + ] + + msg = %UserBroadcast{ + topic: "some:other:topic", + user_event: "event", + user_payload: Jason.encode!(%{data: "test"}), + user_payload_encoding: :json, + metadata: %{"id" => "abc"} + } + + assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok + + assert Agent.get(TestSerializer, & &1) == 0 + + refute_receive _any + end + + test "payload is not a map" do + parent = self() + + subscriber_pid = + spawn(fn -> + loop = fn loop -> + receive do + msg -> + send(parent, {:subscriber, msg}) + loop.(loop) + end + end + + loop.(loop) + end) + + from_pid = :erlang.list_to_pid(~c'<0.2.1>') + + subscribers = [ + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant123", MapSet.new()}} + ] + + msg = %Broadcast{topic: "some:other:topic", event: "event", payload: "not a map"} + + log = + capture_log(fn -> + assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok + end) + + assert log =~ "Received message on realtime:topic with payload: #{inspect(msg, pretty: true)}" + + assert_receive {:encoded, %Broadcast{event: "event", payload: "not a map", topic: "realtime:topic"}} + assert_receive {:encoded, %Broadcast{event: "event", payload: "not a map", topic: "realtime:topic"}} + + assert Agent.get(TestSerializer, & &1) == 1 + + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + + refute_receive _any + end + + test "encodes message separately for each unique serializer and join topic combination" do + parent = self() + + subscriber_pid = + spawn(fn -> + loop = fn loop -> + receive do + msg -> + send(parent, {:subscriber, msg}) + loop.(loop) + end + end + + loop.(loop) + end) + + from_pid = :erlang.list_to_pid(~c'<0.2.1>') + + # Four subscribers: same serializer, two different join_topics (two each) + subscribers = [ + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic-a", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic-a", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic-b", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic-b", :info, "tenant123", MapSet.new()}} + ] + + msg = %Broadcast{topic: "some:other:topic", event: "event", payload: %{data: "test"}} + + log = + capture_log(fn -> + assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok + end) + + assert log =~ "Received message on realtime:topic-a" + assert log =~ "Received message on realtime:topic-b" + + # Serializer called once per unique {serializer, join_topic} pair (2 topics = 2 calls) + assert Agent.get(TestSerializer, & &1) == 2 + + # Each topic gets encoded with the correct topic rewritten + assert_receive {:encoded, %Broadcast{event: "event", topic: "realtime:topic-a"}} + assert_receive {:encoded, %Broadcast{event: "event", topic: "realtime:topic-a"}} + assert_receive {:encoded, %Broadcast{event: "event", topic: "realtime:topic-b"}} + assert_receive {:encoded, %Broadcast{event: "event", topic: "realtime:topic-b"}} + + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + + refute_receive _any + end + test "dispatches messages to non fastlane subscribers" do from_pid = :erlang.list_to_pid(~c'<0.2.1>') @@ -93,5 +330,236 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do # TestSerializer is not called assert Agent.get(TestSerializer, & &1) == 0 end + + test "dispatches Broadcast to V1 & V2 Serializers" do + parent = self() + + subscriber_pid = + spawn(fn -> + loop = fn loop -> + receive do + msg -> + send(parent, {:subscriber, msg}) + loop.(loop) + end + end + + loop.(loop) + end) + + from_pid = :erlang.list_to_pid(~c'<0.2.1>') + + subscribers = [ + {subscriber_pid, {:rc_fastlane, self(), V1.JSONSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), V1.JSONSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), V2Serializer, "realtime:topic", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), V2Serializer, "realtime:topic", :info, "tenant123", MapSet.new()}} + ] + + msg = %Broadcast{topic: "some:other:topic", event: "event", payload: %{data: "test"}} + + log = + capture_log(fn -> + assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok + end) + + assert log =~ "Received message on realtime:topic with payload: #{inspect(msg, pretty: true)}" + + # Receive 2 messages using V1 + assert_receive {:socket_push, :text, message_v1} + assert_receive {:socket_push, :text, ^message_v1} + + assert Jason.decode!(message_v1) == %{ + "event" => "event", + "payload" => %{"data" => "test"}, + "ref" => nil, + "topic" => "realtime:topic" + } + + # Receive 2 messages using V2 + assert_receive {:socket_push, :text, message_v2} + assert_receive {:socket_push, :text, ^message_v2} + + # V2 is an array format + assert Jason.decode!(message_v2) == [nil, nil, "realtime:topic", "event", %{"data" => "test"}] + + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + + refute_receive _any + end + + test "dispatches json UserBroadcast to V1 & V2 Serializers" do + parent = self() + + subscriber_pid = + spawn(fn -> + loop = fn loop -> + receive do + msg -> + send(parent, {:subscriber, msg}) + loop.(loop) + end + end + + loop.(loop) + end) + + from_pid = :erlang.list_to_pid(~c'<0.2.1>') + + subscribers = [ + {subscriber_pid, {:rc_fastlane, self(), V1.JSONSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), V1.JSONSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), V2Serializer, "realtime:topic", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), V2Serializer, "realtime:topic", :info, "tenant123", MapSet.new()}} + ] + + user_payload = Jason.encode!(%{data: "test"}) + + msg = %UserBroadcast{ + topic: "some:other:topic", + user_event: "event123", + user_payload: user_payload, + user_payload_encoding: :json, + metadata: %{"id" => "123", "replayed" => true} + } + + log = + capture_log(fn -> + assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok + end) + + assert log =~ "Received message on realtime:topic with payload: #{inspect(msg, pretty: true)}" + + # Receive 2 messages using V1 + assert_receive {:socket_push, :text, message_v1} + assert_receive {:socket_push, :text, ^message_v1} + + assert Jason.decode!(message_v1) == %{ + "event" => "broadcast", + "payload" => %{ + "event" => "event123", + "meta" => %{"id" => "123", "replayed" => true}, + "payload" => %{"data" => "test"}, + "type" => "broadcast" + }, + "ref" => nil, + "topic" => "realtime:topic" + } + + # Receive 2 messages using V2 + assert_receive {:socket_push, :binary, message_v2} + assert_receive {:socket_push, :binary, ^message_v2} + + encoded_metadata = Jason.encode!(%{"id" => "123", "replayed" => true}) + metadata_size = byte_size(encoded_metadata) + + # binary payload structure + assert message_v2 == + << + # user broadcast = 4 + 4::size(8), + # topic_size + 14, + # user_event_size + 8, + # metadata_size + metadata_size, + # json encoding + 1::size(8), + "realtime:topic", + "event123" + >> <> encoded_metadata <> user_payload + + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + + refute_receive _any + end + + test "dispatches binary UserBroadcast to V1 & V2 Serializers" do + parent = self() + + subscriber_pid = + spawn(fn -> + loop = fn loop -> + receive do + msg -> + send(parent, {:subscriber, msg}) + loop.(loop) + end + end + + loop.(loop) + end) + + from_pid = :erlang.list_to_pid(~c'<0.2.1>') + + subscribers = [ + {subscriber_pid, {:rc_fastlane, self(), V1.JSONSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), V1.JSONSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), V2Serializer, "realtime:topic", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), V2Serializer, "realtime:topic", :info, "tenant123", MapSet.new()}} + ] + + user_payload = <<123, 456, 789>> + + msg = %UserBroadcast{ + topic: "some:other:topic", + user_event: "event123", + user_payload: user_payload, + user_payload_encoding: :binary, + metadata: %{"id" => "123", "replayed" => true} + } + + log = + capture_log(fn -> + assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok + end) + + assert log =~ "Received message on realtime:topic with payload: #{inspect(msg, pretty: true)}" + assert log =~ "User payload encoding is not JSON" + + # Only prints once + assert String.split(log, "User payload encoding is not JSON") |> length() == 2 + + # No V1 message received as binary payloads are not supported + refute_receive {:socket_push, :text, _message_v1} + + # Receive 2 messages using V2 + assert_receive {:socket_push, :binary, message_v2} + assert_receive {:socket_push, :binary, ^message_v2} + + encoded_metadata = Jason.encode!(%{"id" => "123", "replayed" => true}) + metadata_size = byte_size(encoded_metadata) + + # binary payload structure + assert message_v2 == + << + # user broadcast = 4 + 4::size(8), + # topic_size + 14, + # user_event_size + 8, + # metadata_size + metadata_size, + # binary encoding + 0::size(8), + "realtime:topic", + "event123" + >> <> encoded_metadata <> user_payload + + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + + refute_receive _any + end end end diff --git a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs index e5ecd32ad..18df9f1a0 100644 --- a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs +++ b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs @@ -99,26 +99,42 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do end end - describe "handle/2" do + describe "handle/3" do + setup %{tenant: tenant} do + on_exit(fn -> :telemetry.detach(__MODULE__) end) + + :telemetry.attach( + __MODULE__, + [:realtime, :tenants, :payload, :size], + &__MODULE__.handle_telemetry/4, + %{pid: self(), tenant: tenant} + ) + end + test "with true policy and is private, user can track their presence and changes", %{ tenant: tenant, topic: topic, db_conn: db_conn } do + external_id = tenant.external_id key = random_string() policies = %Policies{presence: %PresencePolicies{read: true, write: true}} socket = socket_fixture(tenant, topic, key, policies: policies) - PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) + PresenceHandler.handle(%{"event" => "track", "payload" => %{"A" => "b", "c" => "b"}}, db_conn, socket) topic = socket.assigns.tenant_topic assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} assert Map.has_key?(joins, key) + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 30}, + %{tenant: ^external_id, message_type: :presence}} end test "when tracking already existing user, metadata updated", %{tenant: tenant, topic: topic, db_conn: db_conn} do + external_id = tenant.external_id key = random_string() policies = %Policies{presence: %PresencePolicies{read: true, write: true}} socket = socket_fixture(tenant, topic, key, policies: policies) @@ -134,19 +150,87 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} assert Map.has_key?(joins, key) - refute_receive :_ + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 6}, + %{tenant: ^external_id, message_type: :presence}} + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 55}, + %{tenant: ^external_id, message_type: :presence}} + + refute_receive _ + end + + test "tracking the same payload does nothing", %{tenant: tenant, topic: topic, db_conn: db_conn} do + external_id = tenant.external_id + key = random_string() + policies = %Policies{presence: %PresencePolicies{read: true, write: true}} + socket = socket_fixture(tenant, topic, key, policies: policies) + + assert {:ok, socket} = PresenceHandler.handle(%{"event" => "track", "payload" => %{"a" => "b"}}, db_conn, socket) + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 18}, + %{tenant: ^external_id, message_type: :presence}} + + topic = socket.assigns.tenant_topic + assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} + assert Map.has_key?(joins, key) + + assert {:ok, _socket} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"a" => "b"}}, db_conn, socket) + + refute_receive _ + end + + test "tracking, untracking and then tracking the same payload emit events", context do + %{tenant: tenant, topic: topic, db_conn: db_conn} = context + external_id = tenant.external_id + key = random_string() + policies = %Policies{presence: %PresencePolicies{read: true, write: true}} + socket = socket_fixture(tenant, topic, key, policies: policies) + + assert {:ok, socket} = PresenceHandler.handle(%{"event" => "track", "payload" => %{"a" => "b"}}, db_conn, socket) + assert socket.assigns.presence_track_payload == %{"a" => "b"} + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 18}, + %{tenant: ^external_id, message_type: :presence}} + + topic = socket.assigns.tenant_topic + assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} + assert %{^key => %{metas: [%{:phx_ref => _, "a" => "b"}]}} = joins + + assert {:ok, socket} = PresenceHandler.handle(%{"event" => "untrack"}, db_conn, socket) + assert socket.assigns.presence_track_payload == nil + + assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: %{}, leaves: leaves}} + assert %{^key => %{metas: [%{:phx_ref => _, "a" => "b"}]}} = leaves + + assert {:ok, socket} = PresenceHandler.handle(%{"event" => "track", "payload" => %{"a" => "b"}}, db_conn, socket) + + assert socket.assigns.presence_track_payload == %{"a" => "b"} + + assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} + assert %{^key => %{metas: [%{:phx_ref => _, "a" => "b"}]}} = joins + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 18}, + %{tenant: ^external_id, message_type: :presence}} + + refute_receive _ end test "with false policy and is public, user can track their presence and changes", %{tenant: tenant, topic: topic} do + external_id = tenant.external_id key = random_string() policies = %Policies{presence: %PresencePolicies{read: false, write: false}} socket = socket_fixture(tenant, topic, key, policies: policies, private?: false) - assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "track"}, socket) + assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "track"}, nil, socket) topic = socket.assigns.tenant_topic assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} assert Map.has_key?(joins, key) + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 6}, + %{tenant: ^external_id, message_type: :presence}} end test "user can untrack when they want", %{tenant: tenant, topic: topic, db_conn: db_conn} do @@ -174,7 +258,9 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do reject(&Authorization.get_write_authorizations/3) key = random_string() - socket = socket_fixture(tenant, topic, key) + # Use high client rate limit to test tenant-level rate limiting + client_rate_limit = %{max_calls: 1000, window_ms: 60_000, counter: 0, reset_at: nil} + socket = socket_fixture(tenant, topic, key, client_rate_limit: client_rate_limit) topic = socket.assigns.tenant_topic for _ <- 1..300, reduce: socket do @@ -191,6 +277,26 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do end end + test "increase_connection_pool from write authorization returns error and does not log UnableToSetPolicies", + %{tenant: tenant, topic: topic, db_conn: db_conn} do + stub(Authorization, :get_write_authorizations, fn _, _, _ -> {:error, :increase_connection_pool} end) + + key = random_string() + socket = socket_fixture(tenant, topic, key) + + log = + capture_log(fn -> + assert {:error, :increase_connection_pool} = + PresenceHandler.handle( + %{"event" => "track", "payload" => %{"metadata" => random_string()}}, + db_conn, + socket + ) + end) + + refute log =~ "UnableToSetPolicies" + end + @tag policies: [:authenticated_read_broadcast_and_presence, :broken_write_presence] test "handle failing rls policy", %{tenant: tenant, topic: topic, db_conn: db_conn} do expect(Authorization, :get_write_authorizations, 1, fn conn, db_conn, auth_context -> @@ -221,7 +327,12 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do key = random_string() policies = %Policies{broadcast: %BroadcastPolicies{read: false}} - socket = socket_fixture(tenant, topic, key, policies: policies, private?: false) + # Use high client rate limit to test tenant-level rate limiting + client_rate_limit = %{max_calls: 1000, window_ms: 60_000, counter: 0, reset_at: nil} + + socket = + socket_fixture(tenant, topic, key, policies: policies, private?: false, client_rate_limit: client_rate_limit) + topic = socket.assigns.tenant_topic for _ <- 1..300, reduce: socket do @@ -229,6 +340,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do assert {:ok, socket} = PresenceHandler.handle( %{"event" => "track", "payload" => %{"metadata" => random_string()}}, + nil, socket ) @@ -238,7 +350,13 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do end test "logs out non recognized events" do - socket = %Phoenix.Socket{joined: true} + tenant = tenant_fixture() + + socket = + socket_fixture(tenant, "topic", "presence_key", + private?: false, + client_rate_limit: %{max_calls: 1000, window_ms: 60_000, counter: 0, reset_at: nil} + ) log = capture_log(fn -> @@ -248,7 +366,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do assert log =~ "UnknownPresenceEvent" end - test "socket with presence enabled false will ignore presence events in public channel", %{ + test "socket with presence enabled false will ignore non-track presence events in public channel", %{ tenant: tenant, topic: topic } do @@ -256,12 +374,12 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do policies = %Policies{presence: %PresencePolicies{read: true, write: true}} socket = socket_fixture(tenant, topic, key, policies: policies, private?: false, enabled?: false) - assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "track"}, socket) + assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "untrack"}, nil, socket) topic = socket.assigns.tenant_topic refute_receive %Broadcast{topic: ^topic, event: "presence_diff"} end - test "socket with presence enabled false will ignore presence events in private channel", %{ + test "socket with presence enabled false will ignore non-track presence events in private channel", %{ tenant: tenant, topic: topic, db_conn: db_conn @@ -270,11 +388,80 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do policies = %Policies{presence: %PresencePolicies{read: true, write: true}} socket = socket_fixture(tenant, topic, key, policies: policies, private?: false, enabled?: false) - assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) + assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "untrack"}, db_conn, socket) + topic = socket.assigns.tenant_topic + refute_receive %Broadcast{topic: ^topic, event: "presence_diff"} + end + + test "socket with presence disabled will enable presence on track message for public channel", %{ + tenant: tenant, + topic: topic + } do + key = random_string() + policies = %Policies{presence: %PresencePolicies{read: true, write: true}} + socket = socket_fixture(tenant, topic, key, policies: policies, private?: false, enabled?: false) + + refute socket.assigns.presence_enabled? + + assert {:ok, updated_socket} = PresenceHandler.handle(%{"event" => "track"}, nil, socket) + + assert updated_socket.assigns.presence_enabled? + topic = socket.assigns.tenant_topic + assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} + assert Map.has_key?(joins, key) + end + + test "socket with presence disabled will enable presence on track message for private channel", %{ + tenant: tenant, + topic: topic, + db_conn: db_conn + } do + key = random_string() + policies = %Policies{presence: %PresencePolicies{read: true, write: true}} + socket = socket_fixture(tenant, topic, key, policies: policies, private?: true, enabled?: false) + + refute socket.assigns.presence_enabled? + + assert {:ok, updated_socket} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) + + assert updated_socket.assigns.presence_enabled? + topic = socket.assigns.tenant_topic + assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} + assert Map.has_key?(joins, key) + end + + test "socket with presence disabled will not enable presence on untrack message", %{ + tenant: tenant, + topic: topic, + db_conn: db_conn + } do + key = random_string() + policies = %Policies{presence: %PresencePolicies{read: true, write: true}} + socket = socket_fixture(tenant, topic, key, policies: policies, enabled?: false) + + refute socket.assigns.presence_enabled? + + assert {:ok, updated_socket} = PresenceHandler.handle(%{"event" => "untrack"}, db_conn, socket) + + refute updated_socket.assigns.presence_enabled? topic = socket.assigns.tenant_topic refute_receive %Broadcast{topic: ^topic, event: "presence_diff"} end + test "socket with presence disabled will not enable presence on unknown event", %{ + tenant: tenant, + topic: topic, + db_conn: db_conn + } do + key = random_string() + policies = %Policies{presence: %PresencePolicies{read: true, write: true}} + socket = socket_fixture(tenant, topic, key, policies: policies, enabled?: false) + + refute socket.assigns.presence_enabled? + + assert {:error, :unknown_presence_event} = PresenceHandler.handle(%{"event" => "unknown"}, db_conn, socket) + end + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] test "rate limit is checked on private channel", %{tenant: tenant, topic: topic, db_conn: db_conn} do key = random_string() @@ -283,8 +470,9 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do log = capture_log(fn -> - for _ <- 1..300, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) - Process.sleep(1100) + for _ <- 1..1500, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) + + {:ok, _} = RateCounterHelper.tick!(Tenants.presence_events_per_second_rate(tenant)) assert {:error, :rate_limit_exceeded} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) end) @@ -298,14 +486,38 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do log = capture_log(fn -> - for _ <- 1..300, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) - Process.sleep(1100) + for _ <- 1..1500, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) + + {:ok, _} = RateCounterHelper.tick!(Tenants.presence_events_per_second_rate(tenant)) assert {:error, :rate_limit_exceeded} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) end) assert log =~ "PresenceRateLimitReached" end + + test "returns error when track payload is not a map", %{tenant: tenant, topic: topic, db_conn: db_conn} do + key = random_string() + policies = %Policies{presence: %PresencePolicies{read: true, write: true}} + socket = socket_fixture(tenant, topic, key, policies: policies, private?: false) + + assert {:error, :invalid_payload} = + PresenceHandler.handle(%{"event" => "track", "payload" => "1111"}, db_conn, socket) + + topic = socket.assigns.tenant_topic + refute_receive %Broadcast{topic: ^topic, event: "presence_diff"} + end + + test "fails on high payload size", %{tenant: tenant, topic: topic, db_conn: db_conn} do + key = random_string() + socket = socket_fixture(tenant, topic, key, private?: false) + payload_size = tenant.max_payload_size_in_kb * 1000 + + payload = %{content: random_string(payload_size)} + + assert {:error, :payload_size_exceeded} = + PresenceHandler.handle(%{"event" => "track", "payload" => payload}, db_conn, socket) + end end describe "sync/1" do @@ -355,8 +567,9 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do log = capture_log(fn -> - for _ <- 1..300, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) - Process.sleep(1100) + for _ <- 1..1500, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) + + {:ok, _} = RateCounterHelper.tick!(Tenants.presence_events_per_second_rate(tenant)) assert {:error, :rate_limit_exceeded} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) end) @@ -371,8 +584,9 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do log = capture_log(fn -> - for _ <- 1..300, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) - Process.sleep(1100) + for _ <- 1..1500, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) + + {:ok, _} = RateCounterHelper.tick!(Tenants.presence_events_per_second_rate(tenant)) assert {:error, :rate_limit_exceeded} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) end) @@ -381,10 +595,186 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do end end + describe "per-client rate limiting" do + test "allows calls under the limit", %{tenant: tenant, topic: topic} do + client_rate_limit = %{max_calls: 10, window_ms: 60_000, counter: 0, reset_at: nil} + socket = socket_fixture(tenant, topic, random_string(), private?: false, client_rate_limit: client_rate_limit) + + # Make 9 calls (under limit of 10) + socket = + Enum.reduce(1..9, socket, fn _, acc_socket -> + {:ok, updated_socket} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, acc_socket) + + updated_socket + end) + + assert %{counter: 9, max_calls: 10, window_ms: 60000, reset_at: _} = socket.assigns.presence_client_rate_limit + + # 10th call should still work + assert {:ok, socket} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket) + + assert %{counter: 10, max_calls: 10, window_ms: 60000, reset_at: _} = socket.assigns.presence_client_rate_limit + end + + test "blocks calls over the limit", %{tenant: tenant, topic: topic} do + client_rate_limit = %{max_calls: 10, window_ms: 60_000, counter: 0, reset_at: nil} + socket = socket_fixture(tenant, topic, random_string(), private?: false, client_rate_limit: client_rate_limit) + + # Make 10 calls (at limit) + socket = + Enum.reduce(1..10, socket, fn _, acc_socket -> + {:ok, updated_socket} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, acc_socket) + + updated_socket + end) + + # 11th call should fail + assert {:error, :client_rate_limit_exceeded} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket) + + assert %{counter: 10, max_calls: 10, window_ms: 60000, reset_at: _} = socket.assigns.presence_client_rate_limit + end + + test "rate limits work independently per socket", %{tenant: tenant, topic: topic} do + client_rate_limit = %{max_calls: 10, window_ms: 60_000, counter: 0, reset_at: nil} + socket1 = socket_fixture(tenant, topic, random_string(), private?: false, client_rate_limit: client_rate_limit) + socket2 = socket_fixture(tenant, topic, random_string(), private?: false, client_rate_limit: client_rate_limit) + + socket1 = + Enum.reduce(1..10, socket1, fn _, acc_socket -> + {:ok, updated_socket} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, acc_socket) + + updated_socket + end) + + assert {:error, :client_rate_limit_exceeded} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket1) + + # socket2 should still work (independent limit) + assert {:ok, _socket} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket2) + end + + test "tenant override for max_client_presence_events_per_window is applied", %{tenant: tenant, topic: topic} do + {:ok, updated_tenant} = + Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{max_client_presence_events_per_window: 3}) + + Realtime.Tenants.Cache.update_cache(updated_tenant) + + socket = socket_fixture(updated_tenant, topic, random_string(), private?: false) + + assert %{max_calls: 3} = socket.assigns.presence_client_rate_limit + + socket = + Enum.reduce(1..3, socket, fn _, acc_socket -> + {:ok, updated_socket} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, acc_socket) + + updated_socket + end) + + assert {:error, :client_rate_limit_exceeded} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket) + end + + test "falls back to env config when tenant override is nil", %{tenant: tenant, topic: topic} do + assert is_nil(tenant.max_client_presence_events_per_window) + assert is_nil(tenant.client_presence_window_ms) + + config = Application.get_env(:realtime, :client_presence_rate_limit) + expected_max_calls = config[:max_calls] + expected_window_ms = config[:window_ms] + socket = socket_fixture(tenant, topic, random_string(), private?: false) + + assert %{max_calls: ^expected_max_calls, window_ms: ^expected_window_ms} = + socket.assigns.presence_client_rate_limit + end + + test "tenant override for client_presence_window_ms is applied", %{tenant: tenant, topic: topic} do + {:ok, updated_tenant} = + Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{client_presence_window_ms: 5_000}) + + Realtime.Tenants.Cache.update_cache(updated_tenant) + + socket = socket_fixture(updated_tenant, topic, random_string(), private?: false) + + assert %{window_ms: 5_000} = socket.assigns.presence_client_rate_limit + end + + test "tenant override for client_presence_window_ms respects the window", %{tenant: tenant, topic: topic} do + {:ok, updated_tenant} = + Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{ + max_client_presence_events_per_window: 3, + client_presence_window_ms: 100 + }) + + Realtime.Tenants.Cache.update_cache(updated_tenant) + + socket = socket_fixture(updated_tenant, topic, random_string(), private?: false) + + assert %{max_calls: 3, window_ms: 100} = socket.assigns.presence_client_rate_limit + + socket = + Enum.reduce(1..3, socket, fn _, acc_socket -> + {:ok, updated_socket} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, acc_socket) + + updated_socket + end) + + assert {:error, :client_rate_limit_exceeded} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket) + + Process.sleep(101) + + assert {:ok, _socket} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket) + end + + test "rate limit resets after window expires", %{tenant: tenant, topic: topic} do + # Create socket with a very short window (100ms) + socket = socket_fixture(tenant, topic, random_string(), private?: false) + + # Override the window to be very short for testing + short_window_config = %{ + max_calls: 3, + window_ms: 100, + counter: 0, + reset_at: nil + } + + socket = %{socket | assigns: Map.put(socket.assigns, :presence_client_rate_limit, short_window_config)} + + # Make 3 calls (at limit) + socket = + Enum.reduce(1..3, socket, fn _, acc_socket -> + {:ok, updated_socket} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, acc_socket) + + updated_socket + end) + + # 4th call should fail + assert {:error, :client_rate_limit_exceeded} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket) + + # Wait for window to expire + Process.sleep(101) + + # Should be able to call again after window reset + assert {:ok, _socket} = + PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket) + end + end + defp initiate_tenant(context) do tenant = Containers.checkout_tenant(run_migrations: true) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) assert Connect.ready?(tenant.external_id) @@ -427,6 +817,34 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do RateCounter.new(rate) + client_rate_limit_override = Keyword.get(opts, :client_rate_limit) + + client_rate_limit = + if client_rate_limit_override do + client_rate_limit_override + else + config = Application.get_env(:realtime, :client_presence_rate_limit, max_calls: 10, window_ms: 60_000) + + max_calls = + case tenant.max_client_presence_events_per_window do + value when is_integer(value) and value > 0 -> value + _ -> config[:max_calls] + end + + window_ms = + case tenant.client_presence_window_ms do + value when is_integer(value) and value > 0 -> value + _ -> config[:window_ms] + end + + %{ + max_calls: max_calls, + window_ms: window_ms, + counter: 0, + reset_at: nil + } + end + %Phoenix.Socket{ joined: true, topic: "realtime:#{topic}", @@ -438,6 +856,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do policies: policies, authorization_context: authorization_context, presence_rate_counter: rate, + presence_client_rate_limit: client_rate_limit, private?: private?, presence_key: presence_key, presence_enabled?: enabled?, @@ -447,4 +866,10 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do } } end + + def handle_telemetry(event, measures, metadata, %{pid: pid, tenant: tenant}) do + if metadata[:tenant] == tenant.external_id do + send(pid, {:telemetry, event, measures, metadata}) + end + end end diff --git a/test/realtime_web/channels/realtime_channel/tracker_test.exs b/test/realtime_web/channels/realtime_channel/tracker_test.exs index 2590b9597..7137256c1 100644 --- a/test/realtime_web/channels/realtime_channel/tracker_test.exs +++ b/test/realtime_web/channels/realtime_channel/tracker_test.exs @@ -1,5 +1,7 @@ defmodule RealtimeWeb.RealtimeChannel.TrackerTest do - use Realtime.DataCase + # It kills websockets when no channels are open + # It can affect other tests + use Realtime.DataCase, async: false alias RealtimeWeb.RealtimeChannel.Tracker setup do diff --git a/test/realtime_web/channels/realtime_channel_test.exs b/test/realtime_web/channels/realtime_channel_test.exs index 2dff83da3..2f52a57d8 100644 --- a/test/realtime_web/channels/realtime_channel_test.exs +++ b/test/realtime_web/channels/realtime_channel_test.exs @@ -1,61 +1,731 @@ defmodule RealtimeWeb.RealtimeChannelTest do - # Can't run async true because under the hood Cachex is used and it doesn't see Ecto Sandbox - use RealtimeWeb.ChannelCase, async: false + use RealtimeWeb.ChannelCase, async: true use Mimic import ExUnit.CaptureLog - alias Phoenix.Socket alias Phoenix.Channel.Server + alias Phoenix.Socket alias Realtime.Tenants.Authorization alias Realtime.Tenants.Connect alias Realtime.RateCounter alias RealtimeWeb.UserSocket - @default_limits %{ - max_concurrent_users: 200, - max_events_per_second: 100, - max_joins_per_second: 100, - max_channels_per_client: 100, - max_bytes_per_second: 100_000 - } - setup do tenant = Containers.checkout_tenant(run_migrations: true) + {:ok, db_conn} = Realtime.Database.connect(tenant, "realtime_test", :stop) + Integrations.setup_postgres_changes(db_conn) + GenServer.stop(db_conn) + Realtime.Tenants.Cache.update_cache(tenant) {:ok, tenant: tenant} end setup :rls_context - describe "presence" do - test "events are counted", %{tenant: tenant} do + describe "join - tenant not found" do + test "sends disconnect to transport_pid and logs TenantNotFound", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + stub(Realtime.Tenants.Cache, :fetch_tenant_by_external_id, fn _id -> + {:error, :tenant_not_found} + end) + + log = + capture_log(fn -> + assert {:error, _} = subscribe_and_join(socket, "realtime:test", %{}) + end) + + assert log =~ "TenantNotFound" + assert_received %Phoenix.Socket.Broadcast{event: "disconnect"} + end + end + + describe "process flags" do + test "max heap size is set for both transport and channel processes", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + assert Process.info(socket.transport_pid, :max_heap_size) == + {:max_heap_size, %{error_logger: true, include_shared_binaries: false, kill: true, size: 6_250_000}} + + assert {:ok, _, socket} = subscribe_and_join(socket, "realtime:test", %{}) + + assert Process.info(socket.channel_pid, :max_heap_size) == + {:max_heap_size, %{error_logger: true, include_shared_binaries: false, kill: true, size: 6_250_000}} + end + + # We don't test the socket because on unit tests Phoenix is not setting the fullsweep_after config + test "fullsweep_after is set on channel process", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + assert {:ok, _, socket} = subscribe_and_join(socket, "realtime:test", %{}) + + assert Process.info(socket.channel_pid, :fullsweep_after) == {:fullsweep_after, 20} + end + end + + describe "postgres changes" do + test "subscribes to inserts", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + config = %{ + "postgres_changes" => [%{"event" => "INSERT", "schema" => "public", "table" => "test"}] + } + + assert {:ok, reply, _socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + assert %{postgres_changes: [%{:id => sub_id, "event" => "INSERT", "schema" => "public", "table" => "test"}]} = + reply + + assert_push "system", + %{message: "Subscribed to PostgreSQL", status: "ok", extension: "postgres_changes", channel: "test"}, + 5000 + + {:ok, conn} = Connect.lookup_or_start_connection(tenant.external_id) + %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) + + assert_push "postgres_changes", %{data: data, ids: [^sub_id]}, 500 + + # we encode and decode because the data is a Jason.Fragment + assert %{ + "table" => "test", + "type" => "INSERT", + "record" => %{"details" => "test", "id" => ^id, "binary_data" => nil}, + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"}, + %{"name" => "binary_data", "type" => "bytea"} + ], + "errors" => nil, + "schema" => "public", + "commit_timestamp" => _ + } = Jason.encode!(data) |> Jason.decode!() + + refute_receive %Socket.Message{} + refute_receive %Socket.Reply{} + end + + test "multiple subscriptions", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + config = %{ + "postgres_changes" => [ + %{"event" => "INSERT", "schema" => "public", "table" => "test"}, + %{"event" => "DELETE", "schema" => "public", "table" => "test"} + ] + } + + assert {:ok, reply, _socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + assert %{ + postgres_changes: [ + %{:id => insert_sub_id, "event" => "INSERT", "schema" => "public", "table" => "test"}, + %{ + :id => delete_sub_id, + "event" => "DELETE", + "schema" => "public", + "table" => "test" + } + ] + } = + reply + + assert_push "system", + %{message: "Subscribed to PostgreSQL", status: "ok", extension: "postgres_changes", channel: "test"}, + 5000 + + {:ok, conn} = Connect.lookup_or_start_connection(tenant.external_id) + # Insert, update and delete but update should not be received + %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) + Postgrex.query!(conn, "update test set details = 'test' where id = $1", [id]) + Postgrex.query!(conn, "delete from test where id = $1", [id]) + + assert_push "postgres_changes", %{data: data, ids: [^insert_sub_id]}, 500 + + # we encode and decode because the data is a Jason.Fragment + assert %{ + "table" => "test", + "type" => "INSERT", + "record" => %{"details" => "test", "id" => ^id}, + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"}, + %{"name" => "binary_data", "type" => "bytea"} + ], + "errors" => nil, + "schema" => "public", + "commit_timestamp" => _ + } = Jason.encode!(data) |> Jason.decode!() + + assert_push "postgres_changes", %{data: data, ids: [^delete_sub_id]}, 500 + + # we encode and decode because the data is a Jason.Fragment + assert %{ + "table" => "test", + "type" => "DELETE", + "old_record" => %{"id" => ^id}, + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"}, + %{"name" => "binary_data", "type" => "bytea"} + ], + "errors" => nil, + "schema" => "public", + "commit_timestamp" => _ + } = Jason.encode!(data) |> Jason.decode!() + + refute_receive _any + end + + test "malformed subscription params", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + config = %{ + "postgres_changes" => [%{"event" => "*", "schema" => "public", "table" => "test", "filter" => "wrong"}] + } + + assert {:ok, reply, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + assert %{postgres_changes: [%{"event" => "*", "schema" => "public", "table" => "test"}]} = reply + + assert_push "system", + %{ + message: "Error parsing `filter` params: [\"wrong\"]", + status: "error", + extension: "postgres_changes", + channel: "test" + }, + 3000 + + socket = Server.socket(socket.channel_pid) + + # It won't re-subscribe + assert socket.assigns.pg_sub_ref == nil + end + + test "invalid subscription table does not exist", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + config = %{ + "postgres_changes" => [%{"event" => "*", "schema" => "public", "table" => "doesnotexist"}] + } + + assert {:ok, reply, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + assert %{postgres_changes: [%{"event" => "*", "schema" => "public", "table" => "doesnotexist"}]} = reply + + assert_push "system", + %{ + message: + "Unable to subscribe to changes with given parameters. Please check Realtime is enabled for the given connect parameters: [event: *, schema: public, table: doesnotexist, filters: [], select: nil]", + status: "error", + extension: "postgres_changes", + channel: "test" + }, + 5000 + + socket = Server.socket(socket.channel_pid) + + # It won't re-subscribe + assert socket.assigns.pg_sub_ref == nil + end + + test "invalid subscription column does not exist", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + config = %{ + "postgres_changes" => [ + %{"event" => "*", "schema" => "public", "table" => "test", "filter" => "notacolumn=eq.123"} + ] + } + + assert {:ok, reply, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + assert %{postgres_changes: [%{"event" => "*", "schema" => "public", "table" => "test"}]} = reply + + assert_push "system", + %{ + message: + "Unable to subscribe to changes with given parameters. An exception happened so please check your connect parameters: [event: *, schema: public, table: test, filters: [{\"notacolumn\", \"eq\", \"123\"}], select: nil]. Exception: ERROR P0001 (raise_exception) invalid column for filter notacolumn", + status: "error", + extension: "postgres_changes", + channel: "test" + }, + 5000 + + socket = Server.socket(socket.channel_pid) + + # It won't re-subscribe + assert socket.assigns.pg_sub_ref == nil + end + + test "connection error", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + config = %{ + "postgres_changes" => [%{"event" => "*", "schema" => "public", "table" => "test"}] + } + + conn = spawn(fn -> :ok end) + # Let's set the subscription manager conn to be a pid that is no more + + assert {:ok, reply, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + assert %{postgres_changes: [%{"event" => "*", "schema" => "public", "table" => "test"}]} = reply + + assert_push "system", + %{ + message: "Subscribed to PostgreSQL", + status: "ok", + extension: "postgres_changes", + channel: "test" + }, + 5000 + + {:ok, manager_pid, _conn} = Extensions.PostgresCdcRls.get_manager_conn(tenant.external_id) + Extensions.PostgresCdcRls.update_meta(tenant.external_id, manager_pid, conn) + + assert {:ok, _reply, socket} = subscribe_and_join(socket, "realtime:test_fail", %{"config" => config}) + + assert_push "system", + %{message: message, status: "error", extension: "postgres_changes", channel: "test_fail"}, + 5000 + + assert message =~ "{:error, \"Too many database timeouts\"}" + socket = Server.socket(socket.channel_pid) + + # It will try again in the future + assert socket.assigns.pg_sub_ref != nil + end + end + + describe "broadcast" do + @describetag policies: [:authenticated_all_topic_read] + + test "broadcast map payload", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + config = %{ + "broadcast" => %{"self" => true} + } + + assert {:ok, _, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + push(socket, "broadcast", %{"event" => "my_event", "payload" => %{"hello" => "world"}}) + + assert_receive %Phoenix.Socket.Message{ + topic: "realtime:test", + event: "broadcast", + payload: %{"event" => "my_event", "payload" => %{"hello" => "world"}} + } + end + + test "broadcast non-map payload", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + config = %{ + "broadcast" => %{"self" => true} + } + + assert {:ok, _, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + push(socket, "broadcast", "not a map") + + assert_receive %Phoenix.Socket.Message{ + topic: "realtime:test", + event: "broadcast", + payload: "not a map" + } + end + + test "wrong replay params", %{tenant: tenant} do jwt = Generators.generate_jwt_token(tenant) {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) - assert {:ok, _, %Socket{} = socket} = subscribe_and_join(socket, "realtime:test", %{}) + config = %{ + "private" => true, + "broadcast" => %{ + "replay" => %{"limit" => "not a number", "since" => :erlang.system_time(:millisecond) - 5 * 60000} + } + } - presence_diff = %Socket.Broadcast{event: "presence_diff", payload: %{joins: %{}, leaves: %{}}} - send(socket.channel_pid, presence_diff) + assert {:error, %{reason: "UnableToReplayMessages: Replay params are not valid"}} = + subscribe_and_join(socket, "realtime:test", %{"config" => config}) - assert_receive %Socket.Message{topic: "realtime:test", event: "presence_state", payload: %{}} + config = %{ + "private" => true, + "broadcast" => %{ + "replay" => %{"limit" => 1, "since" => "not a number"} + } + } + + assert {:error, %{reason: "UnableToReplayMessages: Replay params are not valid"}} = + subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + config = %{ + "private" => true, + "broadcast" => %{ + "replay" => %{} + } + } + + assert {:error, %{reason: "UnableToReplayMessages: Replay params are not valid"}} = + subscribe_and_join(socket, "realtime:test", %{"config" => config}) + end + + test "failure to replay", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) + + config = %{ + "private" => true, + "broadcast" => %{ + "replay" => %{"limit" => 12, "since" => :erlang.system_time(:millisecond) - 5 * 60000} + } + } + + Authorization + |> expect(:get_read_authorizations, fn _, _, _, _ -> + {:ok, + %Authorization.Policies{ + broadcast: %Authorization.Policies.BroadcastPolicies{read: true, write: nil} + }} + end) + + # Broken database connection + conn = spawn(fn -> :ok end) + Connect.lookup_or_start_connection(tenant.external_id) + {:ok, _} = :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: conn} end) + + assert {:error, %{reason: "UnableToReplayMessages: Realtime was unable to replay messages"}} = + subscribe_and_join(socket, "realtime:test", %{"config" => config}) + end + + test "replay messages on public topic not allowed", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) + + config = %{ + "broadcast" => %{"replay" => %{"limit" => 2, "since" => :erlang.system_time(:millisecond) - 5 * 60000}} + } + + assert { + :error, + %{reason: "UnableToReplayMessages: Replay is not allowed for public channels"} + } = subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + refute_receive %Socket.Message{} + refute_receive %Socket.Reply{} + end + + @tag policies: [:authenticated_all_topic_read] + test "replay messages on private topic", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) + + # Old message + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :day), + "event" => "old", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "old"} + }) + + %{id: message1_id} = + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute), + "event" => "first", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "first"} + }) + + %{id: message2_id} = + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute), + "event" => "second", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "second"} + }) + + # This one should not be received because of the limit + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-3, :minute), + "event" => "third", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "third"} + }) + + config = %{ + "private" => true, + "broadcast" => %{"replay" => %{"limit" => 2, "since" => :erlang.system_time(:millisecond) - 5 * 60000}} + } + + assert {:ok, _, %Socket{}} = subscribe_and_join(socket, "realtime:test", %{"config" => config}) assert_receive %Socket.Message{ topic: "realtime:test", - event: "presence_diff", - payload: %{joins: %{}, leaves: %{}} + event: "broadcast", + payload: %{ + "event" => "first", + "meta" => %{"id" => ^message1_id, "replayed" => true}, + "payload" => %{"value" => "first"}, + "type" => "broadcast" + } } - tenant_id = tenant.external_id + assert_receive %Socket.Message{ + topic: "realtime:test", + event: "broadcast", + payload: %{ + "event" => "second", + "meta" => %{"id" => ^message2_id, "replayed" => true}, + "payload" => %{"value" => "second"}, + "type" => "broadcast" + } + } + + refute_receive %Socket.Message{} + end + end - # Wait for RateCounter to tick - Process.sleep(1100) + describe "presence" do + test "presence state event is counted", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) + + assert {:ok, _, %Socket{} = socket} = + subscribe_and_join(socket, "realtime:test", %{"config" => %{"presence" => %{"enabled" => true}}}) + + assert_receive %Socket.Message{topic: "realtime:test", event: "presence_state", payload: %{}} + + tenant_id = tenant.external_id assert {:ok, %RateCounter{id: {:channel, :presence_events, ^tenant_id}, bucket: bucket}} = - RateCounter.get(socket.assigns.presence_rate_counter) + RateCounterHelper.tick!(socket.assigns.presence_rate_counter) + + # presence_state + assert Enum.sum(bucket) == 1 + end + + test "client rate limit blocks calls over the limit and shuts down channel", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) + + config = %{"config" => %{"presence" => %{"enabled" => true, "key" => "user_id"}}} + assert {:ok, _, %Socket{channel_pid: channel_pid} = socket} = subscribe_and_join(socket, "realtime:test", config) + + assert_receive %Socket.Message{topic: "realtime:test", event: "presence_state", payload: %{}} + + # Make 5 presence calls (at the default limit) + for i <- 1..5 do + ref = push(socket, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => %{"call" => i}}) + assert_receive %Socket.Reply{ref: ^ref, status: :ok}, 500 + end + + assert capture_log(fn -> + # 6th call should cause channel shutdown + push(socket, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => %{"call" => 6}}) + + assert_receive %Socket.Message{ + topic: "realtime:test", + event: "system", + payload: %{ + message: "Client presence rate limit exceeded", + status: "error", + extension: "system", + channel: "test" + } + }, + 500 + end) =~ "ClientPresenceRateLimitReached" + + assert_process_down(channel_pid) + end + + test "client rate limits are independent per connection", %{tenant: tenant} do + jwt1 = Generators.generate_jwt_token(tenant) + jwt2 = Generators.generate_jwt_token(tenant) + + {:ok, %Socket{} = socket1} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt1)) + {:ok, %Socket{} = socket2} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt2)) + + config = %{"config" => %{"presence" => %{"key" => "user_id"}}} + + assert {:ok, _, %Socket{channel_pid: channel_pid1} = socket1} = + subscribe_and_join(socket1, "realtime:test1", config) + + assert {:ok, _, %Socket{} = socket2} = subscribe_and_join(socket2, "realtime:test2", config) + + # Exhaust rate limit for socket1 + for i <- 1..5 do + ref = push(socket1, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => %{"call" => i}}) + assert_receive %Socket.Reply{ref: ^ref, status: :ok}, 500 + end + + # socket1's 6th call should cause shutdown + push(socket1, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => %{"call" => 6}}) + + assert_receive %Socket.Message{ + topic: "realtime:test1", + event: "system", + payload: %{ + message: "Client presence rate limit exceeded", + status: "error", + extension: "system", + channel: "test1" + } + }, + 500 + + assert_process_down(channel_pid1) + + # socket2 should still work (independent rate limit) + ref = push(socket2, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => %{"call" => 1}}) + assert_receive %Socket.Reply{ref: ^ref, status: :ok}, 500 + end + + test "presence track closes on high payload size", %{tenant: tenant} do + topic = "realtime:test" + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) - # presence_state + presence_diff - assert 2 in bucket + assert {:ok, _, %Socket{} = socket} = + subscribe_and_join(socket, topic, %{"config" => %{"presence" => %{"enabled" => true}}}) + + assert_receive %Phoenix.Socket.Message{topic: "realtime:test", event: "presence_state"}, 500 + + payload = %{ + type: "presence", + event: "TRACK", + payload: %{name: "realtime_presence_96", t: 1814.7000000029802, content: String.duplicate("a", 3_500_000)} + } + + push(socket, "presence", payload) + + assert_receive %Phoenix.Socket.Message{ + event: "system", + payload: %{ + extension: "system", + message: "Track message size exceeded", + status: "error" + }, + topic: ^topic + }, + 500 + end + + test "presence track with non-map payload replies with error and keeps socket alive", %{tenant: tenant} do + topic = "realtime:test" + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) + + assert {:ok, _, %Socket{} = socket} = + subscribe_and_join(socket, topic, %{"config" => %{"presence" => %{"enabled" => true}}}) + + assert_receive %Phoenix.Socket.Message{topic: "realtime:test", event: "presence_state"}, 500 + + ref = push(socket, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => "not a map"}) + + assert_receive %Socket.Reply{ + ref: ^ref, + status: :error, + payload: %{reason: "Presence track payload must be a map"} + }, + 500 + + ref = push(socket, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => %{"user" => "a"}}) + assert_receive %Socket.Reply{ref: ^ref, status: :ok}, 500 + end + + test "presence track with same payload does nothing", %{tenant: tenant} do + topic = "realtime:test" + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) + + assert {:ok, _, %Socket{} = socket} = + subscribe_and_join(socket, topic, %{config: %{presence: %{enabled: true, key: "my_key"}}}) + + assert_receive %Phoenix.Socket.Message{topic: "realtime:test", event: "presence_state"}, 500 + + payload = %{type: "presence", event: "TRACK", payload: %{"hello" => "world"}} + + push(socket, "presence", payload) + + assert_receive %Socket.Reply{payload: %{}, topic: "realtime:test", status: :ok}, 500 + + assert_receive %Socket.Message{ + payload: %{ + joins: %{"my_key" => %{metas: [%{:phx_ref => _, "hello" => "world"}]}}, + leaves: %{} + }, + topic: "realtime:test", + event: "presence_diff" + }, + 500 + + push(socket, "presence", payload) + + assert_receive %Socket.Reply{payload: %{}, topic: "realtime:test", status: :ok}, 500 + # no presence_diff this time + + refute_receive %Socket.Message{} + refute_receive %Socket.Reply{} + end + + test "presence is disabled when tenant has presence_enabled false and client does not override", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + assert {:ok, _, %Socket{} = socket} = subscribe_and_join(socket, "realtime:test", %{}) + + refute_receive %Socket.Message{event: "presence_state"}, 200 + assert socket.assigns.presence_enabled? == false + end + + test "presence is enabled when client explicitly enables it even if tenant flag is false", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + config = %{"config" => %{"presence" => %{"enabled" => true}}} + + assert {:ok, _, %Socket{} = socket} = subscribe_and_join(socket, "realtime:test", config) + + assert_receive %Socket.Message{event: "presence_state"}, 500 + assert socket.assigns.presence_enabled? == true + end + + test "presence defaults to tenant flag when client does not specify", %{tenant: tenant} do + {:ok, tenant} = + Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{"presence_enabled" => true}) + + Realtime.Tenants.Cache.update_cache(tenant) + + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + assert {:ok, _, %Socket{} = socket} = subscribe_and_join(socket, "realtime:test", %{}) + + assert_receive %Socket.Message{event: "presence_state"}, 500 + assert socket.assigns.presence_enabled? == true end end @@ -74,12 +744,26 @@ defmodule RealtimeWeb.RealtimeChannelTest do end) =~ "UnknownErrorOnChannel: Realtime was unable to connect to the project database" end - test "unexpected error while setting policies", %{tenant: tenant} do + test "unexpected error while setting policies logs UnknownErrorOnChannel", %{tenant: tenant} do jwt = Generators.generate_jwt_token(tenant) {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) - expect(Authorization, :get_read_authorizations, fn _, _, _ -> - {:error, "Realtime was unable to connect to the project database"} + expect(Authorization, :get_read_authorizations, fn _, _, _, _ -> + {:error, "unexpected error"} + end) + + assert capture_log(fn -> + assert {:error, %{reason: "Unknown Error on Channel"}} = + subscribe_and_join(socket, "realtime:test", %{"config" => %{"private" => true}}) + end) =~ "UnknownErrorOnChannel" + end + + test "struct error while setting policies logs UnableToSetPolicies", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + expect(Authorization, :get_read_authorizations, fn _, _, _, _ -> + {:error, %DBConnection.ConnectionError{message: "unexpected error", reason: :error, severity: :error}} end) assert capture_log(fn -> @@ -87,6 +771,66 @@ defmodule RealtimeWeb.RealtimeChannelTest do subscribe_and_join(socket, "realtime:test", %{"config" => %{"private" => true}}) end) =~ "UnableToSetPolicies" end + + test "query canceled during join logs QueryCanceled", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + expect(Authorization, :get_read_authorizations, fn _, _, _, _ -> + {:error, :query_canceled, + %Postgrex.Error{postgres: %{code: :query_canceled, message: "canceling statement due to user request"}}} + end) + + assert capture_log(fn -> + assert {:error, _} = + subscribe_and_join(socket, "realtime:test", %{"config" => %{"private" => true}}) + end) =~ "QueryCanceled" + end + + test "missing partition during join logs MissingPartition", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + expect(Authorization, :get_read_authorizations, fn _, _, _, _ -> {:error, :missing_partition} end) + + assert capture_log(fn -> + assert {:error, _} = + subscribe_and_join(socket, "realtime:test", %{"config" => %{"private" => true}}) + end) =~ "MissingPartition" + end + end + + describe "maximum number of channels per client" do + test "logs error once when last channel slot is taken", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "error"}, conn_opts(tenant, jwt)) + + Realtime.Tenants.Cache.update_cache(%{tenant | max_channels_per_client: 1}) + + log = + capture_log(fn -> + assert {:ok, _, _} = subscribe_and_join(socket, "realtime:test", %{}) + end) + + assert log =~ "ChannelRateLimitReached" + end + + test "does not log when channel limit is already exceeded", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) + + Realtime.Tenants.Cache.update_cache(%{tenant | max_channels_per_client: 1}) + + capture_log(fn -> subscribe_and_join(socket, "realtime:test", %{}) end) + + log = + capture_log(fn -> + assert {:error, %{reason: "ChannelRateLimitReached: Too many channels"}} = + subscribe_and_join(socket, "realtime:test2", %{}) + end) + + refute log =~ "ChannelRateLimitReached" + end end describe "maximum number of connected clients per tenant" do @@ -94,25 +838,38 @@ defmodule RealtimeWeb.RealtimeChannelTest do jwt = Generators.generate_jwt_token(tenant) {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) - socket = Socket.assign(socket, %{limits: %{@default_limits | max_concurrent_users: 1}}) + Realtime.Tenants.Cache.update_cache(%{tenant | max_concurrent_users: 1}) + assert {:ok, _, %Socket{}} = subscribe_and_join(socket, "realtime:test", %{}) end - test "reached", %{tenant: tenant} do + test "reached after connecting", %{tenant: tenant} do jwt = Generators.generate_jwt_token(tenant) {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) - socket_at_capacity = - Socket.assign(socket, %{limits: %{@default_limits | max_concurrent_users: 0}}) + Realtime.Tenants.Cache.update_cache(%{tenant | max_concurrent_users: 1}) - socket_over_capacity = - Socket.assign(socket, %{limits: %{@default_limits | max_concurrent_users: -1}}) + pid = spawn_link(fn -> Process.sleep(:infinity) end) + Realtime.UsersCounter.add(pid, tenant.external_id) assert {:error, %{reason: "ConnectionRateLimitReached: Too many connected users"}} = - subscribe_and_join(socket_at_capacity, "realtime:test", %{}) + subscribe_and_join(socket, "realtime:test", %{}) + + pid = spawn_link(fn -> Process.sleep(:infinity) end) + Realtime.UsersCounter.add(pid, tenant.external_id) assert {:error, %{reason: "ConnectionRateLimitReached: Too many connected users"}} = - subscribe_and_join(socket_over_capacity, "realtime:test", %{}) + subscribe_and_join(socket, "realtime:test", %{}) + end + + test "reached before connecting", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + + Realtime.Tenants.Cache.update_cache(%{tenant | max_concurrent_users: 1}) + + Realtime.UsersCounter.add(self(), tenant.external_id) + + {:error, :too_many_connections} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) end end @@ -122,7 +879,11 @@ defmodule RealtimeWeb.RealtimeChannelTest do jwt = Generators.generate_jwt_token(tenant) {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) - assert socket = subscribe_and_join!(socket, "realtime:test", %{"config" => %{"private" => true}}) + assert socket = + subscribe_and_join!(socket, "realtime:test", %{ + "config" => %{"private" => true, "presence" => %{"enabled" => true}} + }) + old_confirm_ref = socket.assigns.confirm_token_ref assert socket.assigns.policies == %Realtime.Tenants.Authorization.Policies{ @@ -152,7 +913,10 @@ defmodule RealtimeWeb.RealtimeChannelTest do jwt = Generators.generate_jwt_token(tenant) {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) - assert socket = subscribe_and_join!(socket, "realtime:test", %{"config" => %{"private" => true}}) + assert socket = + subscribe_and_join!(socket, "realtime:test", %{ + "config" => %{"private" => true, "presence" => %{"enabled" => true}} + }) assert socket.assigns.policies == %Realtime.Tenants.Authorization.Policies{ broadcast: %Realtime.Tenants.Authorization.Policies.BroadcastPolicies{read: true, write: nil}, @@ -379,6 +1143,21 @@ defmodule RealtimeWeb.RealtimeChannelTest do end describe "access_token validations" do + test "access_token has exp and iat in decimal format", %{tenant: tenant} do + api_key = Generators.generate_jwt_token(tenant) + + jwt = + Generators.generate_jwt_token(tenant, %{ + role: "authenticated", + exp: System.system_time(:second) + 100.99, + iat: System.system_time(:second) - 100.99 + }) + + assert {:ok, socket} = connect(UserSocket, %{}, conn_opts(tenant, api_key)) + + assert {:ok, _, _} = subscribe_and_join(socket, "realtime:test", %{"access_token" => jwt}) + end + test "access_token has expired", %{tenant: tenant} do api_key = Generators.generate_jwt_token(tenant) jwt = Generators.generate_jwt_token(tenant, %{role: "authenticated", exp: System.system_time(:second) - 1}) @@ -709,7 +1488,7 @@ defmodule RealtimeWeb.RealtimeChannelTest do "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => "supabase_realtime_admin", "db_password" => "postgres", "poll_interval" => 100, "poll_max_changes" => 100, @@ -733,19 +1512,6 @@ defmodule RealtimeWeb.RealtimeChannelTest do end end - test "registers transport pid and channel pid per tenant", %{tenant: tenant} do - jwt = Generators.generate_jwt_token(tenant) - {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) - - assert {:ok, _, %Socket{transport_pid: transport_pid_1} = socket} = - subscribe_and_join(socket, "realtime:#{random_string()}", %{}) - - assert {:ok, _, %Socket{transport_pid: ^transport_pid_1}} = - subscribe_and_join(socket, "realtime:#{random_string()}", %{}) - - assert [{_, ^transport_pid_1}] = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant.external_id) - end - defp conn_opts(tenant, token) do [ connect_info: %{ @@ -762,7 +1528,10 @@ defmodule RealtimeWeb.RealtimeChannelTest do put_in(extension, ["settings", "db_port"], db_port) ] - Realtime.Api.update_tenant(tenant, %{extensions: extensions}) + with {:ok, tenant} <- Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions}) do + Realtime.Tenants.Cache.update_cache(tenant) + {:ok, tenant} + end end defp assert_process_down(pid) do diff --git a/test/realtime_web/channels/socket_disconnect_test.exs b/test/realtime_web/channels/socket_disconnect_test.exs deleted file mode 100644 index 585958457..000000000 --- a/test/realtime_web/channels/socket_disconnect_test.exs +++ /dev/null @@ -1,165 +0,0 @@ -defmodule RealtimeWeb.SocketDisconnectTest do - use ExUnit.Case - import ExUnit.CaptureLog - import Generators - - alias Phoenix.PubSub - alias RealtimeWeb.SocketDisconnect - - @aux_mod (quote do - defmodule DisconnectTestAux do - alias RealtimeWeb.SocketDisconnect - - def generate_tenant_processes(tenant_external_id) do - tenant_pids = - for _ <- 1..10 do - pid = spawn(fn -> Process.sleep(:infinity) end) - SocketDisconnect.add(tenant_external_id, %Phoenix.Socket{transport_pid: pid}) - pid - end - - other_tenant_pids = - for _ <- 1..10 do - pid = spawn(fn -> Process.sleep(:infinity) end) - SocketDisconnect.add(Generators.random_string(), %Phoenix.Socket{transport_pid: pid}) - pid - end - - %{tenant: tenant_pids, other: other_tenant_pids} - end - end - end) - - Code.eval_quoted(@aux_mod) - - describe "add/2" do - test "successfully registers a socket with the tenant's external_id" do - tenant_external_id = random_string() - pid = spawn(fn -> Process.sleep(:infinity) end) - socket = %Phoenix.Socket{transport_pid: pid} - - assert :ok = SocketDisconnect.add(tenant_external_id, socket) - # Verify that the socket is registered in the registry - - assert [{_, ^pid}] = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant_external_id) - end - - test "successfully registers multiple entries repeatedly without collision" do - tenant_external_id = random_string() - - transport_pid = spawn(fn -> Process.sleep(:infinity) end) - socket = %Phoenix.Socket{transport_pid: transport_pid} - - assert :ok = SocketDisconnect.add(tenant_external_id, socket) - assert :ok = SocketDisconnect.add(tenant_external_id, socket) - assert :ok = SocketDisconnect.add(tenant_external_id, socket) - - assert result = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant_external_id) - assert length(result) == 1 - assert [{_, ^transport_pid}] = result - assert Process.alive?(transport_pid) - end - - test "successfully registers multiple entries from different pids without collision" do - tenant_external_id = random_string() - - for _ <- 1..10 do - pid = spawn(fn -> Process.sleep(:infinity) end) - socket = %Phoenix.Socket{transport_pid: pid} - assert :ok = SocketDisconnect.add(tenant_external_id, socket) - assert :ok = SocketDisconnect.add(tenant_external_id, socket) - assert :ok = SocketDisconnect.add(tenant_external_id, socket) - - pid - end - - # Verify that only one entry is registered - result = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant_external_id) - assert length(result) == 10 - for {_, pid} <- result, do: assert(Process.alive?(pid)) - end - end - - describe "disconnect/1" do - test "successfully disconnects all sockets associated with a given tenant on the current node" do - tenant_external_id = random_string() - %{tenant: tenant_pids, other: other_pids} = DisconnectTestAux.generate_tenant_processes(tenant_external_id) - - # Ensure all processes are alive before disconnecting - for pid <- tenant_pids, do: assert(Process.alive?(pid)) - for pid <- other_pids, do: assert(Process.alive?(pid)) - - # Perform the disconnect - assert :ok = SocketDisconnect.disconnect(tenant_external_id) - - # Verify that tenant processes are killed and other processes remain alive - for pid <- tenant_pids, do: refute(Process.alive?(pid)) - for pid <- other_pids, do: assert(Process.alive?(pid)) - end - - test "after disconnect, pid is unregistered" do - tenant_external_id = random_string() - PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant_external_id) - %{tenant: tenant_pids, other: other_pids} = DisconnectTestAux.generate_tenant_processes(tenant_external_id) - - # Ensure all processes are alive before disconnecting - for pid <- tenant_pids, do: assert(Process.alive?(pid)) - for pid <- other_pids, do: assert(Process.alive?(pid)) - - # Perform the disconnect - log = - capture_log(fn -> - assert :ok = SocketDisconnect.disconnect(tenant_external_id) - end) - - assert_received :disconnect - Process.sleep(200) - assert [] = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant_external_id) - assert log =~ "Disconnecting all sockets for tenant #{tenant_external_id}" - end - end - - describe "distributed_disconnect/1" do - setup do - {:ok, node} = Clustered.start(@aux_mod) - %{node: node} - end - - test "successfully kills all processes associated with a given tenant and non others" do - tenant_external_id = random_string() - # Generate fake processes for the tenant and other tenants - %{tenant: tenant_pids, other: other_pids} = DisconnectTestAux.generate_tenant_processes(tenant_external_id) - - %{tenant: remote_tenant_pids, other: remote_other_pids} = - :erpc.call(Node.self(), DisconnectTestAux, :generate_tenant_processes, [tenant_external_id]) - - # Ensure all processes are alive before disconnecting - for pid <- tenant_pids ++ remote_tenant_pids, do: assert(Process.alive?(pid)) - for pid <- other_pids ++ remote_other_pids, do: assert(Process.alive?(pid)) - - # Perform the distributed disconnect - assert [:ok, :ok] = SocketDisconnect.distributed_disconnect(tenant_external_id) - - # Verify that tenant processes are killed and other processes remain alive - for pid <- tenant_pids ++ remote_tenant_pids, do: refute(Process.alive?(pid)) - for pid <- other_pids ++ remote_other_pids, do: assert(Process.alive?(pid)) - end - end - - test "on registered pid dead, Registry cleans up" do - tenant_external_id = random_string() - - pid = - spawn(fn -> - pid = spawn(fn -> Process.sleep(:infinity) end) - socket = %Phoenix.Socket{transport_pid: pid} - assert :ok = SocketDisconnect.add(tenant_external_id, socket) - - assert [^pid] = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant_external_id) - end) - - Process.sleep(100) - refute Process.alive?(pid) - assert [] = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant_external_id) - end -end diff --git a/test/realtime_web/channels/tenant_rate_limiters_test.exs b/test/realtime_web/channels/tenant_rate_limiters_test.exs new file mode 100644 index 000000000..05d56ec82 --- /dev/null +++ b/test/realtime_web/channels/tenant_rate_limiters_test.exs @@ -0,0 +1,31 @@ +defmodule RealtimeWeb.TenantRateLimitersTest do + use Realtime.DataCase, async: true + + use Mimic + alias RealtimeWeb.TenantRateLimiters + alias Realtime.Api.Tenant + + setup do + tenant = %Tenant{external_id: random_string(), max_concurrent_users: 1, max_joins_per_second: 1} + + %{tenant: tenant} + end + + describe "check_tenant/1" do + test "rate is not exceeded", %{tenant: tenant} do + assert TenantRateLimiters.check_tenant(tenant) == :ok + end + + test "max concurrent users is exceeded", %{tenant: tenant} do + Realtime.UsersCounter.add(self(), tenant.external_id) + + assert TenantRateLimiters.check_tenant(tenant) == {:error, :too_many_connections} + end + + test "max joins is exceeded", %{tenant: tenant} do + expect(Realtime.RateCounter, :get, fn _ -> {:ok, %{limit: %{triggered: true}}} end) + + assert TenantRateLimiters.check_tenant(tenant) == {:error, :too_many_joins} + end + end +end diff --git a/test/realtime_web/channels/user_socket_test.exs b/test/realtime_web/channels/user_socket_test.exs new file mode 100644 index 000000000..f66eef421 --- /dev/null +++ b/test/realtime_web/channels/user_socket_test.exs @@ -0,0 +1,102 @@ +defmodule RealtimeWeb.UserSocketTest do + use ExUnit.Case, async: true + import ExUnit.CaptureLog + + alias RealtimeWeb.Socket.V2Serializer + alias RealtimeWeb.UserSocket + + @socket %Phoenix.Socket{ + serializer: V2Serializer, + assigns: %{tenant: "test-tenant", access_token: "test-token", log_level: :error} + } + @state {%{channels: %{}, channels_inverse: %{}}, @socket} + + describe "disconnect/1" do + test "returns :ok" do + assert :ok = UserSocket.disconnect("tenant-disconnect-ok") + end + + test "broadcasts socket drain to subscribers topic" do + tenant_id = "tenant-disconnect-drain" + Phoenix.PubSub.subscribe(Realtime.PubSub, UserSocket.subscribers_id(tenant_id)) + + UserSocket.disconnect(tenant_id) + + assert_receive :socket_drain + end + + test "broadcasts system disconnect message to operations topic" do + tenant_id = "tenant-disconnect-ops" + Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant_id) + + UserSocket.disconnect(tenant_id) + + assert_receive %Phoenix.Socket.Broadcast{ + event: "system", + payload: %{extension: "system", status: "ok", message: "Server requested disconnect"} + } + end + + test "logs a warning with tenant id" do + tenant_id = "tenant-disconnect-log" + + log = + capture_log(fn -> + UserSocket.disconnect(tenant_id) + end) + + assert log =~ "Disconnecting all sockets for tenant #{tenant_id}" + end + end + + describe "handle_in/2 with invalid messages" do + test "does not crash and logs when message is an array with not enough items" do + raw = Jason.encode!(["join_ref", "ref", "topic"]) + + log = + capture_log(fn -> + assert {:ok, @state} = UserSocket.handle_in({raw, [opcode: :text]}, @state) + end) + + assert log =~ "MalformedWebSocketMessage" + end + + test "does not crash and logs when message is a map" do + raw = Jason.encode!(%{"topic" => "t", "event" => "e", "payload" => %{}}) + + log = + capture_log(fn -> + assert {:ok, @state} = UserSocket.handle_in({raw, [opcode: :text]}, @state) + end) + + assert log =~ "MalformedWebSocketMessage" + end + + test "does not crash and logs when message is empty string" do + log = + capture_log(fn -> + assert {:ok, @state} = UserSocket.handle_in({"", [opcode: :text]}, @state) + end) + + assert log =~ "MalformedWebSocketMessage" + end + + test "does not crash and logs when message is invalid JSON" do + log = + capture_log(fn -> + assert {:ok, @state} = UserSocket.handle_in({"not json", [opcode: :text]}, @state) + end) + + assert log =~ "MalformedWebSocketMessage" + end + + test "does not crash and logs on unexpected errors" do + log = + capture_log(fn -> + assert {:ok, @state} = UserSocket.handle_in({:not_a_binary, [opcode: :text]}, @state) + end) + + assert log =~ "UnknownErrorOnWebSocketMessage" + end + end +end diff --git a/test/realtime_web/controllers/broadcast_controller_test.exs b/test/realtime_web/controllers/broadcast_controller_test.exs index 9c38d58bd..418fe24b4 100644 --- a/test/realtime_web/controllers/broadcast_controller_test.exs +++ b/test/realtime_web/controllers/broadcast_controller_test.exs @@ -12,13 +12,10 @@ defmodule RealtimeWeb.BroadcastControllerTest do alias RealtimeWeb.Endpoint alias RealtimeWeb.TenantBroadcaster - @token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1MTYyMzkwMjIsInJvbGUiOiJmb28iLCJleHAiOiJiYXIifQ.Ret2CevUozCsPhpgW2FMeFL7RooLgoOvfQzNpLBj5ak" - @expired_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjEwNzMyOTAsImlhdCI6MTYyNzg4NjQ0MCwicm9sZSI6ImFub24ifQ.AHmuaydSU3XAxwoIFhd3gwGwjnBIKsjFil0JQEOLtRw" - setup %{conn: conn} do tenant = Containers.checkout_tenant(run_migrations: true) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) conn = generate_conn(conn, tenant) @@ -141,16 +138,57 @@ defmodule RealtimeWeb.BroadcastControllerTest do assert conn.status == 422 - # Wait for counters to increment. RateCounter tick is 1 second - Process.sleep(2000) - {:ok, rate_counter} = RateCounter.get(Tenants.requests_per_second_rate(tenant)) + {:ok, rate_counter} = RateCounterHelper.tick!(Tenants.requests_per_second_rate(tenant)) assert rate_counter.avg != 0.0 - {:ok, rate_counter} = RateCounter.get(Tenants.events_per_second_rate(tenant)) + {:ok, rate_counter} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant)) assert rate_counter.avg == 0.0 refute_receive {:socket_push, _, _} end + + test "returns 422 when batch of messages includes a message that exceeds the tenant payload size", %{ + conn: conn, + tenant: tenant + } do + sub_topic_1 = "sub_topic_1" + sub_topic_2 = "sub_topic_2" + + payload_1 = %{"data" => "data"} + payload_2 = %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 100)} + event_1 = "event_1" + event_2 = "event_2" + + conn = + post(conn, Routes.broadcast_path(conn, :broadcast), %{ + "messages" => [ + %{"topic" => sub_topic_1, "payload" => payload_1, "event" => event_1}, + %{"topic" => sub_topic_1, "payload" => payload_1, "event" => event_1}, + %{"topic" => sub_topic_2, "payload" => payload_2, "event" => event_2} + ] + }) + + assert conn.status == 422 + end + end + + describe "suspended tenant" do + test "returns 403 and does not broadcast when tenant is suspended", %{conn: conn, tenant: tenant} do + Realtime.Tenants.Cache.update_cache(%{tenant | suspend: true}) + + reject(&TenantBroadcaster.pubsub_broadcast/5) + + conn = + post(conn, Routes.broadcast_path(conn, :broadcast), %{ + "messages" => [ + %{"topic" => "sub_topic", "payload" => %{"data" => "data"}, "event" => "event"} + ] + }) + + assert conn.status == 403 + assert conn.resp_body == Jason.encode!(%{message: "Tenant is suspended"}) + assert calls(&TenantBroadcaster.pubsub_broadcast/5) == [] + end end describe "too many requests" do @@ -209,23 +247,28 @@ defmodule RealtimeWeb.BroadcastControllerTest do end describe "unauthorized" do - test "invalid token returns 401", %{conn: conn} do + test "invalid token returns 401", %{conn: conn, tenant: tenant} do conn = conn + |> delete_req_header("authorization") |> put_req_header("accept", "application/json") |> put_req_header("x-api-key", "potato") - |> then(&%{&1 | host: "dev_tenant.supabase.com"}) + |> then(&%{&1 | host: "#{tenant.external_id}.supabase.com"}) conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{}) assert conn.status == 401 end - test "expired token returns 401", %{conn: conn} do + test "expired token returns 401", %{conn: conn, tenant: tenant} do + now = System.system_time(:second) + expired_token = generate_jwt_token(tenant, %{role: "anon", iat: now - 200, exp: now - 100}) + conn = conn + |> delete_req_header("authorization") |> put_req_header("accept", "application/json") - |> put_req_header("x-api-key", @expired_token) - |> then(&%{&1 | host: "dev_tenant.supabase.com"}) + |> put_req_header("x-api-key", expired_token) + |> then(&%{&1 | host: "#{tenant.external_id}.supabase.com"}) conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{}) assert conn.status == 401 @@ -272,7 +315,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } do request_events_key = Tenants.requests_per_second_key(tenant) broadcast_events_key = Tenants.events_per_second_key(tenant) - expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _ -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _, _ -> :ok end) messages_to_send = Stream.repeatedly(fn -> generate_message_with_policies(db_conn, tenant) end) @@ -294,7 +337,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) - broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/4) + broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/5) Enum.each(messages_to_send, fn %{topic: topic} -> broadcast_topic = Tenants.tenant_topic(tenant, topic, false) @@ -310,7 +353,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } assert Enum.any?(broadcast_calls, fn - [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher] -> true + [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher, :broadcast] -> true _ -> false end) end) @@ -326,7 +369,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } do request_events_key = Tenants.requests_per_second_key(tenant) broadcast_events_key = Tenants.events_per_second_key(tenant) - expect(TenantBroadcaster, :pubsub_broadcast, 6, fn _, _, _, _ -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, 6, fn _, _, _, _, _ -> :ok end) channels = Stream.repeatedly(fn -> generate_message_with_policies(db_conn, tenant) end) @@ -358,7 +401,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) - broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/4) + broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/5) Enum.each(channels, fn %{topic: topic} -> broadcast_topic = Tenants.tenant_topic(tenant, topic, false) @@ -374,7 +417,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } assert Enum.count(broadcast_calls, fn - [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher] -> true + [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher, :broadcast] -> true _ -> false end) == 1 end) @@ -393,7 +436,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do open_channel_topic = Tenants.tenant_topic(tenant, "open_channel", true) assert Enum.count(broadcast_calls, fn - [_, ^open_channel_topic, ^message, RealtimeChannel.MessageDispatcher] -> true + [_, ^open_channel_topic, ^message, RealtimeChannel.MessageDispatcher, :broadcast] -> true _ -> false end) == 1 @@ -408,7 +451,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } do request_events_key = Tenants.requests_per_second_key(tenant) broadcast_events_key = Tenants.events_per_second_key(tenant) - expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _ -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _, _ -> :ok end) messages_to_send = Stream.repeatedly(fn -> generate_message_with_policies(db_conn, tenant) end) @@ -428,11 +471,12 @@ defmodule RealtimeWeb.BroadcastControllerTest do GenCounter |> expect(:add, fn ^request_events_key -> :ok end) - |> expect(:add, length(messages_to_send), fn ^broadcast_events_key -> :ok end) + # remove the one message that won't be broadcasted for this user + |> expect(:add, length(messages) - 1, fn ^broadcast_events_key -> :ok end) conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) - broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/4) + broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/5) Enum.each(messages_to_send, fn %{topic: topic} -> broadcast_topic = Tenants.tenant_topic(tenant, topic, false) @@ -448,7 +492,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } assert Enum.count(broadcast_calls, fn - [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher] -> true + [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher, :broadcast] -> true _ -> false end) == 1 end) @@ -461,7 +505,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do @tag role: "anon" test "user without permission won't broadcast", %{conn: conn, db_conn: db_conn, tenant: tenant} do request_events_key = Tenants.requests_per_second_key(tenant) - reject(&TenantBroadcaster.pubsub_broadcast/4) + reject(&TenantBroadcaster.pubsub_broadcast/5) messages = Stream.repeatedly(fn -> generate_message_with_policies(db_conn, tenant) end) @@ -482,7 +526,6 @@ defmodule RealtimeWeb.BroadcastControllerTest do GenCounter |> expect(:add, fn ^request_events_key -> 1 end) - |> reject(:add, 1) conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) @@ -497,9 +540,12 @@ defmodule RealtimeWeb.BroadcastControllerTest do end defp generate_conn(conn, tenant) do + now = System.system_time(:second) + claims = %{role: "test", iat: now, exp: now + 100_000} + conn |> put_req_header("accept", "application/json") - |> put_req_header("authorization", "Bearer #{@token}") + |> put_req_header("authorization", "Bearer #{generate_jwt_token(tenant, claims)}") |> then(&%{&1 | host: "#{tenant.external_id}.supabase.com"}) end end diff --git a/test/realtime_web/controllers/broadcast_single_controller_test.exs b/test/realtime_web/controllers/broadcast_single_controller_test.exs new file mode 100644 index 000000000..e358f78b7 --- /dev/null +++ b/test/realtime_web/controllers/broadcast_single_controller_test.exs @@ -0,0 +1,665 @@ +defmodule RealtimeWeb.BroadcastSingleControllerTest do + use RealtimeWeb.ConnCase, async: true + use Mimic + + alias Realtime.Crypto + alias Realtime.GenCounter + alias Realtime.RateCounter + alias Realtime.Tenants + alias Realtime.Tenants.Authorization + alias Realtime.Tenants.Connect + + alias RealtimeWeb.RealtimeChannel + alias RealtimeWeb.Endpoint + + setup %{conn: conn} do + tenant = Containers.checkout_tenant(run_migrations: true) + # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues + Realtime.Tenants.Cache.update_cache(tenant) + + conn = generate_conn(conn, tenant) + + {:ok, conn: conn, tenant: tenant} + end + + defp subscribe(tenant_topic, topic, serializer \\ Phoenix.Socket.V1.JSONSerializer) do + fastlane = RealtimeChannel.MessageDispatcher.fastlane_metadata(self(), serializer, topic, :error, "tenant_id") + + Endpoint.subscribe(tenant_topic, metadata: fastlane) + end + + defp assert_receive_message do + assert_receive {:socket_push, :text, data} + + data + |> IO.iodata_to_binary() + |> Jason.decode!() + end + + describe "JSON broadcast" do + test "returns 202 when JSON message is broadcasted", %{conn: conn, tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + request_events_key = Tenants.requests_per_second_key(tenant) + + GenCounter + |> expect(:add, fn ^request_events_key -> :ok end) + |> expect(:add, fn ^broadcast_events_key -> :ok end) + + sub_topic = "room:123" + event = "message" + topic = Tenants.tenant_topic(tenant, sub_topic) + payload = %{"text" => "hello", "user" => "alice"} + json_payload = Jason.encode!(payload) + + subscribe(topic, sub_topic) + subscribe(topic, sub_topic, RealtimeWeb.Socket.V2Serializer) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), payload) + + assert conn.status == 202 + + message = assert_receive_message() + + assert message == %{ + "event" => "broadcast", + "payload" => %{ + "payload" => payload, + "event" => event, + "type" => "broadcast" + }, + "ref" => nil, + "topic" => sub_topic + } + + # Assert WebSocket binary message received with V2Serializer format + assert_receive {:socket_push, :binary, data} + + # Verify V2 binary format: + # Header: [type(1), topic_size(1), event_size(1), metadata_size(1), encoding(1)] + # Body: [topic, event, metadata?, payload] + topic_size = byte_size(sub_topic) + event_size = byte_size(event) + + assert IO.iodata_to_binary(data) == << + # user broadcast type = 4 + 4::size(8), + # sizes + topic_size::size(8), + event_size::size(8), + # metadata_size = 0 (no metadata) + 0::size(8), + # json encoding = 1 + 1::size(8), + # topic and event strings + sub_topic::binary, + event::binary, + # binary payload + json_payload::binary + >> + + refute_receive {:socket_push, _, _} + end + + test "handles empty JSON payload", %{conn: conn, tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + request_events_key = Tenants.requests_per_second_key(tenant) + + GenCounter + |> expect(:add, fn ^request_events_key -> :ok end) + |> expect(:add, fn ^broadcast_events_key -> :ok end) + + sub_topic = "room:456" + event = "empty" + topic = Tenants.tenant_topic(tenant, sub_topic) + + subscribe(topic, sub_topic) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{}) + + assert conn.status == 202 + + message = assert_receive_message() + + assert message == %{ + "event" => "broadcast", + "payload" => %{ + "payload" => %{}, + "event" => event, + "type" => "broadcast" + }, + "ref" => nil, + "topic" => sub_topic + } + + refute_receive {:socket_push, _, _} + end + + test "handles topics with colons", %{conn: conn, tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + request_events_key = Tenants.requests_per_second_key(tenant) + + GenCounter + |> expect(:add, fn ^request_events_key -> :ok end) + |> expect(:add, fn ^broadcast_events_key -> :ok end) + + sub_topic = "room:lobby:main" + event = "message" + topic = Tenants.tenant_topic(tenant, sub_topic) + + subscribe(topic, sub_topic) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{"data" => "test"}) + + assert conn.status == 202 + + message = assert_receive_message() + + assert message == %{ + "event" => "broadcast", + "payload" => %{ + "payload" => %{"data" => "test"}, + "event" => event, + "type" => "broadcast" + }, + "ref" => nil, + "topic" => sub_topic + } + + refute_receive {:socket_push, _, _} + end + + test "returns 422 when private=true and the JWT role cannot be set in Postgres", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + + # Only request counter is bumped; broadcast counter must NOT be incremented because no message is published. + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + sub_topic = "private:room" + event = "secret" + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event) <> "?private=true", %{ + "secret" => "data" + }) + + assert conn.status == 422 + assert Jason.decode!(conn.resp_body)["message"] == "RLS policy error" + end + + test "handles private=false query param (default)", %{conn: conn, tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + request_events_key = Tenants.requests_per_second_key(tenant) + + GenCounter + |> expect(:add, fn ^request_events_key -> :ok end) + |> expect(:add, fn ^broadcast_events_key -> :ok end) + + sub_topic = "public:room" + event = "message" + topic = Tenants.tenant_topic(tenant, sub_topic) + + subscribe(topic, sub_topic) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event) <> "?private=false", %{ + "data" => "public" + }) + + assert conn.status == 202 + end + + test "returns 422 when JSON payload exceeds size limit", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + sub_topic = "room:large" + event = "message" + large_payload = %{"data" => String.duplicate("a", tenant.max_payload_size_in_kb * 1024)} + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), large_payload) + + assert conn.status == 422 + assert Jason.decode!(conn.resp_body)["errors"]["payload"] == ["Payload size exceeds tenant limit"] + + {:ok, rate_counter} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant)) + assert rate_counter.avg == 0.0 + end + + test "returns 401 when JWT is expired", %{conn: conn, tenant: tenant} do + sub_topic = "room:123" + event = "message" + now = System.system_time(:second) + expired_token = generate_jwt_token(tenant, %{role: "anon", iat: now - 200, exp: now - 100}) + + conn = + conn + |> put_req_header("authorization", "Bearer #{expired_token}") + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{"data" => "test"}) + + assert conn.status == 401 + end + + test "returns 401 when JWT is missing", %{conn: conn, tenant: _tenant} do + sub_topic = "room:123" + event = "message" + + conn = + conn + |> delete_req_header("authorization") + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{"data" => "test"}) + + assert conn.status == 401 + end + end + + describe "Binary broadcast" do + test "returns 202 when binary message is broadcasted", %{conn: conn, tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + request_events_key = Tenants.requests_per_second_key(tenant) + + GenCounter + |> expect(:add, fn ^request_events_key -> :ok end) + |> expect(:add, fn ^broadcast_events_key -> :ok end) + + sub_topic = "binary:room" + event = "data" + topic = Tenants.tenant_topic(tenant, sub_topic) + binary_payload = <<1, 2, 3, 4, 5>> + + # Subscribe with V2Serializer to receive binary messages + subscribe(topic, sub_topic, RealtimeWeb.Socket.V2Serializer) + + conn = + conn + |> put_req_header("content-type", "application/octet-stream") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), binary_payload) + + assert conn.status == 202 + + # Assert binary message received with V2Serializer format + assert_receive {:socket_push, :binary, data} + + # Verify V2 binary format: + # Header: [type(1), topic_size(1), event_size(1), metadata_size(1), encoding(1)] + # Body: [topic, event, metadata?, payload] + topic_size = byte_size(sub_topic) + event_size = byte_size(event) + + assert IO.iodata_to_binary(data) == << + # user broadcast type = 4 + 4::size(8), + # sizes + topic_size::size(8), + event_size::size(8), + # metadata_size = 0 (no metadata) + 0::size(8), + # binary encoding = 0 + 0::size(8), + # topic and event strings + sub_topic::binary, + event::binary, + # binary payload + binary_payload::binary + >> + end + + test "handles empty binary payload", %{conn: conn, tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + request_events_key = Tenants.requests_per_second_key(tenant) + + GenCounter + |> expect(:add, fn ^request_events_key -> :ok end) + |> expect(:add, fn ^broadcast_events_key -> :ok end) + + sub_topic = "binary:empty" + event = "empty" + + conn = + conn + |> put_req_header("content-type", "application/octet-stream") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), <<>>) + + assert conn.status == 202 + end + + test "returns 422 when binary payload exceeds size limit", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + sub_topic = "binary:large" + event = "data" + large_binary = :crypto.strong_rand_bytes(tenant.max_payload_size_in_kb * 1024 + 1) + + conn = + conn + |> put_req_header("content-type", "application/octet-stream") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), large_binary) + + assert conn.status == 422 + assert Jason.decode!(conn.resp_body)["errors"]["payload"] == ["Payload size exceeds tenant limit"] + + {:ok, rate_counter} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant)) + assert rate_counter.avg == 0.0 + end + + test "returns 401 when JWT is expired for binary", %{conn: conn, tenant: tenant} do + sub_topic = "binary:room" + event = "data" + now = System.system_time(:second) + expired_token = generate_jwt_token(tenant, %{role: "anon", iat: now - 200, exp: now - 100}) + + conn = + conn + |> put_req_header("authorization", "Bearer #{expired_token}") + |> put_req_header("content-type", "application/octet-stream") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), <<1, 2, 3>>) + + assert conn.status == 401 + end + end + + describe "Content-Type handling" do + test "returns 415 for unsupported content type", %{conn: conn, tenant: _tenant} do + sub_topic = "room:123" + event = "message" + + conn = + conn + |> put_req_header("content-type", "text/plain") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), "plain text") + + assert conn.status == 415 + assert Jason.decode!(conn.resp_body)["error"] =~ "Unsupported Media Type" + end + + test "handles application/json with charset", %{conn: conn, tenant: tenant} do + broadcast_events_key = Tenants.events_per_second_key(tenant) + request_events_key = Tenants.requests_per_second_key(tenant) + + GenCounter + |> expect(:add, fn ^request_events_key -> :ok end) + |> expect(:add, fn ^broadcast_events_key -> :ok end) + + sub_topic = "room:charset" + event = "message" + + conn = + conn + |> put_req_header("content-type", "application/json; charset=utf-8") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{"data" => "test"}) + + assert conn.status == 202 + end + end + + describe "suspended tenant" do + test "returns 403 and does not broadcast when tenant is suspended", %{conn: conn, tenant: tenant} do + Realtime.Tenants.Cache.update_cache(%{tenant | suspend: true}) + + sub_topic = "room:123" + event = "message" + topic = Tenants.tenant_topic(tenant, sub_topic) + + subscribe(topic, sub_topic) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{"data" => "test"}) + + assert conn.status == 403 + assert Jason.decode!(conn.resp_body)["message"] == "Tenant is suspended" + + refute_receive {:socket_push, _, _} + end + end + + describe "Rate limiting" do + test "returns 429 when rate limit is exceeded", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + events_per_second_rate = Tenants.events_per_second_rate(tenant) + + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + RateCounter + |> stub(:new, fn _ -> {:ok, nil} end) + |> stub(:get, fn rate -> + case rate do + ^events_per_second_rate -> + {:ok, %RateCounter{avg: tenant.max_events_per_second + 1}} + + _ -> + {:ok, %RateCounter{avg: 0}} + end + end) + + sub_topic = "room:rate-limited" + event = "message" + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{"data" => "test"}) + + assert conn.status == 429 + end + end + + describe "Private broadcast authorization" do + setup %{conn: conn, tenant: tenant} do + jwt_secret = Crypto.decrypt!(tenant.jwt_secret) + claims = %{sub: "test-user", role: "anon", exp: Joken.current_time() + 1_000} + signer = Joken.Signer.create("HS256", jwt_secret) + jwt = Joken.generate_and_sign!(%{}, claims, signer) + + conn = + conn + |> delete_req_header("authorization") + |> put_req_header("authorization", "Bearer #{jwt}") + + {:ok, conn: conn} + end + + test "returns 403 when anon caller has no RLS write policy", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + sub_topic = "private:room" + event = "secret" + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event) <> "?private=true", %{ + "secret" => "data" + }) + + assert conn.status == 403 + assert Jason.decode!(conn.resp_body)["message"] == "Unauthorized" + end + + test "returns 422 when authorization query is canceled", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + expect(Authorization, :get_write_authorizations, fn _, _ -> + {:error, :query_canceled, + %Postgrex.Error{postgres: %{code: :query_canceled, message: "canceling statement due to user request"}}} + end) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1}) + + assert conn.status == 422 + assert Jason.decode!(conn.resp_body)["message"] == "Query canceled" + end + + test "returns 422 when messages partition is missing", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + expect(Authorization, :get_write_authorizations, fn _, _ -> {:error, :missing_partition} end) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1}) + + assert conn.status == 422 + assert Jason.decode!(conn.resp_body)["message"] == "Missing messages partition" + end + + test "returns 429 when authorization signals connection pool exhaustion", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + expect(Authorization, :get_write_authorizations, fn _, _ -> {:error, :increase_connection_pool} end) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1}) + + assert conn.status == 429 + assert Jason.decode!(conn.resp_body)["message"] == "Connection pool exhausted" + end + + test "returns 422 when tenant database is unavailable", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + expect(Authorization, :get_write_authorizations, fn _, _ -> {:error, :tenant_database_unavailable} end) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1}) + + assert conn.status == 422 + assert Jason.decode!(conn.resp_body)["message"] == "Tenant database unavailable" + end + + test "returns 500 for unexpected authorization errors", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + expect(Authorization, :get_write_authorizations, fn _, _ -> {:error, "boom"} end) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1}) + + assert conn.status == 500 + assert Jason.decode!(conn.resp_body)["message"] == "Unable to authorize broadcast" + end + + test "returns 422 when tenant database is initializing", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :initializing} end) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1}) + + assert conn.status == 422 + assert Jason.decode!(conn.resp_body)["message"] == "Tenant database initializing" + end + + test "returns 422 when tenant database connection is initializing", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :tenant_database_connection_initializing} end) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1}) + + assert conn.status == 422 + assert Jason.decode!(conn.resp_body)["message"] == "Tenant database connection initializing" + end + + test "returns 422 when tenant database has too many connections", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :tenant_db_too_many_connections} end) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1}) + + assert conn.status == 422 + assert Jason.decode!(conn.resp_body)["message"] == "Tenant database has too many connections" + end + + test "returns 422 when connect rate limit is reached", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :connect_rate_limit_reached} end) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1}) + + assert conn.status == 422 + assert Jason.decode!(conn.resp_body)["message"] == "Connect rate limit reached" + end + + test "returns 500 when an RPC error occurs while looking up the connection", %{conn: conn, tenant: tenant} do + request_events_key = Tenants.requests_per_second_key(tenant) + expect(GenCounter, :add, fn ^request_events_key -> :ok end) + + expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :rpc_error, :timeout} end) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1}) + + assert conn.status == 500 + assert Jason.decode!(conn.resp_body)["message"] == "RPC error" + end + end + + defp generate_conn(conn, tenant) do + now = System.system_time(:second) + claims = %{role: "test", iat: now, exp: now + 100_000} + + conn + |> put_req_header("accept", "application/json") + |> put_req_header("authorization", "Bearer #{generate_jwt_token(tenant, claims)}") + |> then(&%{&1 | host: "#{tenant.external_id}.supabase.com"}) + end +end diff --git a/test/realtime_web/controllers/fallback_controller_test.exs b/test/realtime_web/controllers/fallback_controller_test.exs new file mode 100644 index 000000000..ce1684fce --- /dev/null +++ b/test/realtime_web/controllers/fallback_controller_test.exs @@ -0,0 +1,76 @@ +defmodule RealtimeWeb.FallbackControllerTest do + use RealtimeWeb.ConnCase, async: true + + import ExUnit.CaptureLog + + alias RealtimeWeb.FallbackController + + describe "call/2" do + test "returns 404 with not found message", %{conn: conn} do + conn = FallbackController.call(conn, {:error, :not_found}) + + assert json_response(conn, 404) == %{"message" => "not found"} + end + + test "returns 422 with changeset errors", %{conn: conn} do + changeset = + {%{}, %{name: :string}} + |> Ecto.Changeset.cast(%{name: 123}, [:name]) + + conn = FallbackController.call(conn, {:error, changeset}) + + assert %{"errors" => _} = json_response(conn, 422) + end + + test "returns custom status with message", %{conn: conn} do + conn = FallbackController.call(conn, {:error, :bad_request, "invalid input"}) + + assert json_response(conn, 400) == %{"message" => "invalid input"} + end + + test "does not log UnprocessableEntity for non-422 statuses", %{conn: conn} do + log = + capture_log(fn -> + conn = FallbackController.call(conn, {:error, :forbidden, "Tenant is suspended"}) + + assert json_response(conn, 403) == %{"message" => "Tenant is suspended"} + end) + + refute log =~ "UnprocessableEntity" + end + + test "logs UnprocessableEntity for 422 status with message", %{conn: conn} do + log = + capture_log(fn -> + conn = FallbackController.call(conn, {:error, :unprocessable_entity, "invalid input"}) + + assert json_response(conn, 422) == %{"message" => "invalid input"} + end) + + assert log =~ "UnprocessableEntity: invalid input" + end + + test "returns 401 for generic error tuple", %{conn: conn} do + conn = FallbackController.call(conn, {:error, "something went wrong"}) + + assert json_response(conn, 401) == %{"message" => "Unauthorized"} + end + + test "returns 422 for bare invalid changeset", %{conn: conn} do + changeset = + {%{}, %{name: :string}} + |> Ecto.Changeset.cast(%{name: 123}, [:name]) + |> Map.put(:valid?, false) + + conn = FallbackController.call(conn, changeset) + + assert %{"errors" => _} = json_response(conn, 422) + end + + test "returns 422 for unknown error format", %{conn: conn} do + conn = FallbackController.call(conn, :unexpected_value) + + assert json_response(conn, 422) == %{"message" => "Unknown error"} + end + end +end diff --git a/test/realtime_web/controllers/live_dasboard_test.exs b/test/realtime_web/controllers/live_dasboard_test.exs index 9e5c06c43..0a62d962d 100644 --- a/test/realtime_web/controllers/live_dasboard_test.exs +++ b/test/realtime_web/controllers/live_dasboard_test.exs @@ -1,28 +1,25 @@ defmodule RealtimeWeb.LiveDashboardTest do use RealtimeWeb.ConnCase import Generators + import Mimic - describe "live_dashboard" do + describe "live_dashboard with basic_auth" do setup do user = random_string() password = random_string() - System.put_env("DASHBOARD_USER", user) - System.put_env("DASHBOARD_PASSWORD", password) + Application.put_env(:realtime, :dashboard_auth, :basic_auth) + Application.put_env(:realtime, :dashboard_credentials, {user, password}) on_exit(fn -> - System.delete_env("DASHBOARD_USER") - System.delete_env("DASHBOARD_PASSWORD") + Application.delete_env(:realtime, :dashboard_auth) + Application.delete_env(:realtime, :dashboard_credentials) end) %{user: user, password: password} end - test "with credetentials renders view", %{ - conn: conn, - user: user, - password: password - } do + test "with credentials renders view", %{conn: conn, user: user, password: password} do path = conn |> using_basic_auth(user, password) @@ -34,9 +31,42 @@ defmodule RealtimeWeb.LiveDashboardTest do assert html_response(conn, 200) =~ "Dashboard" end - test "without credetentials returns 401", %{conn: conn} do + test "without credentials returns 401", %{conn: conn} do assert conn |> get("/admin/dashboard") |> response(401) end + + test "with wrong credentials returns 401", %{conn: conn} do + assert conn |> using_basic_auth("wrong", "wrong") |> get("/admin/dashboard") |> response(401) + end + end + + describe "live_dashboard with zta" do + setup do + Application.put_env(:realtime, :dashboard_auth, :zta) + + on_exit(fn -> Application.delete_env(:realtime, :dashboard_auth) end) + end + + test "with valid cf token renders view", %{conn: conn} do + stub(NimbleZTA.Cloudflare, :authenticate, fn _name, conn -> {conn, %{email: "user@example.com"}} end) + + path = conn |> get("/admin/dashboard") |> redirected_to(302) + conn = conn |> recycle() |> get(path) + + assert html_response(conn, 200) =~ "Dashboard" + end + + test "without cf token returns 403", %{conn: conn} do + stub(NimbleZTA.Cloudflare, :authenticate, fn _name, conn -> {conn, nil} end) + + assert conn |> get("/admin/dashboard") |> response(403) + end + + test "when zta service is unavailable returns 503", %{conn: conn} do + stub(NimbleZTA.Cloudflare, :authenticate, fn _name, _conn -> exit(:noproc) end) + + assert conn |> get("/admin/dashboard") |> response(503) + end end defp using_basic_auth(conn, username, password) do diff --git a/test/realtime_web/controllers/metrics_controller_test.exs b/test/realtime_web/controllers/metrics_controller_test.exs index f16edc83f..ee37b28c4 100644 --- a/test/realtime_web/controllers/metrics_controller_test.exs +++ b/test/realtime_web/controllers/metrics_controller_test.exs @@ -1,77 +1,284 @@ defmodule RealtimeWeb.MetricsControllerTest do # Usage of Clustered - # Also changing Application env use RealtimeWeb.ConnCase, async: false + alias Realtime.GenRpc import ExUnit.CaptureLog + use Mimic + + # {help_metric, value_metric, tags} + # help_metric: base name checked against "# HELP " in the response + # value_metric: metric name passed to MetricsHelper.search (distributions use _count suffix); nil = skip value check + # tags: label filters for the value assertion; nil = any labels + @global_metrics [ + # BEAM / OS — polling metrics, no fired events, skip value check + {"beam_system_schedulers_online_info", nil, nil}, + {"osmon_ram_usage", nil, nil}, + # Phoenix counters — populated by fire_all_tenant_events/0 + {"phoenix_channel_joined_total", "phoenix_channel_joined_total", + [result: "ok", transport: "websocket", endpoint: "RealtimeWeb.Endpoint"]}, + # Phoenix distributions — value lives under _count suffix + {"phoenix_channel_handled_in_duration_milliseconds", "phoenix_channel_handled_in_duration_milliseconds_count", + [endpoint: "RealtimeWeb.Endpoint"]}, + {"phoenix_socket_connected_duration_milliseconds", "phoenix_socket_connected_duration_milliseconds_count", + [ + result: "ok", + transport: "websocket", + endpoint: "RealtimeWeb.Endpoint", + serializer: "Phoenix.Socket.V2.JSONSerializer" + ]}, + # Phoenix connections — polling metrics, skip value check + {"phoenix_connections_active", nil, nil}, + {"phoenix_connections_max", nil, nil}, + # GenRPC call latency — distribution, value lives under _count suffix + {"realtime_global_rpc", "realtime_global_rpc_count", [success: "true", mechanism: "erpc"]}, + # Global aggregates — sums with no explicit tags (framework adds global labels) + {"realtime_channel_global_events", "realtime_channel_global_events", nil}, + {"realtime_channel_global_presence_events", "realtime_channel_global_presence_events", nil}, + {"realtime_channel_global_db_events", "realtime_channel_global_db_events", nil}, + {"realtime_channel_global_joins", "realtime_channel_global_joins", nil}, + {"realtime_channel_global_input_bytes", "realtime_channel_global_input_bytes", nil}, + {"realtime_channel_global_output_bytes", "realtime_channel_global_output_bytes", nil}, + {"realtime_channel_global_error", "realtime_channel_global_error", [code: "TestError"]}, + # Global payload size — distribution, value lives under _count suffix + {"realtime_payload_size", "realtime_payload_size_count", [message_type: "broadcast"]} + ] + + @tenant_metrics [ + # Per-tenant channel events — sums with tenant tag + {"realtime_channel_events", "realtime_channel_events", [tenant: "test_tenant"]}, + {"realtime_channel_presence_events", "realtime_channel_presence_events", [tenant: "test_tenant"]}, + {"realtime_channel_db_events", "realtime_channel_db_events", [tenant: "test_tenant"]}, + {"realtime_channel_joins", "realtime_channel_joins", [tenant: "test_tenant"]}, + {"realtime_channel_input_bytes", "realtime_channel_input_bytes", [tenant: "test_tenant"]}, + {"realtime_channel_output_bytes", "realtime_channel_output_bytes", [tenant: "test_tenant"]}, + # Per-tenant distributions — value lives under _count suffix + {"realtime_tenants_payload_size", "realtime_tenants_payload_size_count", + [tenant: "test_tenant", message_type: "broadcast"]}, + {"realtime_replication_poller_query_duration", "realtime_replication_poller_query_duration_count", + [tenant: "test_tenant"]}, + {"realtime_tenants_read_authorization_check", "realtime_tenants_read_authorization_check_count", + [tenant: "test_tenant"]}, + {"realtime_tenants_write_authorization_check", "realtime_tenants_write_authorization_check_count", + [tenant: "test_tenant"]}, + {"realtime_tenants_broadcast_from_database_latency_committed_at", + "realtime_tenants_broadcast_from_database_latency_committed_at_count", [tenant: "test_tenant"]}, + {"realtime_tenants_broadcast_from_database_latency_inserted_at", + "realtime_tenants_broadcast_from_database_latency_inserted_at_count", [tenant: "test_tenant"]}, + {"realtime_tenants_replay", "realtime_tenants_replay_count", [tenant: "test_tenant"]}, + # Per-tenant errors + {"realtime_channel_error", "realtime_channel_error", [code: "TestError", tenant: "test_tenant"]} + ] + + # Fires every telemetry event needed to populate all event-based metrics + defp fire_all_tenant_events do + tenant_meta = %{tenant: "test_tenant"} + + :telemetry.execute([:realtime, :channel, :error], %{count: 1}, %{code: "TestError", tenant: "test_tenant"}) + :telemetry.execute([:realtime, :rate_counter, :channel, :events], %{sum: 5}, tenant_meta) + :telemetry.execute([:realtime, :rate_counter, :channel, :presence_events], %{sum: 3}, tenant_meta) + :telemetry.execute([:realtime, :rate_counter, :channel, :db_events], %{sum: 2}, tenant_meta) + :telemetry.execute([:realtime, :rate_counter, :channel, :joins], %{sum: 1}, tenant_meta) + :telemetry.execute([:realtime, :channel, :input_bytes], %{size: 1024}, tenant_meta) + :telemetry.execute([:realtime, :channel, :output_bytes], %{size: 2048}, tenant_meta) + + :telemetry.execute( + [:realtime, :tenants, :payload, :size], + %{size: 512}, + Map.put(tenant_meta, :message_type, "broadcast") + ) + + :telemetry.execute([:realtime, :replication, :poller, :query, :stop], %{duration: 100}, tenant_meta) + :telemetry.execute([:realtime, :tenants, :read_authorization_check], %{latency: 10}, tenant_meta) + :telemetry.execute([:realtime, :tenants, :write_authorization_check], %{latency: 15}, tenant_meta) + + :telemetry.execute( + [:realtime, :tenants, :broadcast_from_database], + %{latency_committed_at: 50, latency_inserted_at: 40}, + tenant_meta + ) + + :telemetry.execute([:realtime, :tenants, :replay], %{latency: 20}, tenant_meta) + :telemetry.execute([:realtime, :rpc], %{latency: 5}, %{success: true, mechanism: :erpc}) + + :telemetry.execute([:phoenix, :channel_joined], %{}, %{ + result: :ok, + socket: %Phoenix.Socket{transport: :websocket, endpoint: RealtimeWeb.Endpoint} + }) + + :telemetry.execute([:phoenix, :channel_handled_in], %{duration: 500_000}, %{ + socket: %Phoenix.Socket{endpoint: RealtimeWeb.Endpoint} + }) + + :telemetry.execute([:phoenix, :socket_connected], %{duration: 200_000}, %{ + result: :ok, + endpoint: RealtimeWeb.Endpoint, + transport: :websocket, + serializer: Phoenix.Socket.V2.JSONSerializer + }) + end setup_all do - {:ok, _} = Clustered.start(nil, extra_config: [{:realtime, :region, "ap-southeast-2"}]) + metrics_tags = %{ + region: "ap-southeast-2", + host: "anothernode@something.com", + id: "someid" + } + + {:ok, _} = + Clustered.start(nil, + extra_config: [{:realtime, :region, "ap-southeast-2"}, {:realtime, :metrics_tags, metrics_tags}] + ) + :ok end + setup %{conn: conn} do + jwt_secret = Application.fetch_env!(:realtime, :metrics_jwt_secret) + token = generate_jwt_token(jwt_secret, %{}) + + {:ok, conn: put_req_header(conn, "authorization", "Bearer #{token}")} + end + describe "GET /metrics" do - setup %{conn: conn} do - # The metrics pipeline requires authentication - jwt_secret = Application.fetch_env!(:realtime, :metrics_jwt_secret) - token = generate_jwt_token(jwt_secret, %{}) - authenticated_conn = put_req_header(conn, "authorization", "Bearer #{token}") + test "contains both global and tenant metrics with values", %{conn: conn} do + fire_all_tenant_events() - {:ok, conn: authenticated_conn} - end + response = + conn + |> get(~p"/metrics") + |> text_response(200) - test "returns 200", %{conn: conn} do - assert response = - conn - |> get(~p"/metrics") - |> text_response(200) + for {help_metric, value_metric, tags} <- @global_metrics do + assert response =~ "# HELP #{help_metric}", "expected global metric #{help_metric} to be present" - # Check prometheus like metrics - assert response =~ - "# HELP beam_system_schedulers_online_info The number of scheduler threads that are online." + if value_metric do + assert MetricsHelper.search(response, value_metric, tags) > 0, + "expected global metric #{value_metric} to have a value with tags #{inspect(tags)}" + end + end - assert response =~ "region=\"ap-southeast-2" - assert response =~ "region=\"us-east-1" + for {help_metric, value_metric, tags} <- @tenant_metrics do + assert response =~ "# HELP #{help_metric}", "expected tenant metric #{help_metric} to be present" + + if value_metric do + assert MetricsHelper.search(response, value_metric, tags) > 0, + "expected tenant metric #{value_metric} to have a value with tags #{inspect(tags)}" + end + end + end + + test "includes region tags from all nodes", %{conn: conn} do + response = + conn + |> get(~p"/metrics") + |> text_response(200) + + assert response =~ "region=\"ap-southeast-2\"" + assert response =~ "region=\"us-east-1\"" end - test "returns 200 and log on timeout", %{conn: conn} do - current_value = Application.get_env(:realtime, :metrics_rpc_timeout) - on_exit(fn -> Application.put_env(:realtime, :metrics_rpc_timeout, current_value) end) - Application.put_env(:realtime, :metrics_rpc_timeout, 0) + test "returns 200 and logs error on node timeout", %{conn: conn} do + Mimic.stub(GenRpc, :call, fn node, mod, func, args, opts -> + if node != node() do + {:error, :rpc_error, :timeout} + else + call_original(GenRpc, :call, [node, mod, func, args, opts]) + end + end) log = capture_log(fn -> - assert response = - conn - |> get(~p"/metrics") - |> text_response(200) - - # Check prometheus like metrics - assert response =~ - "# HELP beam_system_schedulers_online_info The number of scheduler threads that are online." + response = + conn + |> get(~p"/metrics") + |> text_response(200) - refute response =~ "region=\"ap-southeast-2" - assert response =~ "region=\"us-east-1" + refute response =~ "region=\"ap-southeast-2\"" + assert response =~ "region=\"us-east-1\"" end) assert log =~ "Cannot fetch metrics from the node" end test "returns 403 when authorization header is missing", %{conn: conn} do - assert conn - |> delete_req_header("authorization") - |> get(~p"/metrics") - |> response(403) + conn + |> delete_req_header("authorization") + |> get(~p"/metrics") + |> response(403) end test "returns 403 when authorization header is wrong", %{conn: conn} do - token = generate_jwt_token("bad_secret", %{}) + conn + |> put_req_header("authorization", "Bearer #{generate_jwt_token("bad_secret", %{})}") + |> get(~p"/metrics") + |> response(403) + end + end + + describe "GET /metrics/:region" do + test "returns both global and tenant metrics with values scoped to the given region", %{conn: conn} do + fire_all_tenant_events() + + response = + conn + |> get(~p"/metrics/us-east-1") + |> text_response(200) + + for {help_metric, value_metric, tags} <- @global_metrics do + assert response =~ "# HELP #{help_metric}", "expected global metric #{help_metric} to be present" + + if value_metric do + assert MetricsHelper.search(response, value_metric, tags) > 0, + "expected global metric #{value_metric} to have a value with tags #{inspect(tags)}" + end + end + + for {help_metric, value_metric, tags} <- @tenant_metrics do + assert response =~ "# HELP #{help_metric}", "expected tenant metric #{help_metric} to be present" + + if value_metric do + assert MetricsHelper.search(response, value_metric, tags) > 0, + "expected tenant metric #{value_metric} to have a value with tags #{inspect(tags)}" + end + end + end + + test "filters metrics to the given region", %{conn: conn} do + response = + conn + |> get(~p"/metrics/ap-southeast-2") + |> text_response(200) + + assert response =~ "region=\"ap-southeast-2\"" + refute response =~ "region=\"us-east-1\"" + end + + test "returns 200 and logs error on node timeout", %{conn: conn} do + Mimic.stub(GenRpc, :call, fn _node, _mod, _func, _args, _opts -> + {:error, :rpc_error, :timeout} + end) - assert _ = - conn - |> put_req_header("authorization", "Bearer #{token}") - |> get(~p"/metrics") - |> response(403) + log = + capture_log(fn -> + assert conn |> get(~p"/metrics/ap-southeast-2") |> text_response(200) == "" + end) + + assert log =~ "Cannot fetch metrics from the node" + end + + test "returns 403 when authorization header is missing", %{conn: conn} do + conn + |> delete_req_header("authorization") + |> get(~p"/metrics/ap-southeast-2") + |> response(403) + end + + test "returns 403 when authorization header is wrong", %{conn: conn} do + conn + |> put_req_header("authorization", "Bearer #{generate_jwt_token("bad_secret", %{})}") + |> get(~p"/metrics/ap-southeast-2") + |> response(403) end end end diff --git a/test/realtime_web/controllers/page_controller_test.exs b/test/realtime_web/controllers/page_controller_test.exs index 91fe825b5..581c46970 100644 --- a/test/realtime_web/controllers/page_controller_test.exs +++ b/test/realtime_web/controllers/page_controller_test.exs @@ -1,5 +1,7 @@ defmodule RealtimeWeb.PageControllerTest do - use RealtimeWeb.ConnCase + use RealtimeWeb.ConnCase, async: false + + import ExUnit.CaptureLog test "GET / renders index page", %{conn: conn} do conn = get(conn, "/") @@ -10,4 +12,48 @@ defmodule RealtimeWeb.PageControllerTest do conn = get(conn, "/healthcheck") assert text_response(conn, 200) == "ok" end + + describe "GET /healthcheck logging behavior" do + setup do + original_value = Application.get_env(:realtime, :disable_healthcheck_logging, false) + on_exit(fn -> Application.put_env(:realtime, :disable_healthcheck_logging, original_value) end) + :ok + end + + test "logs request when DISABLE_HEALTHCHECK_LOGGING is false", %{conn: conn} do + Application.put_env(:realtime, :disable_healthcheck_logging, false) + + log = + capture_log(fn -> + conn = get(conn, "/healthcheck") + assert text_response(conn, 200) == "ok" + end) + + assert log =~ "GET /healthcheck" + end + + test "does not log request when DISABLE_HEALTHCHECK_LOGGING is true", %{conn: conn} do + Application.put_env(:realtime, :disable_healthcheck_logging, true) + + log = + capture_log(fn -> + conn = get(conn, "/healthcheck") + assert text_response(conn, 200) == "ok" + end) + + refute log =~ "GET /healthcheck" + end + + test "logs request when DISABLE_HEALTHCHECK_LOGGING is not set (default)", %{conn: conn} do + Application.delete_env(:realtime, :disable_healthcheck_logging) + + log = + capture_log(fn -> + conn = get(conn, "/healthcheck") + assert text_response(conn, 200) == "ok" + end) + + assert log =~ "GET /healthcheck" + end + end end diff --git a/test/realtime_web/controllers/tenant_controller_test.exs b/test/realtime_web/controllers/tenant_controller_test.exs index 3974e7e7b..2e6e775a5 100644 --- a/test/realtime_web/controllers/tenant_controller_test.exs +++ b/test/realtime_web/controllers/tenant_controller_test.exs @@ -3,6 +3,7 @@ defmodule RealtimeWeb.TenantControllerTest do # Also using global otel_simple_processor use RealtimeWeb.ConnCase, async: false + import ExUnit.CaptureLog require OpenTelemetry.Tracer, as: Tracer alias Realtime.Api.Tenant @@ -48,7 +49,7 @@ defmodule RealtimeWeb.TenantControllerTest do test "returns not found on non existing tenant", %{conn: conn} do conn = get(conn, ~p"/api/tenants/no") response = json_response(conn, 404) - assert response == %{"error" => "not found"} + assert response == %{"message" => "not found"} end test "sets appropriate observability metadata", %{conn: conn, tenant: tenant} do @@ -89,7 +90,7 @@ defmodule RealtimeWeb.TenantControllerTest do assert Crypto.encrypt!("127.0.0.1") == settings["db_host"] assert Crypto.encrypt!("postgres") == settings["db_name"] - assert Crypto.encrypt!("supabase_admin") == settings["db_user"] + assert Crypto.encrypt!("supabase_realtime_admin") == settings["db_user"] refute settings["db_password"] Process.sleep(100) @@ -118,7 +119,7 @@ defmodule RealtimeWeb.TenantControllerTest do assert Crypto.encrypt!("127.0.0.1") == settings["db_host"] assert Crypto.encrypt!("postgres") == settings["db_name"] - assert Crypto.encrypt!("supabase_admin") == settings["db_user"] + assert Crypto.encrypt!("supabase_realtime_admin") == settings["db_user"] refute settings["db_password"] Process.sleep(100) %{extensions: [%{settings: settings}]} = tenant = Tenants.get_tenant_by_external_id(external_id) @@ -133,7 +134,7 @@ defmodule RealtimeWeb.TenantControllerTest do test "renders tenant when data is valid", %{conn: conn, tenant: tenant} do external_id = tenant.external_id - port = Database.from_tenant(tenant, "realtime_test", :stop).port + port = tenant_db_port(tenant) attrs = default_tenant_attrs(port) attrs = Map.put(attrs, "external_id", external_id) conn = post(conn, ~p"/api/tenants", tenant: attrs) @@ -147,6 +148,46 @@ defmodule RealtimeWeb.TenantControllerTest do assert 100 = json_response(conn, 200)["data"]["max_joins_per_second"] end + test "can set max_client_presence_events_per_window", %{conn: conn, tenant: tenant} do + external_id = tenant.external_id + port = tenant_db_port(tenant) + attrs = default_tenant_attrs(port) |> Map.put("max_client_presence_events_per_window", 42) + attrs = Map.put(attrs, "external_id", external_id) + + conn = post(conn, ~p"/api/tenants", tenant: attrs) + assert %{"max_client_presence_events_per_window" => 42} = json_response(conn, 200)["data"] + + conn = get(conn, Routes.tenant_path(conn, :show, external_id)) + assert 42 = json_response(conn, 200)["data"]["max_client_presence_events_per_window"] + end + + test "max_client_presence_events_per_window defaults to nil", %{conn: conn, tenant: tenant} do + external_id = tenant.external_id + + conn = get(conn, Routes.tenant_path(conn, :show, external_id)) + assert is_nil(json_response(conn, 200)["data"]["max_client_presence_events_per_window"]) + end + + test "can set client_presence_window_ms", %{conn: conn, tenant: tenant} do + external_id = tenant.external_id + port = tenant_db_port(tenant) + attrs = default_tenant_attrs(port) |> Map.put("client_presence_window_ms", 5_000) + attrs = Map.put(attrs, "external_id", external_id) + + conn = post(conn, ~p"/api/tenants", tenant: attrs) + assert %{"client_presence_window_ms" => 5_000} = json_response(conn, 200)["data"] + + conn = get(conn, Routes.tenant_path(conn, :show, external_id)) + assert 5_000 = json_response(conn, 200)["data"]["client_presence_window_ms"] + end + + test "client_presence_window_ms defaults to nil", %{conn: conn, tenant: tenant} do + external_id = tenant.external_id + + conn = get(conn, Routes.tenant_path(conn, :show, external_id)) + assert is_nil(json_response(conn, 200)["data"]["client_presence_window_ms"]) + end + test "renders errors when data is invalid", %{conn: conn} do conn = post(conn, ~p"/api/tenants", tenant: @invalid_attrs) assert json_response(conn, 422)["errors"] != %{} @@ -164,7 +205,7 @@ defmodule RealtimeWeb.TenantControllerTest do test "renders tenant when data is valid", %{tenant: tenant, conn: conn} do external_id = tenant.external_id - port = Database.from_tenant(tenant, "realtime_test", :stop).port + port = tenant_db_port(tenant) attrs = default_tenant_attrs(port) conn = put(conn, ~p"/api/tenants/#{external_id}", tenant: attrs) @@ -178,6 +219,38 @@ defmodule RealtimeWeb.TenantControllerTest do assert 100 = json_response(conn, 200)["data"]["max_joins_per_second"] end + test "can update max_client_presence_events_per_window", %{tenant: tenant, conn: conn} do + external_id = tenant.external_id + port = tenant_db_port(tenant) + attrs = default_tenant_attrs(port) |> Map.put("max_client_presence_events_per_window", 99) + + conn = put(conn, ~p"/api/tenants/#{external_id}", tenant: attrs) + assert %{"max_client_presence_events_per_window" => 99} = json_response(conn, 200)["data"] + end + + test "can update client_presence_window_ms", %{tenant: tenant, conn: conn} do + external_id = tenant.external_id + port = tenant_db_port(tenant) + attrs = default_tenant_attrs(port) |> Map.put("client_presence_window_ms", 10_000) + + conn = put(conn, ~p"/api/tenants/#{external_id}", tenant: attrs) + assert %{"client_presence_window_ms" => 10_000} = json_response(conn, 200)["data"] + end + + test "can update presence_enabled", %{tenant: tenant, conn: conn} do + external_id = tenant.external_id + port = tenant_db_port(tenant) + + assert tenant.presence_enabled == false + + attrs = default_tenant_attrs(port) |> Map.put("presence_enabled", true) + conn = put(conn, ~p"/api/tenants/#{external_id}", tenant: attrs) + assert %{"presence_enabled" => true} = json_response(conn, 200)["data"] + + updated_tenant = Realtime.Api.get_tenant_by_external_id(external_id, use_replica?: false) + assert updated_tenant.presence_enabled == true + end + test "renders errors when data is invalid", %{conn: conn} do conn = put(conn, ~p"/api/tenants/#{random_string()}", tenant: @invalid_attrs) assert json_response(conn, 422)["errors"] != %{} @@ -191,7 +264,7 @@ defmodule RealtimeWeb.TenantControllerTest do test "sets appropriate observability metadata", %{conn: conn, tenant: tenant} do external_id = tenant.external_id - port = Database.from_tenant(tenant, "realtime_test", :stop).port + port = tenant_db_port(tenant) attrs = default_tenant_attrs(port) # opentelemetry_phoenix expects to be a child of the originating cowboy process hence the Task here :shrug: @@ -218,6 +291,7 @@ defmodule RealtimeWeb.TenantControllerTest do {:ok, _pid} = Connect.lookup_or_start_connection(tenant.external_id) assert Connect.ready?(tenant.external_id) + assert Realtime.Tenants.ReplicationConnection.ready?(tenant.external_id) assert Cache.get_tenant_by_external_id(tenant.external_id) {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) @@ -291,7 +365,11 @@ defmodule RealtimeWeb.TenantControllerTest do assert status == 204 - assert_receive :disconnect + assert_receive %Phoenix.Socket.Broadcast{ + payload: %{message: "Server requested disconnect", status: "ok", extension: "system"}, + event: "system" + } + assert_receive {:DOWN, _, :process, ^manager_pid, _} assert_receive {:DOWN, _, :process, ^connect_pid, _} @@ -330,12 +408,66 @@ defmodule RealtimeWeb.TenantControllerTest do end end + describe "shutdown Connect module for tenant" do + setup [:with_tenant] + + test "shuts down Connect process when tenant exists", %{conn: conn, tenant: %{external_id: external_id}} do + Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> external_id) + + {:ok, connect_pid} = Connect.lookup_or_start_connection(external_id) + Process.monitor(connect_pid) + + assert Process.alive?(connect_pid) + + %{status: status} = post(conn, ~p"/api/tenants/#{external_id}/shutdown") + + assert status == 204 + assert_receive {:DOWN, _, :process, ^connect_pid, _} + refute Process.alive?(connect_pid) + end + + test "returns 204 when tenant exists but Connect is not running", %{conn: conn, tenant: %{external_id: external_id}} do + %{status: status} = post(conn, ~p"/api/tenants/#{external_id}/shutdown") + assert status == 204 + end + + test "returns 404 when tenant does not exist", %{conn: conn} do + %{status: status} = post(conn, ~p"/api/tenants/nope/shutdown") + assert status == 404 + end + + test "returns 403 when jwt is invalid", %{conn: conn, tenant: tenant} do + conn = put_req_header(conn, "authorization", "Bearer potato") + conn = post(conn, ~p"/api/tenants/#{tenant.external_id}/shutdown") + assert response(conn, 403) == "" + end + + test "sets appropriate observability metadata", %{conn: conn, tenant: tenant} do + external_id = tenant.external_id + + Tracer.with_span "test" do + Task.async(fn -> + post(conn, ~p"/api/tenants/#{tenant.external_id}/shutdown") + + assert Logger.metadata()[:external_id] == external_id + assert Logger.metadata()[:project] == external_id + end) + |> Task.await() + end + + assert_receive {:span, span(name: "POST /api/tenants/:tenant_id/shutdown", attributes: attributes)} + + assert attributes(map: %{external_id: ^external_id}) = attributes + end + end + describe "health check tenant" do setup [:with_tenant] setup do + previous_region = Application.get_env(:realtime, :region) Application.put_env(:realtime, :region, "us-east-1") - on_exit(fn -> Application.put_env(:realtime, :region, nil) end) + on_exit(fn -> Application.put_env(:realtime, :region, previous_region) end) end test "health check when tenant does not exist", %{conn: conn} do @@ -354,13 +486,14 @@ defmodule RealtimeWeb.TenantControllerTest do assert %{ "healthy" => true, "db_connected" => false, + "replication_connected" => false, "connected_cluster" => 0, "region" => "us-east-1", "node" => "#{node()}" } == data end - test "unhealthy tenant with 1 client connections", %{ + test "unhealthy tenant with 1 client connections and no db connection", %{ conn: conn, tenant: %Tenant{external_id: ext_id} } do @@ -374,19 +507,23 @@ defmodule RealtimeWeb.TenantControllerTest do assert %{ "healthy" => false, "db_connected" => false, + "replication_connected" => false, "connected_cluster" => 1, "region" => "us-east-1", "node" => "#{node()}" } == data end - test "healthy tenant with 1 client connection", %{conn: conn, tenant: %Tenant{external_id: ext_id}} do + test "healthy tenant with db connection but no replication connection", %{ + conn: conn, + tenant: %Tenant{external_id: ext_id} + } do {:ok, db_conn} = Connect.lookup_or_start_connection(ext_id) # Fake adding a connected client here UsersCounter.add(self(), ext_id) - # Fake a db connection - :syn.register(Realtime.Tenants.Connect, ext_id, self(), %{conn: nil}) + # Fake a db connection without replication (replication_conn: nil) + :syn.register(Realtime.Tenants.Connect, ext_id, self(), %{conn: nil, region: "us-east-1", replication_conn: nil}) :syn.update_registry(Realtime.Tenants.Connect, ext_id, fn _pid, meta -> %{meta | conn: db_conn} @@ -398,6 +535,32 @@ defmodule RealtimeWeb.TenantControllerTest do assert %{ "healthy" => true, "db_connected" => true, + "replication_connected" => false, + "connected_cluster" => 1, + "region" => "us-east-1", + "node" => "#{node()}" + } == data + end + + test "healthy tenant with db and replication connection", %{conn: conn, tenant: %Tenant{external_id: ext_id}} do + {:ok, db_conn} = Connect.lookup_or_start_connection(ext_id) + # Fake adding a connected client here + UsersCounter.add(self(), ext_id) + + # Fake a db connection with replication_conn in syn metadata + :syn.register(Realtime.Tenants.Connect, ext_id, self(), %{conn: nil, region: "us-east-1", replication_conn: nil}) + + :syn.update_registry(Realtime.Tenants.Connect, ext_id, fn _pid, meta -> + %{meta | conn: db_conn, replication_conn: self()} + end) + + conn = get(conn, ~p"/api/tenants/#{ext_id}/health") + data = json_response(conn, 200)["data"] + + assert %{ + "healthy" => true, + "db_connected" => true, + "replication_connected" => true, "connected_cluster" => 1, "region" => "us-east-1", "node" => "#{node()}" @@ -410,19 +573,27 @@ defmodule RealtimeWeb.TenantControllerTest do assert response(conn, 403) == "" end - test "runs migrations", %{conn: conn} do + test "triggers migrations without blocking and self heals eventually", %{conn: conn} do tenant = Containers.checkout_tenant(run_migrations: false) {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) assert {:error, _} = Postgrex.query(db_conn, "SELECT * FROM realtime.messages", []) conn = get(conn, ~p"/api/tenants/#{tenant.external_id}/health") - data = json_response(conn, 200)["data"] - Process.sleep(2000) + + assert %{"healthy" => false, "db_connected" => false, "replication_connected" => false, "connected_cluster" => 0} = + json_response(conn, 200)["data"] + + assert eventually(fn -> + match?({:ok, %{healthy: true}}, Realtime.Tenants.health_check(tenant.external_id)) + end) assert {:ok, %{rows: []}} = Postgrex.query(db_conn, "SELECT * FROM realtime.messages", []) - assert %{"healthy" => true, "db_connected" => false, "connected_cluster" => 0} = data + conn = get(conn, ~p"/api/tenants/#{tenant.external_id}/health") + + assert %{"healthy" => true, "db_connected" => false, "replication_connected" => false, "connected_cluster" => 0} = + json_response(conn, 200)["data"] end test "sets appropriate observability metadata", %{conn: conn, tenant: tenant} do @@ -442,6 +613,51 @@ defmodule RealtimeWeb.TenantControllerTest do assert attributes(map: %{external_id: ^external_id}) = attributes end + + test "logs request when DISABLE_HEALTHCHECK_LOGGING is false", %{conn: conn, tenant: tenant} do + original_value = Application.get_env(:realtime, :disable_healthcheck_logging, false) + Application.put_env(:realtime, :disable_healthcheck_logging, false) + on_exit(fn -> Application.put_env(:realtime, :disable_healthcheck_logging, original_value) end) + + log = + capture_log(fn -> + conn = get(conn, ~p"/api/tenants/#{tenant.external_id}/health") + assert json_response(conn, 200) + end) + + assert log =~ "GET /api/tenants" + assert log =~ "/health" + end + + test "does not log request when DISABLE_HEALTHCHECK_LOGGING is true", %{conn: conn, tenant: tenant} do + original_value = Application.get_env(:realtime, :disable_healthcheck_logging, false) + Application.put_env(:realtime, :disable_healthcheck_logging, true) + on_exit(fn -> Application.put_env(:realtime, :disable_healthcheck_logging, original_value) end) + + log = + capture_log(fn -> + conn = get(conn, ~p"/api/tenants/#{tenant.external_id}/health") + assert json_response(conn, 200) + end) + + refute log =~ "GET /api/tenants" + refute log =~ "/health" + end + + test "logs request when DISABLE_HEALTHCHECK_LOGGING is not set (default)", %{conn: conn, tenant: tenant} do + original_value = Application.get_env(:realtime, :disable_healthcheck_logging, false) + Application.delete_env(:realtime, :disable_healthcheck_logging) + on_exit(fn -> Application.put_env(:realtime, :disable_healthcheck_logging, original_value) end) + + log = + capture_log(fn -> + conn = get(conn, ~p"/api/tenants/#{tenant.external_id}/health") + assert json_response(conn, 200) + end) + + assert log =~ "GET /api/tenants" + assert log =~ "/health" + end end defp default_tenant_attrs(port) do @@ -452,7 +668,7 @@ defmodule RealtimeWeb.TenantControllerTest do "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => "supabase_realtime_admin", "db_password" => "postgres", "db_port" => "#{port}", "poll_interval" => 100, @@ -484,4 +700,9 @@ defmodule RealtimeWeb.TenantControllerTest do wait_on_postgres_cdc_rls(external_id, attempt - 1) end end + + defp tenant_db_port(tenant) do + {:ok, settings} = Database.from_tenant(tenant, "realtime_test", :stop) + settings.port + end end diff --git a/test/realtime_web/dashboard/tenant_info_test.exs b/test/realtime_web/dashboard/tenant_info_test.exs new file mode 100644 index 000000000..26ff89efd --- /dev/null +++ b/test/realtime_web/dashboard/tenant_info_test.exs @@ -0,0 +1,87 @@ +defmodule RealtimeWeb.Dashboard.TenantInfoTest do + use RealtimeWeb.ConnCase + import Phoenix.LiveViewTest + + setup do + Application.put_env(:realtime, :dashboard_auth, :basic_auth) + Application.put_env(:realtime, :dashboard_credentials, {"user", "pass"}) + + on_exit(fn -> + Application.delete_env(:realtime, :dashboard_auth) + Application.delete_env(:realtime, :dashboard_credentials) + end) + + tenant = Containers.checkout_tenant(run_migrations: true) + conn = using_basic_auth(build_conn(), "user", "pass") + + %{tenant: tenant, conn: conn} + end + + test "renders lookup form", %{conn: conn} do + {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info") + + assert html =~ "Tenant Info" + assert html =~ "external_id" + end + + test "shows tenant info for valid external_id via URL param", %{conn: conn, tenant: tenant} do + {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info?external_id=#{tenant.external_id}") + + assert html =~ tenant.external_id + assert html =~ tenant.name + assert html =~ "postgres_cdc_rls" + end + + test "shows tenant info for valid external_id via form submit", %{conn: conn, tenant: tenant} do + {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_info") + + html = view |> element("form[phx-submit='lookup']") |> render_submit(%{external_id: tenant.external_id}) + + assert html =~ tenant.external_id + assert html =~ tenant.name + assert html =~ "postgres_cdc_rls" + end + + test "shows error for unknown external_id via URL param", %{conn: conn} do + {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info?external_id=nonexistent") + + assert html =~ "Tenant not found" + end + + test "shows error for unknown external_id via form submit", %{conn: conn} do + {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_info") + + html = view |> element("form[phx-submit='lookup']") |> render_submit(%{external_id: "nonexistent"}) + + assert html =~ "Tenant not found" + end + + test "does not show db_password", %{conn: conn, tenant: tenant} do + {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info?external_id=#{tenant.external_id}") + + refute html =~ "db_password" + end + + test "does not show db_pass_realtime", %{conn: conn, tenant: tenant} do + {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info?external_id=#{tenant.external_id}") + + refute html =~ "db_pass_realtime" + end + + test "shows decrypted db_host", %{conn: conn, tenant: tenant} do + {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info?external_id=#{tenant.external_id}") + + assert html =~ "127.0.0.1" + end + + test "shows resolved db_host", %{conn: conn, tenant: tenant} do + {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info?external_id=#{tenant.external_id}") + + assert html =~ "db_host_resolved" + end + + defp using_basic_auth(conn, username, password) do + header_content = "Basic " <> Base.encode64("#{username}:#{password}") + put_req_header(conn, "authorization", header_content) + end +end diff --git a/test/realtime_web/dashboard/tenant_migrations_test.exs b/test/realtime_web/dashboard/tenant_migrations_test.exs new file mode 100644 index 000000000..4fc61b6c5 --- /dev/null +++ b/test/realtime_web/dashboard/tenant_migrations_test.exs @@ -0,0 +1,183 @@ +defmodule RealtimeWeb.Dashboard.TenantMigrationsTest do + use RealtimeWeb.ConnCase, async: false + import Phoenix.LiveViewTest + + alias Realtime.Api + alias Realtime.Database + alias Realtime.Tenants.Migrations + alias RealtimeWeb.Dashboard.TenantMigrations + + setup do + Application.put_env(:realtime, :dashboard_auth, :basic_auth) + Application.put_env(:realtime, :dashboard_credentials, {"user", "pass"}) + + on_exit(fn -> + Application.delete_env(:realtime, :dashboard_auth) + Application.delete_env(:realtime, :dashboard_credentials) + end) + + tenant = Containers.checkout_tenant(run_migrations: true) + conn = using_basic_auth(build_conn(), "user", "pass") + + %{tenant: tenant, conn: conn} + end + + test "renders lookup form", %{conn: conn} do + {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_migrations") + + assert has_element?(view, "h5.card-title", "Tenant Migrations") + assert has_element?(view, "input[name=external_id]") + assert has_element?(view, "button[type=submit]", "Lookup") + end + + test "shows schema_migrations for valid external_id via URL param", %{conn: conn, tenant: tenant} do + {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_migrations?external_id=#{tenant.external_id}") + + assert has_element?(view, "h6", "realtime.schema_migrations") + assert has_element?(view, "th", "version") + assert has_element?(view, "th", "inserted_at") + end + + test "shows schema_migrations for valid external_id via form submit", %{conn: conn, tenant: tenant} do + {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_migrations") + + view + |> element("form[phx-submit=lookup]") + |> render_submit(%{external_id: tenant.external_id}) + + assert has_element?(view, "h6", "realtime.schema_migrations") + assert has_element?(view, "th", "version") + end + + test "shows error for unknown external_id via URL param", %{conn: conn} do + {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_migrations?external_id=nonexistent") + + assert has_element?(view, "p.text-danger", "Tenant not found") + end + + test "shows error for unknown external_id via form submit", %{conn: conn} do + {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_migrations") + + view + |> element("form[phx-submit=lookup]") + |> render_submit(%{external_id: "nonexistent"}) + + assert has_element?(view, "p.text-danger", "Tenant not found") + end + + test "renders pg-delta section header when tenant is found", %{conn: conn, tenant: tenant} do + {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_migrations?external_id=#{tenant.external_id}") + + assert has_element?(view, "h6", "pg-delta plan vs catalog") + end + + describe "backfill_schema_migrations/1" do + test "inserts missing versions and updates tenants.migrations_ran", %{tenant: tenant} do + {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) + + Postgrex.query!( + db_conn, + "DELETE FROM realtime.schema_migrations WHERE version > 20211116213934", + [] + ) + + {:ok, _} = Api.update_migrations_ran(tenant.external_id, 7) + + assert :ok = TenantMigrations.backfill_schema_migrations(tenant) + + %{rows: [[count]]} = + Postgrex.query!(db_conn, "SELECT count(*)::int FROM realtime.schema_migrations", []) + + total = length(Migrations.migrations()) + assert count == total + + updated = Api.get_tenant_by_external_id(tenant.external_id, use_replica?: false) + assert updated.migrations_ran == total + end + + test "running twice keeps the row count and migrations_ran stable", %{tenant: tenant} do + {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) + total = length(Migrations.migrations()) + + assert :ok = TenantMigrations.backfill_schema_migrations(tenant) + assert :ok = TenantMigrations.backfill_schema_migrations(tenant) + + %{rows: [[count]]} = + Postgrex.query!(db_conn, "SELECT count(*)::int FROM realtime.schema_migrations", []) + + assert count == total + + updated = Api.get_tenant_by_external_id(tenant.external_id, use_replica?: false) + assert updated.migrations_ran == total + end + end + + describe "apply_pg_delta/2" do + test "runs the sql plan and backfills schema_migrations", %{tenant: tenant} do + {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) + + Postgrex.query!( + db_conn, + "DELETE FROM realtime.schema_migrations WHERE version > 20211116213934", + [] + ) + + {:ok, _} = Api.update_migrations_ran(tenant.external_id, 7) + + assert :ok = TenantMigrations.apply_pg_delta(tenant, "SELECT 1") + + %{rows: [[count]]} = + Postgrex.query!(db_conn, "SELECT count(*)::int FROM realtime.schema_migrations", []) + + total = length(Migrations.migrations()) + assert count == total + + updated = Api.get_tenant_by_external_id(tenant.external_id, use_replica?: false) + assert updated.migrations_ran == total + end + end + + describe "postgres_url/1" do + test "builds a valid URL for IPv4 hosts" do + assert TenantMigrations.postgres_url(%Database{ + hostname: "db.example.com", + port: 5432, + database: "postgres", + username: "supabase_admin", + password: "s3cr3t", + socket_options: [:inet], + ssl: true + }) == "postgresql://supabase_admin:s3cr3t@db.example.com:5432/postgres?sslmode=require" + end + + test "builds a valid URL for IPv6 hosts" do + assert TenantMigrations.postgres_url(%Database{ + hostname: "2600:1f14:359d:9302:205d:38ca:a017:c7e3", + port: 5432, + database: "postgres", + username: "supabase_admin", + password: "s3cr3t", + socket_options: [:inet6], + ssl: true + }) == + "postgresql://supabase_admin:s3cr3t@[2600:1f14:359d:9302:205d:38ca:a017:c7e3]:5432/postgres?sslmode=require" + end + + test "builds a valid URL for DNS hostnames resolved over IPv6" do + assert TenantMigrations.postgres_url(%Database{ + hostname: "db.example.com", + port: 5432, + database: "postgres", + username: "supabase_admin", + password: "s3cr3t", + socket_options: [:inet6], + ssl: true + }) == "postgresql://supabase_admin:s3cr3t@db.example.com:5432/postgres?sslmode=require" + end + end + + defp using_basic_auth(conn, username, password) do + header_content = "Basic " <> Base.encode64("#{username}:#{password}") + put_req_header(conn, "authorization", header_content) + end +end diff --git a/test/realtime_web/live/feature_flags_live/index_test.exs b/test/realtime_web/live/feature_flags_live/index_test.exs new file mode 100644 index 000000000..41c89ec61 --- /dev/null +++ b/test/realtime_web/live/feature_flags_live/index_test.exs @@ -0,0 +1,160 @@ +defmodule RealtimeWeb.FeatureFlagsLive.IndexTest do + use RealtimeWeb.ConnCase, async: false + import Phoenix.LiveViewTest + + alias Realtime.Api + alias Realtime.Api.FeatureFlag + alias Realtime.FeatureFlags.Cache + alias RealtimeWeb.Endpoint + + setup do + user = random_string() + password = random_string() + + Application.put_env(:realtime, :dashboard_auth, :basic_auth) + Application.put_env(:realtime, :dashboard_credentials, {user, password}) + + on_exit(fn -> + Application.delete_env(:realtime, :dashboard_auth) + Application.delete_env(:realtime, :dashboard_credentials) + Cachex.clear(Cache) + end) + + Cachex.clear(Cache) + + %{user: user, password: password} + end + + describe "auth" do + test "renders feature flags page with valid credentials", %{conn: conn, user: user, password: password} do + {:ok, _view, html} = + conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags") + + assert html =~ "Feature Flags" + end + + test "returns 401 without credentials", %{conn: conn} do + assert conn |> get(~p"/admin/feature-flags") |> response(401) + end + + test "returns 401 with wrong credentials", %{conn: conn} do + assert conn |> using_basic_auth("wrong", "wrong") |> get(~p"/admin/feature-flags") |> response(401) + end + end + + describe "mount" do + test "lists existing flags ordered by name", %{conn: conn, user: user, password: password} do + {:ok, _alpha} = Api.upsert_feature_flag(%{name: "alpha_flag", enabled: true}) + {:ok, _zeta} = Api.upsert_feature_flag(%{name: "zeta_flag", enabled: false}) + + {:ok, _view, html} = + conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags") + + assert html =~ "alpha_flag" + assert html =~ "zeta_flag" + end + end + + describe "create event" do + test "adds a new flag to the list and persists it", %{conn: conn, user: user, password: password} do + flag_name = "created_#{random_string()}" + + {:ok, view, _} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags") + + html = view |> form("form[phx-submit=create]", name: flag_name) |> render_submit() + + assert html =~ flag_name + assert %FeatureFlag{enabled: false} = Api.get_feature_flag(flag_name) + end + + test "trims whitespace from the new flag name", %{conn: conn, user: user, password: password} do + flag_name = "trim_#{random_string()}" + + {:ok, view, _} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags") + + _ = view |> form("form[phx-submit=create]", name: " #{flag_name} ") |> render_submit() + + assert %FeatureFlag{name: ^flag_name} = Api.get_feature_flag(flag_name) + end + + test "does not create a flag when name is empty", %{conn: conn, user: user, password: password} do + {:ok, view, _} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags") + + flags_before = Api.list_feature_flags() |> length() + + _ = view |> form("form[phx-submit=create]", name: "") |> render_submit() + + assert Api.list_feature_flags() |> length() == flags_before + end + end + + describe "toggle event" do + test "flips the enabled state and persists", %{conn: conn, user: user, password: password} do + flag_name = "toggle_#{random_string()}" + {:ok, flag} = Api.upsert_feature_flag(%{name: flag_name, enabled: false}) + + {:ok, view, _} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags") + + view |> element("button[phx-click=toggle][phx-value-id='#{flag.id}']") |> render_click() + + assert %FeatureFlag{enabled: true} = Api.get_feature_flag(flag_name) + end + end + + describe "delete event" do + test "removes the flag from the list and DB", %{conn: conn, user: user, password: password} do + flag_name = "delete_#{random_string()}" + {:ok, flag} = Api.upsert_feature_flag(%{name: flag_name, enabled: false}) + + {:ok, view, _} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags") + + html = view |> element("button[phx-click=delete][phx-value-id='#{flag.id}']") |> render_click() + + refute html =~ flag_name + refute Api.get_feature_flag(flag_name) + end + end + + describe "broadcasts" do + test "adds a new flag when an 'updated' broadcast arrives for an unseen flag", + %{conn: conn, user: user, password: password} do + {:ok, view, _} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags") + + remote = %FeatureFlag{id: Ecto.UUID.generate(), name: "remote_#{random_string()}", enabled: true} + Endpoint.broadcast("feature_flags", "updated", remote) + + assert render(view) =~ remote.name + end + + test "updates an existing flag when an 'updated' broadcast arrives", + %{conn: conn, user: user, password: password} do + flag_name = "broadcast_#{random_string()}" + {:ok, flag} = Api.upsert_feature_flag(%{name: flag_name, enabled: false}) + + {:ok, view, html} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags") + assert html =~ "Disabled" + + Endpoint.broadcast("feature_flags", "updated", %{flag | enabled: true}) + + assert render(view) =~ "Enabled" + end + + test "removes a flag when a 'deleted' broadcast arrives", + %{conn: conn, user: user, password: password} do + flag_name = "broadcast_delete_#{random_string()}" + {:ok, _flag} = Api.upsert_feature_flag(%{name: flag_name, enabled: false}) + + {:ok, view, html} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags") + assert html =~ flag_name + + Endpoint.broadcast("feature_flags", "deleted", %{name: flag_name}) + + refute render(view) =~ flag_name + end + end + + defp using_basic_auth(conn, username, password) do + header_content = "Basic " <> Base.encode64("#{username}:#{password}") + put_req_header(conn, "authorization", header_content) + end +end diff --git a/test/realtime_web/live/status_live/index_test.exs b/test/realtime_web/live/status_live/index_test.exs new file mode 100644 index 000000000..ae3af0ad0 --- /dev/null +++ b/test/realtime_web/live/status_live/index_test.exs @@ -0,0 +1,33 @@ +defmodule RealtimeWeb.StatusLive.IndexTest do + use RealtimeWeb.ConnCase + import Phoenix.LiveViewTest + + alias Realtime.Latency.Payload + alias Realtime.Nodes + alias RealtimeWeb.Endpoint + + describe "Status LiveView" do + test "renders status page", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/status") + + assert html =~ "Realtime Status" + end + + test "receives broadcast from PubSub", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/status") + + payload = %Payload{ + from_node: Nodes.short_node_id_from_name(:"pink@127.0.0.1"), + node: Nodes.short_node_id_from_name(:"orange@127.0.0.1"), + latency: "42ms", + timestamp: DateTime.utc_now() + } + + Endpoint.broadcast("admin:cluster", "ping", payload) + + html = render(view) + assert html =~ "42ms" + assert html =~ "pink@127.0.0.1_orange@127.0.0.1" + end + end +end diff --git a/test/realtime_web/live/tenants_live/index_test.exs b/test/realtime_web/live/tenants_live/index_test.exs index 71faa9cee..844c66be9 100644 --- a/test/realtime_web/live/tenants_live/index_test.exs +++ b/test/realtime_web/live/tenants_live/index_test.exs @@ -1,18 +1,20 @@ defmodule RealtimeWeb.TenantsLive.IndexTest do use RealtimeWeb.ConnCase import Phoenix.LiveViewTest + import Generators + import Mimic - describe "TenantsLive Index" do + describe "TenantsLive Index with basic_auth" do setup do user = random_string() password = random_string() - System.put_env("DASHBOARD_USER", user) - System.put_env("DASHBOARD_PASSWORD", password) + Application.put_env(:realtime, :dashboard_auth, :basic_auth) + Application.put_env(:realtime, :dashboard_credentials, {user, password}) on_exit(fn -> - System.delete_env("DASHBOARD_USER") - System.delete_env("DASHBOARD_PASSWORD") + Application.delete_env(:realtime, :dashboard_auth) + Application.delete_env(:realtime, :dashboard_credentials) end) %{user: user, password: password} @@ -28,6 +30,38 @@ defmodule RealtimeWeb.TenantsLive.IndexTest do test "returns 401 if no credentials", %{conn: conn} do assert conn |> get(~p"/admin/tenants") |> response(401) end + + test "returns 401 with wrong credentials", %{conn: conn} do + assert conn |> using_basic_auth("wrong", "wrong") |> get(~p"/admin/tenants") |> response(401) + end + end + + describe "TenantsLive Index with zta" do + setup do + Application.put_env(:realtime, :dashboard_auth, :zta) + + on_exit(fn -> Application.delete_env(:realtime, :dashboard_auth) end) + end + + test "renders tenant view with valid cf token", %{conn: conn} do + stub(NimbleZTA.Cloudflare, :authenticate, fn _name, conn -> {conn, %{email: "user@example.com"}} end) + + {:ok, _view, html} = live(conn, ~p"/admin/tenants") + + assert html =~ "Listing all Supabase Realtime tenants." + end + + test "returns 403 without cf token", %{conn: conn} do + stub(NimbleZTA.Cloudflare, :authenticate, fn _name, conn -> {conn, nil} end) + + assert conn |> get(~p"/admin/tenants") |> response(403) + end + + test "returns 503 when zta service is unavailable", %{conn: conn} do + stub(NimbleZTA.Cloudflare, :authenticate, fn _name, _conn -> exit(:noproc) end) + + assert conn |> get(~p"/admin/tenants") |> response(503) + end end defp using_basic_auth(conn, username, password) do diff --git a/test/realtime_web/plugs/assign_tenant_test.exs b/test/realtime_web/plugs/assign_tenant_test.exs index 536d7a548..102c8c6a1 100644 --- a/test/realtime_web/plugs/assign_tenant_test.exs +++ b/test/realtime_web/plugs/assign_tenant_test.exs @@ -15,7 +15,7 @@ defmodule RealtimeWeb.Plugs.AssignTenantTest do "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => "supabase_realtime_admin", "db_password" => "postgres", "db_port" => "6432", "poll_interval" => 100, diff --git a/test/realtime_web/plugs/auth_tenant_test.exs b/test/realtime_web/plugs/auth_tenant_test.exs index ea75d5eb1..e993a99f9 100644 --- a/test/realtime_web/plugs/auth_tenant_test.exs +++ b/test/realtime_web/plugs/auth_tenant_test.exs @@ -5,7 +5,6 @@ defmodule RealtimeWeb.AuthTenantTest do alias RealtimeWeb.AuthTenant - @token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1MTYyMzkwMjIsInJvbGUiOiJmb28iLCJleHAiOiJiYXIifQ.Ret2CevUozCsPhpgW2FMeFL7RooLgoOvfQzNpLBj5ak" describe "without tenant" do test "returns 401", %{conn: conn} do conn = AuthTenant.call(conn, %{}) @@ -16,13 +15,23 @@ defmodule RealtimeWeb.AuthTenantTest do describe "with tenant" do setup %{conn: conn} = context do - api_key = Map.get(context, :api_key) + tenant = tenant_fixture() + now = System.system_time(:second) + token = generate_jwt_token(tenant, %{role: "test", iat: now, exp: now + 100_000}) + header = Map.get(context, :header) - conn = if api_key, do: put_req_header(conn, header, api_key), else: conn + api_key = + cond do + literal = Map.get(context, :api_key) -> literal + header -> Map.get(context, :prefix, "Bearer ") <> token + true -> nil + end + + conn = if header && api_key, do: put_req_header(conn, header, api_key), else: conn - conn = assign(conn, :tenant, tenant_fixture()) - %{conn: conn} + conn = assign(conn, :tenant, tenant) + %{conn: conn, token: token} end test "returns 401 if token isn't present in header", %{conn: conn} do @@ -38,7 +47,7 @@ defmodule RealtimeWeb.AuthTenantTest do assert conn.halted end - @tag api_key: "Bearer #{@token}", header: "authorization" + @tag header: "authorization" test "returns non halted and null status if token in authorization header is valid", %{ conn: conn } do @@ -47,7 +56,7 @@ defmodule RealtimeWeb.AuthTenantTest do refute conn.halted end - @tag api_key: "bearer #{@token}", header: "authorization" + @tag header: "authorization", prefix: "bearer " test "returns non halted and null status if token in authorization header is valid and case insensitive", %{ conn: conn @@ -57,7 +66,7 @@ defmodule RealtimeWeb.AuthTenantTest do refute conn.halted end - @tag api_key: "earer #{@token}", header: "authorization" + @tag api_key: "earer invalid", header: "authorization" test "returns halted and unauthorized if token is badly formatted", %{ conn: conn } do @@ -73,7 +82,7 @@ defmodule RealtimeWeb.AuthTenantTest do assert conn.halted end - @tag api_key: @token, header: "apikey" + @tag header: "apikey", prefix: "" test "returns non halted and null status if token in apikey header is valid", %{ conn: conn } do @@ -82,14 +91,13 @@ defmodule RealtimeWeb.AuthTenantTest do refute conn.halted end - @tag api_key: "Bearer #{@token}", header: "authorization" - test "assigns jwt information on success", %{ - conn: conn - } do + @tag header: "authorization" + test "assigns jwt information on success", %{conn: conn, token: token} do conn = AuthTenant.call(conn, %{}) - assert conn.assigns.jwt == @token - assert conn.assigns.claims == %{"exp" => "bar", "iat" => 1_516_239_022, "role" => "foo"} - assert conn.assigns.role == "foo" + assert conn.assigns.jwt == token + assert conn.assigns.role == "test" + assert %{"exp" => exp, "iat" => iat, "role" => "test"} = conn.assigns.claims + assert is_integer(exp) and is_integer(iat) end end end diff --git a/test/realtime_web/plugs/parsers/octet_stream_test.exs b/test/realtime_web/plugs/parsers/octet_stream_test.exs new file mode 100644 index 000000000..da8957366 --- /dev/null +++ b/test/realtime_web/plugs/parsers/octet_stream_test.exs @@ -0,0 +1,125 @@ +defmodule RealtimeWeb.Plugs.Parsers.OctetStreamTest do + use ExUnit.Case, async: true + import Plug.Test + import Plug.Conn + + alias RealtimeWeb.Plugs.Parsers.OctetStream + + defmodule TimeoutReader do + def read_body(_conn, _opts), do: {:error, :timeout} + end + + defmodule ErrorReader do + def read_body(_conn, _opts), do: {:error, :closed} + end + + describe "init/1" do + test "defaults the body reader to Plug.Conn.read_body/2" do + assert {{Plug.Conn, :read_body, []}, opts} = OctetStream.init([]) + assert opts == [] + end + + test "passes other opts through untouched" do + assert {{Plug.Conn, :read_body, []}, opts} = OctetStream.init(length: 42) + assert Keyword.get(opts, :length) == 42 + end + + test "pops :body_reader out of opts" do + reader = {TimeoutReader, :read_body, []} + assert {^reader, opts} = OctetStream.init(body_reader: reader, length: 10) + refute Keyword.has_key?(opts, :body_reader) + assert Keyword.get(opts, :length) == 10 + end + end + + describe "parse/5" do + test "returns {:next, conn} for non-octet-stream content types" do + conn = conn(:post, "/", "anything") + opts = OctetStream.init([]) + + assert {:next, ^conn} = OctetStream.parse(conn, "application", "json", %{}, opts) + assert {:next, ^conn} = OctetStream.parse(conn, "text", "plain", %{}, opts) + assert {:next, ^conn} = OctetStream.parse(conn, "application", "x-www-form-urlencoded", %{}, opts) + end + + test "parses application/octet-stream body into %{\"_binary\" => body}" do + body = <<1, 2, 3, 4, 5>> + + conn = + conn(:post, "/", body) + |> put_req_header("content-type", "application/octet-stream") + + opts = OctetStream.init([]) + + assert {:ok, %{"_binary" => ^body}, %Plug.Conn{}} = + OctetStream.parse(conn, "application", "octet-stream", %{}, opts) + end + + test "handles empty binary body" do + conn = + conn(:post, "/", <<>>) + |> put_req_header("content-type", "application/octet-stream") + + opts = OctetStream.init([]) + + assert {:ok, %{"_binary" => <<>>}, %Plug.Conn{}} = + OctetStream.parse(conn, "application", "octet-stream", %{}, opts) + end + + test "returns {:error, :too_large, conn} when body exceeds :length" do + body = :crypto.strong_rand_bytes(2_000) + + conn = + conn(:post, "/", body) + |> put_req_header("content-type", "application/octet-stream") + + opts = OctetStream.init(length: 100, read_length: 100) + + assert {:error, :too_large, %Plug.Conn{}} = + OctetStream.parse(conn, "application", "octet-stream", %{}, opts) + end + + test "raises Plug.TimeoutError when body reader returns {:error, :timeout}" do + conn = + conn(:post, "/", <<1, 2, 3>>) + |> put_req_header("content-type", "application/octet-stream") + + opts = OctetStream.init(body_reader: {TimeoutReader, :read_body, []}) + + assert_raise Plug.TimeoutError, fn -> + OctetStream.parse(conn, "application", "octet-stream", %{}, opts) + end + end + + test "raises Plug.BadRequestError for other read_body errors" do + conn = + conn(:post, "/", <<1, 2, 3>>) + |> put_req_header("content-type", "application/octet-stream") + + opts = OctetStream.init(body_reader: {ErrorReader, :read_body, []}) + + assert_raise Plug.BadRequestError, fn -> + OctetStream.parse(conn, "application", "octet-stream", %{}, opts) + end + end + + test "ignores content-type parameters" do + body = <<10, 20, 30>> + + conn = + conn(:post, "/", body) + |> put_req_header("content-type", "application/octet-stream; charset=binary") + + opts = OctetStream.init([]) + + assert {:ok, %{"_binary" => ^body}, %Plug.Conn{}} = + OctetStream.parse( + conn, + "application", + "octet-stream", + %{"charset" => "binary"}, + opts + ) + end + end +end diff --git a/test/realtime_web/plugs/rate_limiter_test.exs b/test/realtime_web/plugs/rate_limiter_test.exs index 78b22fc8f..7fd270a66 100644 --- a/test/realtime_web/plugs/rate_limiter_test.exs +++ b/test/realtime_web/plugs/rate_limiter_test.exs @@ -13,7 +13,7 @@ defmodule RealtimeWeb.Plugs.RateLimiterTest do "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => "supabase_realtime_admin", "db_password" => "postgres", "db_port" => "6432", "poll_interval" => 100, @@ -47,9 +47,7 @@ defmodule RealtimeWeb.Plugs.RateLimiterTest do end test "serve a 200 when rate limit is set to 100", %{conn: conn} do - {:ok, _tenant} = - Api.get_tenant_by_external_id(@tenant["external_id"]) - |> Api.update_tenant(%{"max_events_per_second" => 100}) + {:ok, _tenant} = Api.update_tenant_by_external_id(@tenant["external_id"], %{"max_events_per_second" => 100}) conn = conn @@ -58,4 +56,23 @@ defmodule RealtimeWeb.Plugs.RateLimiterTest do assert conn.status == 200 end + + test "passes through when tenant is not in assigns", %{conn: conn} do + alias RealtimeWeb.Plugs.RateLimiter + + result = RateLimiter.call(conn, []) + + refute result.halted + end + + test "sets rate limit headers on 429 response", %{conn: conn} do + conn = + conn + |> Map.put(:host, "localhost.localhost.com") + |> get(Routes.ping_path(conn, :ping)) + + assert conn.status == 429 + assert get_resp_header(conn, "x-rate-limit") == ["0"] + assert get_resp_header(conn, "x-rate-rolling") != [] + end end diff --git a/test/realtime_web/plugs/validate_broadcast_content_type_test.exs b/test/realtime_web/plugs/validate_broadcast_content_type_test.exs new file mode 100644 index 000000000..ee4e8ceca --- /dev/null +++ b/test/realtime_web/plugs/validate_broadcast_content_type_test.exs @@ -0,0 +1,97 @@ +defmodule RealtimeWeb.Plugs.ValidateBroadcastContentTypeTest do + use ExUnit.Case, async: true + import Plug.Test + import Plug.Conn + + alias RealtimeWeb.Plugs.ValidateBroadcastContentType + + defp call(conn) do + ValidateBroadcastContentType.call(conn, ValidateBroadcastContentType.init([])) + end + + describe "allowed content types" do + test "passes application/json through unchanged" do + conn = + conn(:post, "/", "{}") + |> put_req_header("content-type", "application/json") + |> call() + + refute conn.halted + assert is_nil(conn.status) + end + + test "passes application/json with charset through" do + conn = + conn(:post, "/", "{}") + |> put_req_header("content-type", "application/json; charset=utf-8") + |> call() + + refute conn.halted + assert is_nil(conn.status) + end + + test "passes application/octet-stream through" do + conn = + conn(:post, "/", <<1, 2, 3>>) + |> put_req_header("content-type", "application/octet-stream") + |> call() + + refute conn.halted + assert is_nil(conn.status) + end + + test "passes through when content-type header is missing" do + conn = + conn(:post, "/", "") + |> call() + + refute conn.halted + assert is_nil(conn.status) + end + end + + describe "rejected content types" do + test "returns 415 for text/plain" do + conn = + conn(:post, "/", "plain text") + |> put_req_header("content-type", "text/plain") + |> call() + + assert conn.halted + assert conn.status == 415 + assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"] + assert Jason.decode!(conn.resp_body)["error"] =~ "Unsupported Media Type" + + assert Jason.decode!(conn.resp_body)["error"] == + "Unsupported Media Type. Use application/json or application/octet-stream" + end + + test "returns 415 for application/xml" do + conn = + conn(:post, "/", "") + |> put_req_header("content-type", "application/xml") + |> call() + + assert conn.halted + assert conn.status == 415 + assert Jason.decode!(conn.resp_body)["error"] =~ "Unsupported Media Type" + end + + test "returns 415 for multipart/form-data" do + conn = + conn(:post, "/", "") + |> put_req_header("content-type", "multipart/form-data; boundary=abc") + |> call() + + assert conn.halted + assert conn.status == 415 + end + end + + describe "init/1" do + test "returns its input unchanged" do + assert ValidateBroadcastContentType.init([]) == [] + assert ValidateBroadcastContentType.init(foo: :bar) == [foo: :bar] + end + end +end diff --git a/test/realtime_web/socket/v2_serializer_test.exs b/test/realtime_web/socket/v2_serializer_test.exs new file mode 100644 index 000000000..bd9de3c80 --- /dev/null +++ b/test/realtime_web/socket/v2_serializer_test.exs @@ -0,0 +1,571 @@ +defmodule RealtimeWeb.Socket.V2SerializerTest do + use ExUnit.Case, async: true + + alias Phoenix.Socket.{Broadcast, Message, Reply} + alias RealtimeWeb.Socket.UserBroadcast + alias RealtimeWeb.Socket.V2Serializer + + @serializer V2Serializer + @v2_fastlane_json "[null,null,\"t\",\"e\",{\"m\":1}]" + @v2_msg_json "[null,null,\"t\",\"e\",{\"m\":1}]" + + @client_push << + # push + 0::size(8), + # join_ref_size + 2, + # ref_size + 3, + # topic_size + 5, + # event_size + 5, + "12", + "123", + "topic", + "event", + 101, + 102, + 103 + >> + + @client_binary_user_broadcast_push << + # user broadcast push + 3::size(8), + # join_ref_size + 2, + # ref_size + 3, + # topic_size + 5, + # user_event_size + 10, + # metadata_size + 0, + # binary encoding + 0::size(8), + "12", + "123", + "topic", + "user_event", + 101, + 102, + 103 + >> + + @client_json_user_broadcast_push << + # user broadcast push + 3::size(8), + # join_ref_size + 2, + # ref_size + 3, + # topic_size + 5, + # user_event_size + 10, + # metadata_size + 0, + # json encoding + 1::size(8), + "12", + "123", + "topic", + "user_event", + 123, + 34, + 97, + 34, + 58, + 34, + 98, + 34, + 125 + >> + + @client_binary_user_broadcast_push_with_metadata << + # user broadcast push + 3::size(8), + # join_ref_size + 2, + # ref_size + 3, + # topic_size + 5, + # user_event_size + 10, + # metadata_size + 14, + # binary encoding + 0::size(8), + "12", + "123", + "topic", + "user_event", + ~s<{"store":true}>, + 101, + 102, + 103 + >> + + @reply << + # reply + 1::size(8), + # join_ref_size + 2, + # ref_size + 3, + # topic_size + 5, + # status_size + 2, + "12", + "123", + "topic", + "ok", + 101, + 102, + 103 + >> + + @broadcast << + # broadcast + 2::size(8), + # topic_size + 5, + # event_size + 5, + "topic", + "event", + 101, + 102, + 103 + >> + + @binary_user_broadcast << + # user broadcast + 4::size(8), + # topic_size + 5, + # user_event_size + 10, + # metadata_size + 17, + # binary encoding + 0::size(8), + "topic", + "user_event", + # metadata + 123, + 34, + 114, + 101, + 112, + 108, + 97, + 121, + 101, + 100, + 34, + 58, + 116, + 114, + 117, + 101, + 125, + # payload + 101, + 102, + 103 + >> + + @binary_user_broadcast_no_metadata << + # user broadcast + 4::size(8), + # topic_size + 5, + # user_event_size + 10, + # metadata_size + 0, + # binary encoding + 0::size(8), + "topic", + "user_event", + # metadata + # payload + 101, + 102, + 103 + >> + + @json_user_broadcast << + # user broadcast + 4::size(8), + # topic_size + 5, + # user_event_size + 10, + # metadata_size + 17, + # json encoding + 1::size(8), + "topic", + "user_event", + # metadata + 123, + 34, + 114, + 101, + 112, + 108, + 97, + 121, + 101, + 100, + 34, + 58, + 116, + 114, + 117, + 101, + 125, + # payload + 123, + 34, + 97, + 34, + 58, + 34, + 98, + 34, + 125 + >> + + @json_user_broadcast_no_metadata << + # broadcast + 4::size(8), + # topic_size + 5, + # user_event_size + 10, + # metadata_size + 0, + # json encoding + 1::size(8), + "topic", + "user_event", + # metadata + # payload + 123, + 34, + 97, + 34, + 58, + 34, + 98, + 34, + 125 + >> + + defp encode!(serializer, msg) do + case serializer.encode!(msg) do + {:socket_push, :text, encoded} -> + assert is_list(encoded) + IO.iodata_to_binary(encoded) + + {:socket_push, :binary, encoded} -> + assert is_binary(encoded) + encoded + end + end + + defp decode!(serializer, msg, opts), do: serializer.decode!(msg, opts) + + defp fastlane!(serializer, msg) do + case serializer.fastlane!(msg) do + {:socket_push, :text, encoded} -> + assert is_list(encoded) + IO.iodata_to_binary(encoded) + + {:socket_push, :binary, encoded} -> + assert is_binary(encoded) + encoded + end + end + + test "encode!/1 encodes `Phoenix.Socket.Message` as JSON" do + msg = %Message{topic: "t", event: "e", payload: %{m: 1}} + assert encode!(@serializer, msg) == @v2_msg_json + end + + test "encode!/1 raises when payload is not a map" do + msg = %Message{topic: "t", event: "e", payload: "invalid"} + assert_raise ArgumentError, fn -> encode!(@serializer, msg) end + end + + test "encode!/1 encodes `Phoenix.Socket.Reply` as JSON" do + msg = %Reply{topic: "t", payload: %{m: 1}} + encoded = encode!(@serializer, msg) + + assert Jason.decode!(encoded) == [ + nil, + nil, + "t", + "phx_reply", + %{"response" => %{"m" => 1}, "status" => nil} + ] + end + + test "decode!/2 decodes `Phoenix.Socket.Message` from JSON" do + assert %Message{topic: "t", event: "e", payload: %{"m" => 1}} == + decode!(@serializer, @v2_msg_json, opcode: :text) + end + + test "fastlane!/1 encodes a broadcast into a message as JSON" do + msg = %Broadcast{topic: "t", event: "e", payload: %{m: 1}} + assert fastlane!(@serializer, msg) == @v2_fastlane_json + end + + test "fastlane!/1 raises when payload is not a map" do + msg = %Broadcast{topic: "t", event: "e", payload: "invalid"} + assert_raise ArgumentError, fn -> fastlane!(@serializer, msg) end + end + + describe "binary encode" do + test "general pushed message" do + push = << + # push + 0::size(8), + # join_ref_size + 2, + # topic_size + 5, + # event_size + 5, + "12", + "topic", + "event", + 101, + 102, + 103 + >> + + assert encode!(@serializer, %Phoenix.Socket.Message{ + join_ref: "12", + ref: nil, + topic: "topic", + event: "event", + payload: {:binary, <<101, 102, 103>>} + }) == push + end + + test "encode with oversized headers" do + assert_raise ArgumentError, ~r/unable to convert topic to binary/, fn -> + encode!(@serializer, %Phoenix.Socket.Message{ + join_ref: "12", + ref: nil, + topic: String.duplicate("t", 256), + event: "event", + payload: {:binary, <<101, 102, 103>>} + }) + end + + assert_raise ArgumentError, ~r/unable to convert event to binary/, fn -> + encode!(@serializer, %Phoenix.Socket.Message{ + join_ref: "12", + ref: nil, + topic: "topic", + event: String.duplicate("e", 256), + payload: {:binary, <<101, 102, 103>>} + }) + end + + assert_raise ArgumentError, ~r/unable to convert join_ref to binary/, fn -> + encode!(@serializer, %Phoenix.Socket.Message{ + join_ref: String.duplicate("j", 256), + ref: nil, + topic: "topic", + event: "event", + payload: {:binary, <<101, 102, 103>>} + }) + end + end + + test "reply" do + assert encode!(@serializer, %Phoenix.Socket.Reply{ + join_ref: "12", + ref: "123", + topic: "topic", + status: :ok, + payload: {:binary, <<101, 102, 103>>} + }) == @reply + end + + test "reply with oversized headers" do + assert_raise ArgumentError, ~r/unable to convert ref to binary/, fn -> + encode!(@serializer, %Phoenix.Socket.Reply{ + join_ref: "12", + ref: String.duplicate("r", 256), + topic: "topic", + status: :ok, + payload: {:binary, <<101, 102, 103>>} + }) + end + end + + test "fastlane binary Broadcast" do + assert fastlane!(@serializer, %Broadcast{ + topic: "topic", + event: "event", + payload: {:binary, <<101, 102, 103>>} + }) == @broadcast + end + + test "fastlane binary UserBroadcast" do + assert fastlane!(@serializer, %UserBroadcast{ + topic: "topic", + user_event: "user_event", + metadata: %{"replayed" => true}, + user_payload_encoding: :binary, + user_payload: <<101, 102, 103>> + }) == @binary_user_broadcast + end + + test "fastlane binary UserBroadcast no metadata" do + assert fastlane!(@serializer, %UserBroadcast{ + topic: "topic", + user_event: "user_event", + metadata: nil, + user_payload_encoding: :binary, + user_payload: <<101, 102, 103>> + }) == @binary_user_broadcast_no_metadata + end + + test "fastlane json UserBroadcast" do + assert fastlane!(@serializer, %UserBroadcast{ + topic: "topic", + user_event: "user_event", + metadata: %{"replayed" => true}, + user_payload_encoding: :json, + user_payload: "{\"a\":\"b\"}" + }) == @json_user_broadcast + end + + test "fastlane json UserBroadcast no metadata" do + assert fastlane!(@serializer, %UserBroadcast{ + topic: "topic", + user_event: "user_event", + user_payload_encoding: :json, + user_payload: "{\"a\":\"b\"}" + }) == @json_user_broadcast_no_metadata + end + + test "fastlane with oversized headers" do + assert_raise ArgumentError, ~r/unable to convert topic to binary/, fn -> + fastlane!(@serializer, %Broadcast{ + topic: String.duplicate("t", 256), + event: "event", + payload: {:binary, <<101, 102, 103>>} + }) + end + + assert_raise ArgumentError, ~r/unable to convert event to binary/, fn -> + fastlane!(@serializer, %Broadcast{ + topic: "topic", + event: String.duplicate("e", 256), + payload: {:binary, <<101, 102, 103>>} + }) + end + + assert_raise ArgumentError, ~r/unable to convert topic to binary/, fn -> + fastlane!(@serializer, %UserBroadcast{ + topic: String.duplicate("t", 256), + user_event: "user_event", + user_payload_encoding: :json, + user_payload: "{\"a\":\"b\"}" + }) + end + + assert_raise ArgumentError, ~r/unable to convert user_event to binary/, fn -> + fastlane!(@serializer, %UserBroadcast{ + topic: "topic", + user_event: String.duplicate("e", 256), + user_payload_encoding: :json, + user_payload: "{\"a\":\"b\"}" + }) + end + + assert_raise ArgumentError, ~r/unable to convert metadata to binary/, fn -> + fastlane!(@serializer, %UserBroadcast{ + topic: "topic", + user_event: "user_event", + metadata: %{k: String.duplicate("e", 256)}, + user_payload_encoding: :json, + user_payload: "{\"a\":\"b\"}" + }) + end + end + end + + describe "decode!/2 invalid text formats" do + test "raises on a bare JSON string" do + raw = Jason.encode!("just a string") + + assert_raise Phoenix.Socket.InvalidMessageError, fn -> + decode!(@serializer, raw, opcode: :text) + end + end + + test "raises on a V1 map" do + raw = Jason.encode!(%{"topic" => "t", "event" => "e", "payload" => %{"m" => 1}, "ref" => "1", "join_ref" => "11"}) + + assert_raise Phoenix.Socket.InvalidMessageError, fn -> + decode!(@serializer, raw, opcode: :text) + end + end + end + + describe "binary decode" do + test "pushed message" do + assert decode!(@serializer, @client_push, opcode: :binary) == %Phoenix.Socket.Message{ + join_ref: "12", + ref: "123", + topic: "topic", + event: "event", + payload: {:binary, <<101, 102, 103>>} + } + end + + test "binary user pushed message with metadata" do + assert decode!(@serializer, @client_binary_user_broadcast_push_with_metadata, opcode: :binary) == + %Phoenix.Socket.Message{ + join_ref: "12", + ref: "123", + topic: "topic", + event: "broadcast", + payload: {"user_event", :binary, <<101, 102, 103>>, %{"store" => true}} + } + end + + test "binary user pushed message" do + assert decode!(@serializer, @client_binary_user_broadcast_push, opcode: :binary) == %Phoenix.Socket.Message{ + join_ref: "12", + ref: "123", + topic: "topic", + event: "broadcast", + payload: {"user_event", :binary, <<101, 102, 103>>, %{}} + } + end + + test "json binary user pushed message" do + assert decode!(@serializer, @client_json_user_broadcast_push, opcode: :binary) == %Phoenix.Socket.Message{ + join_ref: "12", + ref: "123", + topic: "topic", + event: "broadcast", + payload: {"user_event", :json, "{\"a\":\"b\"}", %{}} + } + end + end +end diff --git a/test/realtime_web/socket_test.exs b/test/realtime_web/socket_test.exs new file mode 100644 index 000000000..69e885b3e --- /dev/null +++ b/test/realtime_web/socket_test.exs @@ -0,0 +1,64 @@ +defmodule RealtimeWeb.SocketTest do + use ExUnit.Case, async: true + + alias RealtimeWeb.Socket + + describe "collect_traffic_telemetry/4" do + test "returns previous values unchanged when transport_pid is nil" do + assert Socket.collect_traffic_telemetry(nil, "tenant", 42, 99) == + %{latest_recv: 42, latest_send: 99} + end + + test "fires no telemetry when transport_pid is nil" do + ref = :telemetry_test.attach_event_handlers(self(), [[:realtime, :channel, :output_bytes]]) + + Socket.collect_traffic_telemetry(nil, "tenant", 0, 0) + + refute_received {[:realtime, :channel, :output_bytes], ^ref, _, _} + end + + test "returns zero stats when transport process has no port links" do + pid = spawn(fn -> Process.sleep(:infinity) end) + + assert Socket.collect_traffic_telemetry(pid, "tenant", 0, 0) == + %{latest_recv: 0, latest_send: 0} + + Process.exit(pid, :kill) + end + + test "fires output_bytes and input_bytes telemetry with correct tenant metadata" do + pid = spawn(fn -> Process.sleep(:infinity) end) + + ref = + :telemetry_test.attach_event_handlers(self(), [ + [:realtime, :channel, :output_bytes], + [:realtime, :channel, :input_bytes] + ]) + + Socket.collect_traffic_telemetry(pid, "my-tenant", 0, 0) + + assert_received {[:realtime, :channel, :output_bytes], ^ref, %{size: 0}, %{tenant: "my-tenant"}} + assert_received {[:realtime, :channel, :input_bytes], ^ref, %{size: 0}, %{tenant: "my-tenant"}} + + Process.exit(pid, :kill) + end + + test "delta is clamped to zero when previous stats exceed current (no negative deltas)" do + pid = spawn(fn -> Process.sleep(:infinity) end) + + ref = + :telemetry_test.attach_event_handlers(self(), [ + [:realtime, :channel, :output_bytes], + [:realtime, :channel, :input_bytes] + ]) + + # No port links → latest = 0, but previous > 0 → delta would be negative without max(0, ...) + Socket.collect_traffic_telemetry(pid, "tenant", 1000, 500) + + assert_received {[:realtime, :channel, :output_bytes], ^ref, %{size: 0}, _} + assert_received {[:realtime, :channel, :input_bytes], ^ref, %{size: 0}, _} + + Process.exit(pid, :kill) + end + end +end diff --git a/test/realtime_web/tenant_broadcaster_test.exs b/test/realtime_web/tenant_broadcaster_test.exs index d9afbf641..54872283f 100644 --- a/test/realtime_web/tenant_broadcaster_test.exs +++ b/test/realtime_web/tenant_broadcaster_test.exs @@ -1,5 +1,5 @@ defmodule RealtimeWeb.TenantBroadcasterTest do - # Usage of Clustered + # Usage of Clustered and changing Application env use Realtime.DataCase, async: false alias Phoenix.Socket.Broadcast @@ -33,6 +33,7 @@ defmodule RealtimeWeb.TenantBroadcasterTest do end setup context do + tenant_id = random_string() Endpoint.subscribe(@topic) :erpc.call(context.node, Subscriber, :subscribe, [self(), @topic]) @@ -44,16 +45,16 @@ defmodule RealtimeWeb.TenantBroadcasterTest do __MODULE__, [:realtime, :tenants, :payload, :size], &__MODULE__.handle_telemetry/4, - pid: self() + %{pid: self(), tenant: tenant_id} ) - :ok + {:ok, tenant_id: tenant_id} end - describe "pubsub_broadcast/4" do - test "pubsub_broadcast", %{node: node} do + describe "pubsub_broadcast/5" do + test "pubsub_broadcast", %{node: node, tenant_id: tenant_id} do message = %Broadcast{topic: @topic, event: "an event", payload: %{"a" => "b"}} - TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub) + TenantBroadcaster.pubsub_broadcast(tenant_id, @topic, message, Phoenix.PubSub, :broadcast) assert_receive ^message @@ -64,13 +65,13 @@ defmodule RealtimeWeb.TenantBroadcasterTest do :telemetry, [:realtime, :tenants, :payload, :size], %{size: 114}, - %{tenant: "realtime-dev"} + %{tenant: ^tenant_id, message_type: :broadcast} } end - test "pubsub_broadcast list payload", %{node: node} do + test "pubsub_broadcast list payload", %{node: node, tenant_id: tenant_id} do message = %Broadcast{topic: @topic, event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]} - TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub) + TenantBroadcaster.pubsub_broadcast(tenant_id, @topic, message, Phoenix.PubSub, :broadcast) assert_receive ^message @@ -81,13 +82,13 @@ defmodule RealtimeWeb.TenantBroadcasterTest do :telemetry, [:realtime, :tenants, :payload, :size], %{size: 130}, - %{tenant: "realtime-dev"} + %{tenant: ^tenant_id, message_type: :broadcast} } end - test "pubsub_broadcast string payload", %{node: node} do + test "pubsub_broadcast string payload", %{node: node, tenant_id: tenant_id} do message = %Broadcast{topic: @topic, event: "an event", payload: "some text payload"} - TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub) + TenantBroadcaster.pubsub_broadcast(tenant_id, @topic, message, Phoenix.PubSub, :broadcast) assert_receive ^message @@ -98,13 +99,13 @@ defmodule RealtimeWeb.TenantBroadcasterTest do :telemetry, [:realtime, :tenants, :payload, :size], %{size: 119}, - %{tenant: "realtime-dev"} + %{tenant: ^tenant_id, message_type: :broadcast} } end end - describe "pubsub_broadcast_from/5" do - test "pubsub_broadcast_from", %{node: node} do + describe "pubsub_broadcast_from/6" do + test "pubsub_broadcast_from", %{node: node, tenant_id: tenant_id} do parent = self() spawn_link(fn -> @@ -120,7 +121,7 @@ defmodule RealtimeWeb.TenantBroadcasterTest do message = %Broadcast{topic: @topic, event: "an event", payload: %{"a" => "b"}} - TenantBroadcaster.pubsub_broadcast_from("realtime-dev", self(), @topic, message, Phoenix.PubSub) + TenantBroadcaster.pubsub_broadcast_from(tenant_id, self(), @topic, message, Phoenix.PubSub, :broadcast) assert_receive {:other_process, ^message} @@ -131,7 +132,7 @@ defmodule RealtimeWeb.TenantBroadcasterTest do :telemetry, [:realtime, :tenants, :payload, :size], %{size: 114}, - %{tenant: "realtime-dev"} + %{tenant: ^tenant_id, message_type: :broadcast} } # This process does not receive the message @@ -139,5 +140,99 @@ defmodule RealtimeWeb.TenantBroadcasterTest do end end - def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {:telemetry, event, measures, metadata}) + describe "pubsub_direct_broadcast/6" do + test "pubsub_direct_broadcast", %{node: node, tenant_id: tenant_id} do + message = %Broadcast{topic: @topic, event: "an event", payload: %{"a" => "b"}} + + TenantBroadcaster.pubsub_direct_broadcast(node(), tenant_id, @topic, message, Phoenix.PubSub, :broadcast) + TenantBroadcaster.pubsub_direct_broadcast(node, tenant_id, @topic, message, Phoenix.PubSub, :broadcast) + + assert_receive ^message + + # Remote node received the broadcast + assert_receive {:relay, ^node, ^message} + + assert_receive { + :telemetry, + [:realtime, :tenants, :payload, :size], + %{size: 114}, + %{tenant: ^tenant_id, message_type: :broadcast} + } + end + + test "pubsub_direct_broadcast list payload", %{node: node, tenant_id: tenant_id} do + message = %Broadcast{topic: @topic, event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]} + + TenantBroadcaster.pubsub_direct_broadcast(node(), tenant_id, @topic, message, Phoenix.PubSub, :broadcast) + TenantBroadcaster.pubsub_direct_broadcast(node, tenant_id, @topic, message, Phoenix.PubSub, :broadcast) + + assert_receive ^message + + # Remote node received the broadcast + assert_receive {:relay, ^node, ^message} + + assert_receive { + :telemetry, + [:realtime, :tenants, :payload, :size], + %{size: 130}, + %{tenant: ^tenant_id, message_type: :broadcast} + } + end + + test "pubsub_direct_broadcast string payload", %{node: node, tenant_id: tenant_id} do + message = %Broadcast{topic: @topic, event: "an event", payload: "some text payload"} + + TenantBroadcaster.pubsub_direct_broadcast(node(), tenant_id, @topic, message, Phoenix.PubSub, :broadcast) + TenantBroadcaster.pubsub_direct_broadcast(node, tenant_id, @topic, message, Phoenix.PubSub, :broadcast) + + assert_receive ^message + + # Remote node received the broadcast + assert_receive {:relay, ^node, ^message} + + assert_receive { + :telemetry, + [:realtime, :tenants, :payload, :size], + %{size: 119}, + %{tenant: ^tenant_id, message_type: :broadcast} + } + end + end + + describe "collect_payload_size/3" do + test "emit telemetry for struct", %{tenant_id: tenant_id} do + TenantBroadcaster.collect_payload_size( + tenant_id, + %Phoenix.Socket.Broadcast{event: "broadcast", payload: %{"a" => "b"}}, + :broadcast + ) + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 65}, + %{tenant: ^tenant_id, message_type: :broadcast}} + end + + test "emit telemetry for map", %{tenant_id: tenant_id} do + TenantBroadcaster.collect_payload_size( + tenant_id, + %{event: "broadcast", payload: %{"a" => "b"}}, + :postgres_changes + ) + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 53}, + %{tenant: ^tenant_id, message_type: :postgres_changes}} + end + + test "emit telemetry for non-map", %{tenant_id: tenant_id} do + TenantBroadcaster.collect_payload_size(tenant_id, "some blob", :presence) + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 15}, + %{tenant: ^tenant_id, message_type: :presence}} + end + end + + def handle_telemetry(event, measures, metadata, %{pid: pid, tenant: tenant}) do + if metadata[:tenant] == tenant do + send(pid, {:telemetry, event, measures, metadata}) + end + end end diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex index 8bdab2185..ebc823967 100644 --- a/test/support/channel_case.ex +++ b/test/support/channel_case.ex @@ -16,7 +16,6 @@ defmodule RealtimeWeb.ChannelCase do """ use ExUnit.CaseTemplate - alias Ecto.Adapters.SQL.Sandbox using do quote do @@ -24,14 +23,13 @@ defmodule RealtimeWeb.ChannelCase do import Phoenix.ChannelTest import Generators import TenantConnection + import TestHelpers # The default endpoint for testing @endpoint RealtimeWeb.Endpoint end end setup tags do - pid = Sandbox.start_owner!(Realtime.Repo, shared: not tags[:async]) - on_exit(fn -> Sandbox.stop_owner(pid) end) - :ok + Realtime.DataCase.setup_sandbox(tags) end end diff --git a/test/support/cleanup.ex b/test/support/cleanup.ex index 12954698c..161eb58de 100644 --- a/test/support/cleanup.ex +++ b/test/support/cleanup.ex @@ -10,7 +10,7 @@ defmodule Cleanup do hostname: "localhost", port: 5433, database: "postgres", - username: "supabase_admin", + username: "supabase_realtime_admin", password: "postgres" ) diff --git a/test/support/clustered.ex b/test/support/clustered.ex index c7028b79b..47911a064 100644 --- a/test/support/clustered.ex +++ b/test/support/clustered.ex @@ -23,25 +23,42 @@ defmodule Clustered do end ``` """ - @spec start(any()) :: {:ok, node} + @spec start(any(), keyword()) :: {:ok, node} def start(aux_mod \\ nil, opts \\ []) do - {:ok, _pid, node} = start_disconnected(aux_mod, opts) + {:ok, pid, node} = start_disconnected(aux_mod, opts) + + :ok = wait_for_gen_rpc(pid) true = Node.connect(node) + max_cast_clients = Application.get_env(:realtime, :max_gen_rpc_clients, 5) + max_call_clients = Application.get_env(:realtime, :max_gen_rpc_call_clients, 1) + + for key <- 1..max_cast_clients do + _ = :gen_rpc.call({node, {:cast, key}}, :erlang, :node, [], 5_000) + end + + for key <- 1..max_call_clients do + _ = :gen_rpc.call({node, {:call, key}}, :erlang, :node, [], 5_000) + end + {:ok, node} end @doc """ Similar to `start/2` but the node is not connected automatically """ - @spec start_disconnected(any()) :: {:ok, :peer.server_ref(), node} + @spec start_disconnected(any(), keyword()) :: {:ok, :peer.server_ref(), node} def start_disconnected(aux_mod \\ nil, opts \\ []) do extra_config = Keyword.get(opts, :extra_config, []) phoenix_port = Keyword.get(opts, :phoenix_port, 4012) + name = Keyword.get(opts, :name, :peer.random_name()) + + partition = System.get_env("MIX_TEST_PARTITION") + node_name = if partition, do: :"main#{partition}@127.0.0.1", else: :"main@127.0.0.1" :ok = - case :net_kernel.start([:"main@127.0.0.1"]) do + case :net_kernel.start([node_name]) do {:ok, _} -> :ok @@ -53,7 +70,6 @@ defmodule Clustered do end true = :erlang.set_cookie(:cookie) - name = :peer.random_name() {:ok, pid, node} = ExUnit.Callbacks.start_supervised(%{ @@ -106,10 +122,12 @@ defmodule Clustered do :ok = :peer.call(pid, Application, :put_env, [app_name, key, value]) end + wait_for_port_free(gen_rpc_tcp_client_port) + {:ok, _} = :peer.call(pid, Application, :ensure_all_started, [:gen_rpc]) {:ok, _} = :peer.call(pid, Application, :ensure_all_started, [:mix]) :ok = :peer.call(pid, Mix, :env, [Mix.env()]) - Enum.map( + Enum.each( [:logger, :runtime_tools, :prom_ex, :mix, :os_mon, :realtime], fn app -> {:ok, _} = :peer.call(pid, Application, :ensure_all_started, [app]) end ) @@ -121,7 +139,41 @@ defmodule Clustered do {:ok, pid, node} end - def stop() do - Node.stop() + defp wait_for_gen_rpc(pid) do + port = :peer.call(pid, Application, :get_env, [:gen_rpc, :tcp_server_port]) + + case port do + port when is_integer(port) and port > 0 -> wait_for_port({127, 0, 0, 1}, port, 50, 100) + _ -> raise "gen_rpc tcp_server_port is not configured: #{inspect(port)}" + end + end + + defp wait_for_port_free(port, attempts \\ 50, delay_ms \\ 100) + defp wait_for_port_free(_port, 0, _delay_ms), do: :ok + + defp wait_for_port_free(port, attempts, delay_ms) do + case :gen_tcp.connect({127, 0, 0, 1}, port, [:binary, active: false], 100) do + {:ok, socket} -> + :gen_tcp.close(socket) + Process.sleep(delay_ms) + wait_for_port_free(port, attempts - 1, delay_ms) + + {:error, _} -> + :ok + end + end + + defp wait_for_port(_host, _port, 0, _delay_ms), do: raise("gen_rpc tcp server did not start in time") + + defp wait_for_port(host, port, attempts, delay_ms) do + case :gen_tcp.connect(host, port, [:binary, active: false], 200) do + {:ok, socket} -> + :ok = :gen_tcp.close(socket) + :ok + + {:error, _reason} -> + Process.sleep(delay_ms) + wait_for_port(host, port, attempts - 1, delay_ms) + end end end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 9289af1b5..55f932cbf 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -16,16 +16,17 @@ defmodule RealtimeWeb.ConnCase do """ use ExUnit.CaseTemplate - alias Ecto.Adapters.SQL.Sandbox using do quote do # Import conveniences for testing with connections import Generators + import Integrations import TenantConnection import Phoenix.ConnTest import Plug.Conn import Realtime.DataCase + import TestHelpers alias RealtimeWeb.Router.Helpers, as: Routes @@ -38,9 +39,7 @@ defmodule RealtimeWeb.ConnCase do end setup tags do - pid = Sandbox.start_owner!(Realtime.Repo, shared: not tags[:async]) - on_exit(fn -> Sandbox.stop_owner(pid) end) - + Realtime.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end end diff --git a/test/support/containers.ex b/test/support/containers.ex index cd66f2699..cb4b847bf 100644 --- a/test/support/containers.ex +++ b/test/support/containers.ex @@ -3,21 +3,22 @@ defmodule Containers do alias Realtime.Tenants.Connect alias Containers.Container alias Realtime.Database - alias Realtime.RateCounter + alias Realtime.Tenants alias Realtime.Tenants.Migrations use GenServer - @image "supabase/postgres:15.8.1.040" + defp image, do: System.get_env("POSTGRES_IMAGE", "supabase/postgres:17.6.1.127") + # Pull image if not available def pull do - case System.cmd("docker", ["image", "inspect", @image]) do + case System.cmd("docker", ["image", "inspect", image()]) do {_, 0} -> :ok _ -> - IO.puts("Pulling image #{@image}. This might take a while...") - {_, 0} = System.cmd("docker", ["pull", @image]) + IO.puts("Pulling image #{image()}. This might take a while...") + {_, 0} = System.cmd("docker", ["pull", image()]) end end @@ -29,7 +30,14 @@ defmodule Containers do def init(max_cases) do existing_containers = existing_containers("realtime-test-*") ports = for {_, port} <- existing_containers, do: port - available_ports = Enum.shuffle(5501..9000) -- ports + + partition = System.get_env("MIX_TEST_PARTITION", "1") |> String.to_integer() + total_partitions = System.get_env("MIX_TEST_TOTAL_PARTITIONS", "4") |> String.to_integer() + all_ports = 5501..9000 + range_size = div(Enum.count(all_ports), total_partitions) + + available_ports = + all_ports |> Enum.slice((partition - 1) * range_size, range_size) |> Enum.shuffle() |> Kernel.--(ports) {:ok, %{existing_containers: existing_containers, ports: available_ports}, {:continue, {:pool, max_cases}}} end @@ -37,7 +45,13 @@ defmodule Containers do def handle_continue({:pool, max_cases}, state) do {:ok, _pid} = :poolboy.start_link( - [name: {:local, Containers.Pool}, size: max_cases + 2, max_overflow: 0, worker_module: Containers.Container], + [ + strategy: :fifo, + name: {:local, Containers.Pool}, + size: max_cases + 2, + max_overflow: 0, + worker_module: Containers.Container + ], [] ) @@ -55,12 +69,22 @@ defmodule Containers do {:reply, {:ok, name, port}, %{state | existing_containers: rest}} [] -> - [port | ports] = state.ports - name = "realtime-test-#{random_string(12)}" + {name, port, ports} = start_available_container(state.ports) + {:reply, {:ok, name, port}, %{state | ports: ports}} + end + end - docker_run!(name, port) + defp start_available_container(ports, attempts \\ 5) - {:reply, {:ok, name, port}, %{state | ports: ports}} + defp start_available_container([], _attempts), do: raise("Containers: no ports left to start a container") + defp start_available_container(_ports, 0), do: raise("Containers: exhausted retries starting a container") + + defp start_available_container([port | ports], attempts) do + name = "realtime-test-#{random_string(12)}" + + case docker_run(name, port) do + {_, 0} -> {name, port, ports} + {_output, _code} -> start_available_container(ports, attempts - 1) end end @@ -91,7 +115,7 @@ defmodule Containers do Migrations.run_migrations(tenant) {:ok, pid} = Database.connect(tenant, "realtime_test", :stop) - :ok = Migrations.create_partitions(pid) + :ok = Tenants.create_messages_partitions(pid) Process.exit(pid, :normal) tenant @@ -110,73 +134,119 @@ defmodule Containers do end end - # Might be worth changing this to {:ok, tenant} - def checkout_tenant(opts \\ []) do + defp storage_up!(tenant) do + {:ok, db_settings} = Database.from_tenant(tenant, "realtime_test", :stop) + + settings = + db_settings + |> Map.from_struct() + |> Keyword.new() + + case Ecto.Adapters.Postgres.storage_up(settings) do + :ok -> :ok + {:error, :already_up} -> :ok + _ -> raise "Failed to create database" + end + end + + def checkout_tenant(opts \\ []), do: do_checkout_tenant(opts, :sandbox) + def checkout_tenant_unboxed(opts \\ []), do: do_checkout_tenant(opts, :unboxed) + + defp do_checkout_tenant(opts, mode) do with container when is_pid(container) <- :poolboy.checkout(Containers.Pool, true, 5_000), port <- Container.port(container) do - tenant = Generators.tenant_fixture(%{port: port, migrations_ran: 0}) + tenant = repo_run(mode, fn -> Generators.tenant_fixture(%{port: port, migrations_ran: 0}) end) + + # TODO: REAL-818 - remove when Project Migrations v2 is done + Realtime.FeatureFlags.Cache.update_cache(%Realtime.Api.FeatureFlag{ + name: "use_supabase_realtime_admin", + enabled: true + }) + run_migrations? = Keyword.get(opts, :run_migrations, false) - settings = Database.from_tenant(tenant, "realtime_test", :stop) + {:ok, settings} = Database.from_tenant(tenant, "realtime_test", :stop) settings = %{settings | max_restarts: 0, ssl: false} {:ok, conn} = Database.connect_db(settings) - Postgrex.transaction(conn, fn db_conn -> - Postgrex.query!(db_conn, "DROP SCHEMA IF EXISTS realtime CASCADE", []) - Postgrex.query!(db_conn, "CREATE SCHEMA IF NOT EXISTS realtime", []) - end) - - Process.exit(conn, :normal) + try do + reset_realtime_schema!(settings) + storage_up!(tenant) - RateCounter.stop(tenant.external_id) + RateCounterHelper.stop(tenant.external_id) - # Automatically checkin the container at the end of the test - ExUnit.Callbacks.on_exit(fn -> - # Clean up database connections if they are set-up + ExUnit.Callbacks.on_exit(fn -> + if connect_pid = Connect.whereis(tenant.external_id) do + supervisor = {:via, PartitionSupervisor, {Realtime.Tenants.Connect.DynamicSupervisor, tenant.external_id}} - if connect_pid = Connect.whereis(tenant.external_id) do - supervisor = {:via, PartitionSupervisor, {Realtime.Tenants.Connect.DynamicSupervisor, tenant.external_id}} + DynamicSupervisor.terminate_child(supervisor, connect_pid) + end - DynamicSupervisor.terminate_child(supervisor, connect_pid) - end + try do + PostgresCdcRls.handle_stop(tenant.external_id, 5_000) + catch + _, _ -> :ok + end - try do - PostgresCdcRls.handle_stop(tenant.external_id, 5_000) - catch - _, _ -> :ok - end + if mode == :unboxed do + repo_run(:unboxed, fn -> Realtime.Api.delete_tenant_by_external_id(tenant.external_id) end) + end - :poolboy.checkin(Containers.Pool, container) - end) + :poolboy.checkin(Containers.Pool, container) + end) - tenant = if run_migrations? do case run_migrations(tenant) do {:ok, count} -> - # Avoiding to use Tenants.update_migrations_ran/2 because it touches Cachex and it doesn't play well with - # Ecto Sandbox - :ok = Migrations.create_partitions(conn) - {:ok, tenant} = Realtime.Api.update_tenant(tenant, %{migrations_ran: count}) + :ok = Tenants.create_messages_partitions(conn) + + {:ok, tenant} = + repo_run(mode, fn -> + Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{migrations_ran: count}) + end) + + if mode == :sandbox, do: Realtime.Tenants.Cache.invalidate_tenant_cache(tenant.external_id) + tenant - _ -> - raise "Faled to run migrations" + error -> + raise "Failed to run migrations: #{inspect(error)}" end else tenant end - - tenant + after + GenServer.stop(conn) + end else _ -> {:error, "failed to checkout a container"} end end + defp repo_run(:unboxed, fun), do: Ecto.Adapters.SQL.Sandbox.unboxed_run(Realtime.Repo, fun) + defp repo_run(:sandbox, fun), do: fun.() + + defp reset_realtime_schema!(settings) do + {:ok, admin_conn} = + Postgrex.start_link( + hostname: settings.hostname, + port: settings.port, + database: settings.database, + username: "supabase_admin", + password: settings.password + ) + + Postgrex.query!(admin_conn, "DROP PUBLICATION IF EXISTS supabase_realtime_test", []) + Postgrex.query!(admin_conn, "DROP SCHEMA IF EXISTS realtime CASCADE", []) + Postgrex.query!(admin_conn, "CREATE SCHEMA realtime", []) + Postgrex.query!(admin_conn, "GRANT USAGE ON SCHEMA realtime TO postgres, anon, authenticated, service_role", []) + Postgrex.query!(admin_conn, "GRANT ALL ON SCHEMA realtime TO supabase_realtime_admin WITH GRANT OPTION", []) + end + def stop_containers() do {list, 0} = System.cmd("docker", ["ps", "-a", "--format", "{{.Names}}", "--filter", "name=realtime-test-*"]) - names = list |> String.trim() |> String.split("\n") - for name <- names do + for name <- String.split(list, "\n", trim: true) do System.cmd("docker", ["rm", "-f", name]) end end @@ -222,7 +292,7 @@ defmodule Containers do # This exists so we avoid using an external process on Realtime.Tenants.Migrations defp run_migrations(tenant) do %{extensions: [%{settings: settings} | _]} = tenant - settings = Database.from_settings(settings, "realtime_migrations", :stop) + {:ok, settings} = Database.from_settings(settings, "realtime_migrations", :stop) [ hostname: settings.hostname, @@ -251,8 +321,16 @@ defmodule Containers do end defp docker_run!(name, port) do - {_, 0} = - System.cmd("docker", [ + {_, 0} = docker_run(name, port) + end + + defp docker_run(name, port) do + initdb_sh = Path.expand("../../dev/postgres/za-permit-supabase-admin.sh", __DIR__) + initdb_sql = Path.expand("../../dev/postgres/zb-supabase-schema.sql", __DIR__) + + System.cmd( + "docker", + [ "run", "-d", "--rm", @@ -262,12 +340,24 @@ defmodule Containers do "POSTGRES_HOST=/var/run/postgresql", "-e", "POSTGRES_PASSWORD=postgres", + "-v", + "#{initdb_sh}:/docker-entrypoint-initdb.d/za-permit-supabase-admin.sh", + "-v", + "#{initdb_sql}:/docker-entrypoint-initdb.d/zb-supabase-schema.sql", "-p", "#{port}:5432", - @image, + image(), "postgres", "-c", - "config_file=/etc/postgresql/postgresql.conf" - ]) + "config_file=/etc/postgresql/postgresql.conf", + "-c", + "wal_keep_size=32MB", + "-c", + "max_wal_size=32MB", + "-c", + "max_slot_wal_keep_size=32MB" + ], + stderr_to_stdout: true + ) end end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 3c9cd02b8..65cd08f84 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -25,13 +25,18 @@ defmodule Realtime.DataCase do import Realtime.DataCase import Generators import TenantConnection + import TestHelpers end end - setup tags do + def setup_sandbox(tags) do pid = Sandbox.start_owner!(Realtime.Repo, shared: not tags[:async]) on_exit(fn -> Sandbox.stop_owner(pid) end) + :ok + end + setup tags do + setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end diff --git a/test/support/generators.ex b/test/support/generators.ex index 768e3823b..5669e15c5 100644 --- a/test/support/generators.ex +++ b/test/support/generators.ex @@ -20,10 +20,12 @@ defmodule Generators do "settings" => %{ "db_host" => "127.0.0.1", "db_name" => "postgres", - "db_user" => "supabase_admin", + "db_user" => System.get_env("DB_USER", "supabase_admin"), "db_password" => "postgres", + "db_user_realtime" => System.get_env("DB_USER_REALTIME", "supabase_realtime_admin"), + "db_pass_realtime" => "postgres", "db_port" => "#{override[:port] || port()}", - "poll_interval" => 100, + "poll_interval_ms" => 10, "poll_max_changes" => 100, "poll_max_record_bytes" => 1_048_576, "region" => "us-east-1", @@ -48,7 +50,7 @@ defmodule Generators do @spec message_fixture(Realtime.Api.Tenant.t()) :: any() def message_fixture(tenant, override \\ %{}) do {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) - Realtime.Tenants.Migrations.create_partitions(db_conn) + Realtime.Tenants.create_messages_partitions(db_conn) create_attrs = %{ "topic" => random_string(), @@ -283,25 +285,31 @@ defmodule Generators do jwt end - @port 4003 - @serializer Phoenix.Socket.V1.JSONSerializer + defp test_port do + :realtime + |> Application.get_env(RealtimeWeb.Endpoint, %{}) + |> get_in([:http, :port]) || 4002 + end + + def get_connection(tenant, serializer \\ Phoenix.Socket.V1.JSONSerializer, opts \\ []) do + params = Keyword.get(opts, :params, %{log_level: :warning}) + claims = Keyword.get(opts, :claims, %{}) + role = Keyword.get(opts, :role, "anon") - def get_connection( - tenant, - role \\ "anon", - claims \\ %{}, - params \\ %{vsn: "1.0.0", log_level: :warning} - ) do params = Enum.reduce(params, "", fn {k, v}, acc -> "#{acc}&#{k}=#{v}" end) - uri = "#{uri(tenant)}?#{params}" + uri = "#{uri(tenant, serializer)}&#{params}" with {:ok, token} <- token_valid(tenant, role, claims), - {:ok, socket} <- WebsocketClient.connect(self(), uri, @serializer, [{"x-api-key", token}]) do + {:ok, socket} <- WebsocketClient.connect(self(), uri, serializer, [{"x-api-key", token}]) do {socket, token} end end - def uri(tenant, port \\ @port), do: "ws://#{tenant.external_id}.localhost:#{port}/socket/websocket" + def uri(tenant, serializer, port \\ nil), + do: "ws://#{tenant.external_id}.localhost:#{port || test_port()}/socket/websocket?vsn=#{vsn(serializer)}" + + defp vsn(Phoenix.Socket.V1.JSONSerializer), do: "1.0.0" + defp vsn(RealtimeWeb.Socket.V2Serializer), do: "2.0.0" @spec token_valid(Tenant.t(), binary(), map()) :: {:ok, binary()} def token_valid(tenant, role, claims \\ %{}), do: generate_token(tenant, Map.put(claims, :role, role)) diff --git a/test/support/integrations.ex b/test/support/integrations.ex new file mode 100644 index 000000000..8ed2bc06d --- /dev/null +++ b/test/support/integrations.ex @@ -0,0 +1,125 @@ +defmodule Integrations do + import ExUnit.Assertions + import Generators + + alias Realtime.Api.Tenant + alias Realtime.Database + alias Realtime.Tenants.Authorization + alias Realtime.Tenants.Connect + + def checkout_tenant_and_connect(_context \\ %{}) do + tenant = Containers.checkout_tenant(run_migrations: true) + {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + assert Connect.ready?(tenant.external_id) + %{db_conn: db_conn, tenant: tenant} + end + + def rls_context(%{tenant: tenant} = context) do + {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) + clean_table(db_conn, "realtime", "messages") + topic = Map.get(context, :topic, random_string()) + policies = Map.get(context, :policies, nil) + role = Map.get(context, :role, nil) + sub = Map.get(context, :sub, nil) + + if policies, do: create_rls_policies(db_conn, policies, %{topic: topic, role: role, sub: sub}) + + authorization_context = + Authorization.build_authorization_params(%{ + tenant_id: tenant.external_id, + topic: topic, + headers: [{"header-1", "value-1"}], + claims: %{sub: sub, role: role}, + role: role, + sub: sub + }) + + ExUnit.Callbacks.on_exit(fn -> + if Process.alive?(db_conn) do + try do + GenServer.stop(db_conn, :normal, 1_000) + catch + :exit, _ -> :ok + end + end + end) + + %{topic: topic, role: role, sub: sub, db_conn: db_conn, authorization_context: authorization_context} + end + + def change_tenant_configuration(%Tenant{external_id: external_id}, limit, value) do + tenant = + external_id + |> Realtime.Tenants.get_tenant_by_external_id() + |> Tenant.changeset(%{limit => value}) + |> Realtime.Repo.update!() + + Realtime.Tenants.Cache.update_cache(tenant) + end + + def checkout_tenant_connect_and_setup_postgres_changes(_context \\ %{}) do + %{db_conn: db_conn} = result = checkout_tenant_and_connect() + setup_postgres_changes(db_conn) + result + end + + def setup_postgres_changes(conn) do + publication = "supabase_realtime_test" + + Postgrex.transaction(conn, fn db_conn -> + queries = [ + "DROP TABLE IF EXISTS public.test", + "DROP PUBLICATION IF EXISTS #{publication}", + "create sequence if not exists test_id_seq;", + """ + create table "public"."test" ( + "id" int4 not null default nextval('test_id_seq'::regclass), + "details" text, + "binary_data" bytea, + primary key ("id")); + """, + "grant all on table public.test to anon;", + "grant all on table public.test to supabase_realtime_admin;", + "grant all on table public.test to authenticated;", + # `for all tables` requires superuser + "create publication #{publication} for table public.test", + """ + DO $$ + DECLARE + r RECORD; + BEGIN + FOR r IN + SELECT slot_name, active_pid + FROM pg_replication_slots + WHERE slot_name LIKE 'supabase_realtime%' + LOOP + IF r.active_pid IS NOT NULL THEN + BEGIN + SELECT pg_terminate_backend(r.active_pid); + PERFORM pg_sleep(0.5); + EXCEPTION WHEN OTHERS THEN + NULL; + END; + END IF; + + BEGIN + IF EXISTS (SELECT 1 FROM pg_replication_slots WHERE slot_name = r.slot_name) THEN + PERFORM pg_drop_replication_slot(r.slot_name); + END IF; + EXCEPTION WHEN OTHERS THEN + NULL; + END; + END LOOP; + END$$; + """ + ] + + Enum.each(queries, &Postgrex.query!(db_conn, &1, [])) + end) + end + + def assert_process_down(pid, timeout \\ 5000) do + ref = Process.monitor(pid) + assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, timeout + end +end diff --git a/test/support/metrics_helper.ex b/test/support/metrics_helper.ex new file mode 100644 index 000000000..ca31ad91b --- /dev/null +++ b/test/support/metrics_helper.ex @@ -0,0 +1,53 @@ +defmodule MetricsHelper do + @spec search(String.t(), String.t(), map() | keyword() | nil) :: + {:ok, String.t(), map(), String.t()} | {:error, String.t()} + def search(prometheus_metrics, metric_name, expected_tags \\ nil) do + # Escape the metric_name to handle any special regex characters + escaped_name = Regex.escape(metric_name) + regex = ~r/^(?#{escaped_name})\{(?[^}]+)\}\s+(?\d+(?:\.\d+)?)$/ + + prometheus_metrics + |> IO.iodata_to_binary() + |> String.split("\n", trim: true) + |> Enum.find_value( + nil, + fn item -> + case parse(item, regex, expected_tags) do + {:ok, value} -> value + {:error, _reason} -> false + end + end + ) + |> case do + nil -> nil + number -> String.to_integer(number) + end + end + + defp parse(metric_string, regex, expected_tags) do + case Regex.named_captures(regex, metric_string) do + %{"name" => _name, "tags" => tags_string, "value" => value} -> + tags = parse_tags(tags_string) + + if expected_tags && !matching_tags(tags, expected_tags) do + {:error, "Tags do not match expected tags"} + else + {:ok, value} + end + + nil -> + {:error, "Invalid metric format or metric name mismatch"} + end + end + + defp parse_tags(tags_string) do + ~r/(?[a-zA-Z_][a-zA-Z0-9_]*)="(?[^"]*)"/ + |> Regex.scan(tags_string, capture: :all_names) + |> Enum.map(fn [key, value] -> {key, value} end) + |> Map.new() + end + + defp matching_tags(tags, expected_tags) do + Enum.all?(expected_tags, fn {k, v} -> Map.get(tags, to_string(k)) == to_string(v) end) + end +end diff --git a/test/support/prometheus_fixtures.ex b/test/support/prometheus_fixtures.ex new file mode 100644 index 000000000..e69de29bb diff --git a/test/support/rate_counter_helper.ex b/test/support/rate_counter_helper.ex new file mode 100644 index 000000000..7a290d5a3 --- /dev/null +++ b/test/support/rate_counter_helper.ex @@ -0,0 +1,56 @@ +defmodule RateCounterHelper do + alias Realtime.RateCounter + + @spec new!(RateCounter.Args.t()) :: pid() + def new!(args) do + {:ok, _} = RateCounter.new(args) + [{pid, _}] = Registry.lookup(Realtime.Registry.Unique, {RateCounter, :rate_counter, args.id}) + await_initial_tick(pid) + pid + end + + defp await_initial_tick(pid) do + case :sys.get_state(pid) do + %RateCounter{bucket: []} -> await_initial_tick(pid) + state -> state + end + end + + @spec stop(term()) :: :ok + def stop(tenant_id) do + keys = + Registry.select(Realtime.Registry.Unique, [ + {{{:"$1", :_, {:_, :_, :"$2"}}, :"$3", :_}, [{:==, :"$1", RateCounter}, {:==, :"$2", tenant_id}], [:"$_"]} + ]) + + Enum.each(keys, fn {{_, _, key}, {pid, _}} -> + if Process.alive?(pid), do: GenServer.stop(pid) + Realtime.GenCounter.delete(key) + Cachex.del!(RateCounter, key) + end) + + :ok + end + + @spec tick!(RateCounter.Args.t()) :: RateCounter.t() + def tick!(args) do + [{pid, _}] = Registry.lookup(Realtime.Registry.Unique, {RateCounter, :rate_counter, args.id}) + send(pid, :tick) + {:ok, :sys.get_state(pid)} + end + + def tick_tenant_rate_counters!(tenant_id) do + keys = + Registry.select(Realtime.Registry.Unique, [ + {{{:"$1", :_, {:_, :_, :"$2"}}, :"$3", :_}, [{:==, :"$1", RateCounter}, {:==, :"$2", tenant_id}], [:"$_"]} + ]) + + Enum.each(keys, fn {{_, _, _key}, {pid, _}} -> + send(pid, :tick) + # do a get_state to wait for the tick to be processed + :sys.get_state(pid) + end) + + :ok + end +end diff --git a/test/support/tenant_connection.ex b/test/support/tenant_connection.ex index ce5956b49..77328bdfc 100644 --- a/test/support/tenant_connection.ex +++ b/test/support/tenant_connection.ex @@ -4,17 +4,17 @@ defmodule TenantConnection do """ alias Realtime.Api.Message alias Realtime.Database - alias Realtime.Repo + alias Realtime.Tenants.Repo alias Realtime.Tenants.Connect alias RealtimeWeb.Endpoint def create_message(attrs, conn, opts \\ [mode: :savepoint]) do - channel = Message.changeset(%Message{}, attrs) + message = Message.changeset(%Message{}, attrs) {:ok, result} = Database.transaction(conn, fn transaction_conn -> - with {:ok, %Message{} = channel} <- Repo.insert(transaction_conn, channel, Message, opts) do - channel + with {:ok, %Message{} = message} <- Repo.insert(transaction_conn, message, Message, opts) do + message end end) diff --git a/test/support/test_endpoint.ex b/test/support/test_endpoint.ex deleted file mode 100644 index 67c477153..000000000 --- a/test/support/test_endpoint.ex +++ /dev/null @@ -1,26 +0,0 @@ -defmodule TestEndpoint do - use Phoenix.Endpoint, otp_app: :phoenix - - @session_config store: :cookie, - key: "_hello_key", - signing_salt: "change_me" - - socket("/socket", RealtimeWeb.UserSocket, - websocket: [ - connect_info: [:peer_data, :uri, :x_headers], - fullsweep_after: 20, - max_frame_size: 8_000_000 - ] - ) - - plug(Plug.Session, @session_config) - plug(:fetch_session) - plug(Plug.CSRFProtection) - plug(:put_session) - - defp put_session(conn, _) do - conn - |> put_session(:from_session, "123") - |> send_resp(200, Plug.CSRFProtection.get_csrf_token()) - end -end diff --git a/test/support/test_helpers.ex b/test/support/test_helpers.ex new file mode 100644 index 000000000..74239537c --- /dev/null +++ b/test/support/test_helpers.ex @@ -0,0 +1,33 @@ +defmodule TestHelpers do + @moduledoc """ + Generic helpers for tests. + """ + + @doc """ + Runs `fun` until it returns a truthy value, retrying until it runs out of retries. + Returns `true` if `fun` succeeded within the retries, `false` otherwise. + + ## Options + + * `:retries` - how many times to retry before giving up (default: `50`) + * `:sleep` - how long to wait between retries, in milliseconds (default: `100`) + """ + @spec eventually((-> as_boolean(term())), keyword()) :: boolean() + def eventually(fun, opts \\ []) do + retries = Keyword.get(opts, :retries, 50) + sleep = Keyword.get(opts, :sleep, 100) + + cond do + fun.() -> + true + + retries == 0 -> + false + + true -> + opts = Keyword.put(opts, :retries, retries - 1) + Process.sleep(sleep) + eventually(fun, opts) + end + end +end diff --git a/test/support/websocket_client.ex b/test/support/websocket_client.ex index 1e7204f50..fd4d5a7bb 100644 --- a/test/support/websocket_client.ex +++ b/test/support/websocket_client.ex @@ -61,6 +61,18 @@ defmodule Realtime.Integration.WebsocketClient do """ def send_heartbeat(socket), do: send_event(socket, "phoenix", "heartbeat", %{}) + @doc """ + Sends a user broadcast push (V2 binary wire format, type 3). `payload` is the + raw user payload (binary). `opts` may include `:encoding` (`:binary` (default) + or `:json`) and `:metadata` (a map JSON-encoded into the frame). + """ + def send_user_broadcast(socket, topic, user_event, payload, opts \\ []) do + encoding = Keyword.get(opts, :encoding, :binary) + metadata = Keyword.get(opts, :metadata) + payload_tuple = {user_event, encoding, payload, metadata} + GenServer.call(socket, {:send, %Message{topic: topic, event: "broadcast", payload: payload_tuple}}) + end + @doc """ Sends join event to the WebSocket server per the Message protocol """ @@ -223,7 +235,8 @@ defmodule Realtime.Integration.WebsocketClient do {[binary_decode(data)], state} # prepare to close the connection when a close frame is received - {:close, _code, _data}, state -> + {:close, code, _data}, state -> + Kernel.send(state.sender, {:close_code, code}) {[], put_in(state.closing?, true)} frame, state -> @@ -265,6 +278,15 @@ defmodule Realtime.Integration.WebsocketClient do {{:binary, binary_encode_push!(msg)}, put_in(state.ref, ref + 1)} end + defp serialize_msg(%Message{payload: {user_event, encoding, user_payload, metadata}} = msg, %{ref: ref} = state) + when is_binary(user_event) and encoding in [:json, :binary] and is_binary(user_payload) do + {join_ref, state} = join_ref_for(msg, state) + msg = Map.merge(msg, %{ref: to_string(ref), join_ref: to_string(join_ref)}) + + {{:binary, binary_encode_user_broadcast_push!(msg, user_event, encoding, user_payload, metadata)}, + put_in(state.ref, ref + 1)} + end + defp serialize_msg(%Message{} = msg, %{ref: ref} = state) do {join_ref, state} = join_ref_for(msg, state) msg = Map.merge(msg, %{ref: to_string(ref), join_ref: to_string(join_ref)}) @@ -290,6 +312,29 @@ defmodule Realtime.Integration.WebsocketClient do IO.chardata_to_string(chardata) end + defp binary_encode_user_broadcast_push!(%Message{} = msg, user_event, encoding, user_payload, metadata) do + ref = to_string(msg.ref) + join_ref = to_string(msg.join_ref) + metadata_bin = if metadata, do: Jason.encode!(metadata), else: <<>> + encoding_byte = if encoding == :json, do: 1, else: 0 + + << + 3::size(8), + byte_size(join_ref)::size(8), + byte_size(ref)::size(8), + byte_size(msg.topic)::size(8), + byte_size(user_event)::size(8), + byte_size(metadata_bin)::size(8), + encoding_byte::size(8), + join_ref::binary, + ref::binary, + msg.topic::binary, + user_event::binary, + metadata_bin::binary, + user_payload::binary + >> + end + defp binary_encode_push!(%Message{payload: {:binary, data}} = msg) do ref = to_string(msg.ref) join_ref = to_string(msg.join_ref) @@ -342,4 +387,34 @@ defmodule Realtime.Integration.WebsocketClient do payload = %{"status" => status, "response" => {:binary, data}} %Message{join_ref: join_ref, ref: ref, topic: topic, event: "phx_reply", payload: payload} end + + # user broadcast + defp binary_decode(<< + 4::size(8), + topic_size::size(8), + user_event_size::size(8), + metadata_size::size(8), + user_payload_encoding::size(8), + topic::binary-size(topic_size), + user_event::binary-size(user_event_size), + metadata::binary-size(metadata_size), + user_payload::binary + >>) do + decoded_metadata = if metadata_size > 0, do: Jason.decode!(metadata), else: %{} + + decoded_payload = + case user_payload_encoding do + 1 -> Jason.decode!(user_payload) + 0 -> {:binary, user_payload} + end + + payload = %{ + "event" => user_event, + "payload" => decoded_payload, + "type" => "broadcast", + "meta" => decoded_metadata + } + + %Message{topic: topic, event: "broadcast", payload: payload} + end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 435f00ef8..030148c89 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,8 +1,33 @@ start_time = :os.system_time(:millisecond) alias Realtime.Api -alias Realtime.Database -ExUnit.start(exclude: [:failing], max_cases: 3, capture_log: true) +max_cases = String.to_integer(System.get_env("MAX_CASES", "4")) + +repo_config = Application.fetch_env!(:realtime, Realtime.Repo) + +{:ok, pg_conn} = + Postgrex.start_link( + hostname: repo_config[:hostname], + port: repo_config[:port] || 5432, + username: repo_config[:username], + password: repo_config[:password], + database: "postgres" + ) + +%{rows: [[pg_version_num]]} = Postgrex.query!(pg_conn, "SELECT current_setting('server_version_num')::int") + +%{rows: [[has_supautils_subscription_grants]]} = + Postgrex.query!(pg_conn, "SELECT current_setting('supautils.policy_grants', true) LIKE '%realtime.subscription%'") + +# `realtime.broadcast_changes(..., NEW record, OLD record, ...)` (introduced in commit 2922658c) called from a trigger via `PERFORM` fails on PG <= 14.5 +requires_pg_140006 = if pg_version_num < 140_006, do: :requires_pg_140006 + +# Restriction assertions on the postgres role only hold on builds where supautils.policy_grants includes realtime.subscription (supabase/postgres 15.14.1.018 or higher) +requires_supautils_policy_grants = if !has_supautils_subscription_grants, do: :requires_supautils_policy_grants + +exclude = [:failing, requires_pg_140006, requires_supautils_policy_grants] + +ExUnit.start(exclude: exclude, max_cases: max_cases, capture_log: true) max_cases = ExUnit.configuration()[:max_cases] @@ -10,53 +35,30 @@ Containers.pull() if System.get_env("REUSE_CONTAINERS") != "true" do Containers.stop_containers() - Containers.stop_container("dev_tenant") end {:ok, _pid} = Containers.start_link(max_cases) -for tenant <- Api.list_tenants(), do: Api.delete_tenant(tenant) - -tenant_name = "dev_tenant" -tenant = Containers.initialize(tenant_name) -publication = "supabase_realtime_test" - -# Start dev_realtime container to be used in integration tests -{:ok, conn} = Database.connect(tenant, "realtime_seed", :stop) - -Database.transaction(conn, fn db_conn -> - queries = [ - "DROP TABLE IF EXISTS public.test", - "DROP PUBLICATION IF EXISTS #{publication}", - "create sequence if not exists test_id_seq;", - """ - create table "public"."test" ( - "id" int4 not null default nextval('test_id_seq'::regclass), - "details" text, - primary key ("id")); - """, - "grant all on table public.test to anon;", - "grant all on table public.test to postgres;", - "grant all on table public.test to authenticated;", - "create publication #{publication} for all tables" - ] - - Enum.each(queries, &Postgrex.query!(db_conn, &1, [])) -end) +for tenant <- Api.list_tenants(), do: Api.delete_tenant_by_external_id(tenant.external_id) Ecto.Adapters.SQL.Sandbox.mode(Realtime.Repo, :manual) -end_time = :os.system_time(:millisecond) -IO.puts("[test_helper.exs] Time to start tests: #{end_time - start_time} ms") - Mimic.copy(:syn) +Mimic.copy(Ecto.Migrator) +Mimic.copy(Extensions.PostgresCdcRls) +Mimic.copy(Extensions.PostgresCdcRls.Replications) +Mimic.copy(Extensions.PostgresCdcRls.Subscriptions) +Mimic.copy(Realtime.Database) +Mimic.copy(Realtime.FeatureFlags) Mimic.copy(Realtime.GenCounter) +Mimic.copy(Realtime.GenRpc) Mimic.copy(Realtime.Nodes) +Mimic.copy(Realtime.Repo.Replica) Mimic.copy(Realtime.RateCounter) Mimic.copy(Realtime.Tenants.Authorization) Mimic.copy(Realtime.Tenants.Cache) +Mimic.copy(Realtime.Tenants.Repo) Mimic.copy(Realtime.Tenants.Connect) -Mimic.copy(Realtime.Database) Mimic.copy(Realtime.Tenants.Migrations) Mimic.copy(Realtime.Tenants.Rebalancer) Mimic.copy(Realtime.Tenants.ReplicationConnection) @@ -64,3 +66,14 @@ Mimic.copy(RealtimeWeb.ChannelsAuthorization) Mimic.copy(RealtimeWeb.Endpoint) Mimic.copy(RealtimeWeb.JwtVerification) Mimic.copy(RealtimeWeb.TenantBroadcaster) +Mimic.copy(NimbleZTA.Cloudflare) + +partition = System.get_env("MIX_TEST_PARTITION") +node_name = if partition, do: :"main#{partition}@127.0.0.1", else: :"main@127.0.0.1" +:net_kernel.start([node_name]) +region = Application.get_env(:realtime, :region) +[{pid, _}] = :syn.members(RegionNodes, region) +:syn.update_member(RegionNodes, region, pid, fn _ -> [node: node()] end) + +end_time = :os.system_time(:millisecond) +IO.puts("[test_helper.exs] Time to start tests: #{end_time - start_time} ms")