From bfbed722fc636397cbf0b9ebd88a929dc01dc714 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 28 Aug 2026 14:22:59 +0200 Subject: [PATCH 1/6] test: add end-to-end tests against a local Supabase stack Add an opt-in e2e suite that runs the adapter against a real local Supabase (PostgREST + Realtime), booted via the vendored `supabase` CLI. A Vitest `projects` split keeps `pnpm test` unit-only and Docker-free while `pnpm test:e2e` self-boots only the containers the adapter needs (db, kong, rest, realtime, auth), seeds via the service-role admin client, and tears the stack down afterwards. The smoke test covers a PostgREST read, an insert, and a realtime event, and a CI workflow runs both suites. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e.yml | 44 ++ package.json | 6 +- pnpm-lock.yaml | 135 ++++++ supabase/.gitignore | 8 + supabase/config.toml | 423 ++++++++++++++++++ .../migrations/00000000000000_e2e_schema.sql | 68 +++ tests/e2e/e2e.utils.ts | 73 +++ tests/e2e/global-setup.ts | 155 +++++++ tests/e2e/setup.ts | 39 ++ tests/e2e/smoke.test.ts | 108 +++++ vitest.config.ts | 28 ++ 11 files changed, 1086 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/00000000000000_e2e_schema.sql create mode 100644 tests/e2e/e2e.utils.ts create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/setup.ts create mode 100644 tests/e2e/smoke.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..8c9c1dc --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + # Fast, Docker-free checks. + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm check + - run: pnpm typecheck + - run: pnpm test + + # End-to-end tests against a real, local Supabase stack. + # + # The vendored `supabase` CLI (a dev dependency) is installed by `pnpm install`, + # and the e2e global setup boots only the containers the adapter needs + # (db, kong, rest, realtime; see supabase/config.toml) and stops them afterwards. + # Ubuntu runners ship with Docker, so no extra setup is required. + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:e2e diff --git a/package.json b/package.json index 29a6c55..23c6485 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,10 @@ "scripts": { "build": "tsdown", "dev": "tsdown --watch", - "test": "vitest", + "test": "vitest run --project unit", + "test:watch": "vitest --project unit", + "test:e2e": "vitest run --project e2e", + "test:all": "vitest run", "typecheck": "tsc --noEmit", "prepublishOnly": "pnpm run build", "check": "ultracite check", @@ -44,6 +47,7 @@ "@biomejs/biome": "2.4.7", "@types/node": "^25.0.3", "bumpp": "^10.3.2", + "supabase": "^2.116.0", "tsdown": "^0.18.1", "typescript": "^5.9.3", "ultracite": "7.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0faeb6..bb8a274 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: bumpp: specifier: ^10.3.2 version: 10.4.0 + supabase: + specifier: ^2.116.0 + version: 2.116.0 tsdown: specifier: ^0.18.1 version: 0.18.4(typescript@5.9.3) @@ -138,6 +141,12 @@ packages: '@clack/prompts@1.1.0': resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} + '@ecies/ciphers@0.2.6': + resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==} + engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} + peerDependencies: + '@noble/ciphers': ^1.0.0 + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -319,6 +328,18 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@oxc-project/types@0.103.0': resolution: {integrity: sha512-bkiYX5kaXWwUessFRSoXFkGIQTmc6dLGdxuRTrC+h8PSnIdZyuXHHlLAeTmOue5Br/a0/a7dHH0Gca6eXn9MKg==} @@ -641,6 +662,50 @@ packages: resolution: {integrity: sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==} engines: {node: '>=20.0.0'} + '@supabase/cli-darwin-arm64@2.116.0': + resolution: {integrity: sha512-Mvfxf5q7oQ1KR59ndFFyGkh12IfwKH5ZOv7OWtHsFkBuwHtHiJgY6Zwd3w09tnat4spkpDTFavclBlLsOQnh2A==} + cpu: [arm64] + os: [darwin] + + '@supabase/cli-darwin-x64@2.116.0': + resolution: {integrity: sha512-dxKmIPcVunC8sPTuU+eVWj2SOB5tLoRTE5FX6J/KMZhGH03khTn6ptHvaanZp0YwaACbm//uoffUlJKZrAgt0w==} + cpu: [x64] + os: [darwin] + + '@supabase/cli-linux-arm64-musl@2.116.0': + resolution: {integrity: sha512-6lYrbKFJT5NKbEKGBJTArEc1F3oMfWxnQeq8+RZ4wSLjCq4uwluh6+fzKCsLVxZgPOg4r+RZRqDdb+/cLi0yyg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@supabase/cli-linux-arm64@2.116.0': + resolution: {integrity: sha512-ZmV96NQqcgx1MH4jWdfyqqjLghy57mRI5bysy6lM7MezsirQh+eXaOdWI0xCy7r7FA09k2fKLGh+r7r0X3mxBg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@supabase/cli-linux-x64-musl@2.116.0': + resolution: {integrity: sha512-EtPJPHUvLHvXHkvZHAEr+i6w/bDVm5BOPD+09uXgUffsUbNAzfZ8r7Fb94+SfWI+dQwivw4WmijsX7tlx61Zcg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@supabase/cli-linux-x64@2.116.0': + resolution: {integrity: sha512-o0PvHKyQSKEuC3jJqeV2qorgyMIFGDWQ1Bj+OXf0p80ddgktnJFlDElCU+VDKZkuwLC6vO/LMoBql34zFHzXhw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@supabase/cli-windows-arm64@2.116.0': + resolution: {integrity: sha512-IiglNMXXssDiZbeSRvixYH7eYDDvhiEa2CrOSj419jO5vLrMKvzi1ATxe8E4i7MpKuI9S5U/3tA3rFlIMHtwrg==} + cpu: [arm64] + os: [win32] + + '@supabase/cli-windows-x64@2.116.0': + resolution: {integrity: sha512-pz4zNDs3KCEx0l9JS9Xaiuzd5WXrISajVlBSxC5/2Jyo2+g+N/ftQJDYTHQ6Jir5fNelIqSHIXZelmGds14upw==} + cpu: [x64] + os: [win32] + '@supabase/functions-js@2.107.0': resolution: {integrity: sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==} engines: {node: '>=20.0.0'} @@ -822,6 +887,10 @@ packages: oxc-resolver: optional: true + eciesjs@0.5.0: + resolution: {integrity: sha512-s0J9SEVYAEPg7J63GFMApLYzPH9VNIQIyC6s15JpnqVc0TqcKWdbgFlnAweEBRyMmko2dcs2sfC83Hj4J43tuA==} + engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} + empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} @@ -892,6 +961,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1037,6 +1109,10 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + supabase@2.116.0: + resolution: {integrity: sha512-cMUHkpjBacq4oLGWnMM2HC2drmUlAlfN/PQb31RARoIdYJ8sqA0xONvqBR6yd5v7w8dXuCPwvfd4N1NTHjBKEw==} + hasBin: true + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1268,6 +1344,10 @@ snapshots: '@clack/core': 1.1.0 sisteransi: 1.0.5 + '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': + dependencies: + '@noble/ciphers': 1.3.0 + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -1383,6 +1463,14 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + '@oxc-project/types@0.103.0': {} '@oxc-project/types@0.110.0': {} @@ -1558,6 +1646,30 @@ snapshots: dependencies: tslib: 2.8.1 + '@supabase/cli-darwin-arm64@2.116.0': + optional: true + + '@supabase/cli-darwin-x64@2.116.0': + optional: true + + '@supabase/cli-linux-arm64-musl@2.116.0': + optional: true + + '@supabase/cli-linux-arm64@2.116.0': + optional: true + + '@supabase/cli-linux-x64-musl@2.116.0': + optional: true + + '@supabase/cli-linux-x64@2.116.0': + optional: true + + '@supabase/cli-windows-arm64@2.116.0': + optional: true + + '@supabase/cli-windows-x64@2.116.0': + optional: true + '@supabase/functions-js@2.107.0': dependencies: tslib: 2.8.1 @@ -1747,6 +1859,13 @@ snapshots: dts-resolver@2.1.3: {} + eciesjs@0.5.0: + dependencies: + '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + empathic@2.0.0: {} es-module-lexer@1.7.0: {} @@ -1826,6 +1945,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.10: {} + jsesc@3.1.0: {} jsonc-parser@3.3.1: {} @@ -1999,6 +2120,20 @@ snapshots: std-env@3.10.0: {} + supabase@2.116.0: + dependencies: + eciesjs: 0.5.0 + jose: 6.2.10 + optionalDependencies: + '@supabase/cli-darwin-arm64': 2.116.0 + '@supabase/cli-darwin-x64': 2.116.0 + '@supabase/cli-linux-arm64': 2.116.0 + '@supabase/cli-linux-arm64-musl': 2.116.0 + '@supabase/cli-linux-x64': 2.116.0 + '@supabase/cli-linux-x64-musl': 2.116.0 + '@supabase/cli-windows-arm64': 2.116.0 + '@supabase/cli-windows-x64': 2.116.0 + tinybench@2.9.0: {} tinyexec@1.0.2: {} diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..b75c2d1 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,423 @@ +# 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 = "tanstack-db" + +[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 +# Controls whether new tables, views, sequences and functions created in the `public` schema by +# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) +# without explicit GRANTs. When unset, new entities are NOT auto-exposed, matching the new cloud +# default. Set to `true` to keep the legacy behaviour of auto-exposing new entities; this is +# deprecated and the field is removed on 2026-10-30 once the always-revoked behaviour is permanent. +# auto_expose_new_tables = true + +[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] +# Disabled: the e2e suite only needs db, kong, rest, and realtime. +enabled = false +# 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] +# Disabled: the e2e suite only needs db, kong, rest, and realtime. +enabled = false +# 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] +# Disabled: the e2e suite only needs db, kong, rest, and realtime. +enabled = false +# 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 +[storage.vector] +enabled = true +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +# Kept enabled so `supabase status` can report the anon key the e2e suite uses; +# together with db, kong, rest, and realtime this is the minimal set the adapter +# needs. All other services (studio, storage, analytics, inbucket, edge runtime) +# are disabled above. +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" +# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. +# external_url = "" +# 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 auth.external_url. +# 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 = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[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 callback URL derived from auth.external_url. +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] +# Disabled: the e2e suite only needs db, kong, rest, and realtime. +enabled = false +# 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] +# Disabled: the e2e suite only needs db, kong, rest, and realtime. +enabled = false +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)" + +# pg-delta is the schema diff engine for db diff / db pull / db remote commit. +# Set enabled = false to fall back to the legacy migra engine. +[experimental.pgdelta] +enabled = true +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./database" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" diff --git a/supabase/migrations/00000000000000_e2e_schema.sql b/supabase/migrations/00000000000000_e2e_schema.sql new file mode 100644 index 0000000..c4f69b0 --- /dev/null +++ b/supabase/migrations/00000000000000_e2e_schema.sql @@ -0,0 +1,68 @@ +-- E2E test schema for @supabase-labs/tanstack-db. +-- +-- Column types mirror the Zod schemas in tests/test.utils.ts (ids are numeric, +-- so bigint identity keeps z.number() valid). This schema is intended for the +-- local Supabase stack the e2e suite boots — it is NOT a production schema. + +create table public.users ( + id bigint generated by default as identity primary key, + name text not null, + email text not null, + active boolean not null default true +); + +create table public.todos ( + id bigint generated by default as identity primary key, + title text not null, + description text not null default '', + completed boolean not null default false +); + +create table public.users_todos ( + user_id bigint not null references public.users (id) on delete cascade, + todo_id bigint not null references public.todos (id) on delete cascade, + primary key (user_id, todo_id) +); + +-- Realtime: publish the tables so postgres_changes events fire for subscribers. +alter publication supabase_realtime add table public.users; +alter publication supabase_realtime add table public.todos; +alter publication supabase_realtime add table public.users_todos; + +-- REPLICA IDENTITY FULL so DELETE payloads carry the key columns in `old` +-- (the adapter derives the collection key from payload.old on delete). +alter table public.users replica identity full; +alter table public.todos replica identity full; +alter table public.users_todos replica identity full; + +-- Test-only posture: enable RLS with fully permissive policies. RLS must be ON +-- (not disabled) because Realtime's Postgres Changes uses SELECT policies to +-- decide what to broadcast — with RLS off, anon subscribers receive nothing. +-- The permissive policies let the anon key both CRUD via PostgREST and receive +-- every change. Do NOT copy this posture into a real application. +alter table public.users enable row level security; +alter table public.todos enable row level security; +alter table public.users_todos enable row level security; + +create policy "e2e allow all" on public.users + for all to public using (true) with check (true); +create policy "e2e allow all" on public.todos + for all to public using (true) with check (true); +create policy "e2e allow all" on public.users_todos + for all to public using (true) with check (true); + +-- Reset helper called by the test setup (via the service_role key) between +-- tests. TRUNCATE has no PostgREST endpoint, so it is wrapped in a function. +-- security definer runs it as the owner; execute is restricted to service_role. +create or replace function public.reset_e2e() +returns void +language sql +security definer +set search_path = '' +as $$ + truncate table public.users_todos, public.todos, public.users + restart identity cascade; +$$; + +revoke execute on function public.reset_e2e() from public, anon, authenticated; +grant execute on function public.reset_e2e() to service_role; diff --git a/tests/e2e/e2e.utils.ts b/tests/e2e/e2e.utils.ts new file mode 100644 index 0000000..e4aae53 --- /dev/null +++ b/tests/e2e/e2e.utils.ts @@ -0,0 +1,73 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec" +import { createClient, type SupabaseClient } from "@supabase/supabase-js" +import { createCollection } from "@tanstack/db" +import { QueryClient } from "@tanstack/query-core" +import { supabaseCollectionOptions } from "../../src/index" +import { todosSchema, usersSchema, usersTodosSchema } from "../test.utils" + +type E2eEnv = { + url: string + anonKey: string +} + +export const getEnv = (): E2eEnv => { + const url = process.env.SUPABASE_URL + const anonKey = process.env.SUPABASE_ANON_KEY + + if (!(url && anonKey)) { + throw new Error( + "Missing e2e environment (SUPABASE_URL / SUPABASE_ANON_KEY). The global setup should populate these." + ) + } + + return { url, anonKey } +} + +// A fresh Supabase client. Each realtime "other user" write in the tests uses a +// separate client so the change arrives purely over realtime. +export const makeSupabase = (): SupabaseClient => { + const { url, anonKey } = getEnv() + return createClient(url, anonKey) +} + +type CollectionOptions = { + realtime?: boolean +} + +// Each collection gets its own QueryClient: the realtime channel registry in +// src/db.ts is keyed by QueryClient, so a fresh one per collection prevents +// channels leaking across tests. +const buildCollection = ( + tableName: string, + keys: Array & string>, + schema: TSchema, + { realtime = false }: CollectionOptions +) => { + const supabase = makeSupabase() + const queryClient = new QueryClient() + const collection = createCollection( + supabaseCollectionOptions({ + tableName, + keys, + schema, + supabase, + queryClient, + realtime, + }) + ) + return { collection, supabase, queryClient } +} + +export const makeUsersCollection = (options: CollectionOptions = {}) => + buildCollection("users", ["id"], usersSchema, options) + +export const makeTodosCollection = (options: CollectionOptions = {}) => + buildCollection("todos", ["id"], todosSchema, options) + +export const makeUsersTodosCollection = (options: CollectionOptions = {}) => + buildCollection( + "users_todos", + ["user_id", "todo_id"], + usersTodosSchema, + options + ) diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..da5638e --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,155 @@ +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { createClient } from "@supabase/supabase-js" +import type { TestProject } from "vitest/node" + +const execFileAsync = promisify(execFile) + +// Generous enough for a cold CI runner to pull the container images. +const START_TIMEOUT_MS = 600_000 +const STOP_TIMEOUT_MS = 120_000 + +// Realtime warm-up: a freshly started realtime container needs a moment before +// its Postgres replication is fully wired, so the first subscription can miss +// changes. We subscribe and repeatedly insert a sentinel row until an event is +// actually delivered, guaranteeing realtime is live before any test runs. +const WARMUP_TIMEOUT_MS = 60_000 +const WARMUP_ATTEMPTS = 40 +const WARMUP_POKE_INTERVAL_MS = 1000 +const WARMUP_SENTINEL_ID = 2_000_000_000 + +const delay = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +// Values published to the e2e test workers via `inject(...)` (see setup.ts). +declare module "vitest" { + interface ProvidedContext { + supabaseAnonKey: string + supabaseServiceRoleKey: string + supabaseUrl: string + } +} + +type SupabaseStatus = { + API_URL?: string + ANON_KEY?: string + SERVICE_ROLE_KEY?: string +} + +const runSupabase = (args: string[], timeout: number) => + execFileAsync("pnpm", ["exec", "supabase", ...args], { + cwd: process.cwd(), + timeout, + }) + +// Returns the parsed `supabase status` JSON, or null when no stack is running. +const readStatus = async (): Promise => { + try { + const { stdout } = await runSupabase( + ["status", "-o", "json"], + STOP_TIMEOUT_MS + ) + return JSON.parse(stdout) as SupabaseStatus + } catch { + return null + } +} + +const warmUpRealtime = async (url: string, anonKey: string): Promise => { + const client = createClient(url, anonKey) + let received = false + + // supabase-js insert resolves with { error } instead of throwing, so failed + // pokes (e.g. a leftover sentinel) are simply retried on the next attempt. + const poke = async (): Promise => { + for (let attempt = 0; attempt < WARMUP_ATTEMPTS && !received; attempt++) { + await client.from("users").insert({ + id: WARMUP_SENTINEL_ID + attempt, + name: "warmup", + email: `warmup-${attempt}@e2e.local`, + active: true, + }) + await delay(WARMUP_POKE_INTERVAL_MS) + } + } + + try { + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error( + "Realtime warm-up timed out; is the realtime container healthy?" + ) + ), + WARMUP_TIMEOUT_MS + ) + client + .channel("e2e-warmup") + .on( + "postgres_changes", + { event: "INSERT", schema: "public", table: "users" }, + () => { + received = true + clearTimeout(timer) + resolve() + } + ) + .subscribe((status) => { + if (status === "SUBSCRIBED") { + poke().catch(() => undefined) + } + }) + }) + } finally { + await client.removeAllChannels() + await client.from("users").delete().gte("id", WARMUP_SENTINEL_ID) + } +} + +export default async function setup({ provide }: TestProject) { + let startedByUs = false + let url = process.env.SUPABASE_URL + let anonKey = process.env.SUPABASE_ANON_KEY + let serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + + // Only touch Docker when creds were not already supplied by the environment. + if (!(url && anonKey && serviceRoleKey)) { + let status = await readStatus() + + if (!status) { + // Boots only the containers the adapter needs (db, kong, rest, realtime); + // every other service is disabled in supabase/config.toml. + await runSupabase(["start"], START_TIMEOUT_MS) + startedByUs = true + status = await readStatus() + } + + if (!(status?.API_URL && status.ANON_KEY && status.SERVICE_ROLE_KEY)) { + throw new Error( + "Could not resolve local Supabase credentials. Is Docker running? Try `pnpm exec supabase start`." + ) + } + + url = status.API_URL + anonKey = status.ANON_KEY + serviceRoleKey = status.SERVICE_ROLE_KEY + } + + // Confirm realtime is actually delivering before tests rely on it. + await warmUpRealtime(url, anonKey) + + provide("supabaseUrl", url) + provide("supabaseAnonKey", anonKey) + provide("supabaseServiceRoleKey", serviceRoleKey) + + return async () => { + // Only stop the stack if this setup started it; never tear down a stack the + // developer (or CI) started separately. + if (startedByUs) { + await runSupabase(["stop"], STOP_TIMEOUT_MS) + } + } +} diff --git a/tests/e2e/setup.ts b/tests/e2e/setup.ts new file mode 100644 index 0000000..3394407 --- /dev/null +++ b/tests/e2e/setup.ts @@ -0,0 +1,39 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js" +import { beforeEach, inject } from "vitest" + +// Bridge the anon credentials published by the global setup into the environment +// so the shared helpers (e2e.utils.ts) can read them from process.env. +process.env.SUPABASE_URL = inject("supabaseUrl") +process.env.SUPABASE_ANON_KEY = inject("supabaseAnonKey") + +// Data setup uses the service_role (admin) client, which bypasses RLS. +const admin: SupabaseClient = createClient( + inject("supabaseUrl"), + inject("supabaseServiceRoleKey"), + { auth: { persistSession: false } } +) + +const assertNoError = (label: string, error: { message: string } | null) => { + if (error) { + throw new Error(`e2e seed: ${label} failed: ${error.message}`) + } +} + +// Reset to a known, deterministic fixture before every test. reset_e2e() runs +// TRUNCATE ... RESTART IDENTITY, so seeded rows always start at id 1. +beforeEach(async () => { + const { error: resetError } = await admin.rpc("reset_e2e") + assertNoError("reset", resetError) + + const { error: usersError } = await admin.from("users").insert([ + { name: "Alice", email: "alice@test.com", active: true }, + { name: "Bob", email: "bob@test.com", active: false }, + ]) + assertNoError("seed users", usersError) + + const { error: todosError } = await admin.from("todos").insert([ + { title: "Buy milk", description: "From the store", completed: false }, + { title: "Walk dog", description: "Around the block", completed: true }, + ]) + assertNoError("seed todos", todosError) +}) diff --git a/tests/e2e/smoke.test.ts b/tests/e2e/smoke.test.ts new file mode 100644 index 0000000..27778d9 --- /dev/null +++ b/tests/e2e/smoke.test.ts @@ -0,0 +1,108 @@ +import { createCollection, liveQueryCollectionOptions } from "@tanstack/db" +import { expect, test, vi } from "vitest" +import { makeSupabase, makeUsersCollection } from "./e2e.utils" + +const WAIT = { timeout: 15_000, interval: 200 } as const + +type UsersCollection = ReturnType["collection"] + +// An on-demand collection only loads when a live query drives demand. This live +// query also creates the active query that the realtime channel attaches to. +const liveUsers = (base: UsersCollection) => { + const options = liveQueryCollectionOptions({ + query: (q) => + q.from({ row: base }).select(({ row }) => ({ + id: row.id, + name: row.name, + email: row.email, + active: row.active, + })), + }) + return createCollection( + options as Extract + ) +} + +test("reads seeded rows through PostgREST", async () => { + const { collection } = makeUsersCollection() + const live = liveUsers(collection) + + await live.preload() + await vi.waitFor(() => expect(live.size).toBe(2), WAIT) + + const names = live.toArray.map((user) => user.name).sort() + expect(names).toEqual(["Alice", "Bob"]) + + await live.cleanup() + await collection.cleanup() +}) + +test("inserts a row through PostgREST and reflects the server row", async () => { + const { collection, supabase } = makeUsersCollection() + const live = liveUsers(collection) + + await live.preload() + await vi.waitFor(() => expect(live.size).toBe(2), WAIT) + + // The adapter POSTs the row, then writes the server's returned row back into + // the collection; the live query reflects it. + const tx = collection.insert({ + id: 100, + name: "Carol", + email: "carol@test.com", + active: true, + }) + await tx.isPersisted.promise + + await vi.waitFor( + () => expect(live.toArray.some((user) => user.name === "Carol")).toBe(true), + WAIT + ) + const carol = live.toArray.find((user) => user.name === "Carol") + expect(carol?.id).toBe(100) + + // The row really exists in the database. + const { data, error } = await supabase + .from("users") + .select("*") + .eq("email", "carol@test.com") + .single() + expect(error).toBeNull() + expect(data?.name).toBe("Carol") + + await live.cleanup() + await collection.cleanup() +}) + +test("receives realtime inserts made by another client", async () => { + const { collection, supabase } = makeUsersCollection({ realtime: true }) + const live = liveUsers(collection) + + await live.preload() + await vi.waitFor(() => expect(live.size).toBe(2), WAIT) + + // Wait until the realtime channel is actually joined before writing, otherwise + // the change could be published before the subscription is established. + await vi.waitFor(() => { + const channel = supabase + .getChannels() + .find((c) => c.topic === "realtime:users") + expect(channel?.state).toBe("joined") + }, WAIT) + + // A second client acts as "another user" so the change arrives over realtime. + const other = makeSupabase() + const draft = { id: 200, name: "Zoe", email: "zoe@test.com", active: true } + const { error } = await other.from("users").insert(draft as unknown as never) + expect(error).toBeNull() + + await vi.waitFor( + () => expect(live.toArray.some((user) => user.name === "Zoe")).toBe(true), + WAIT + ) + + await live.cleanup() + await collection.cleanup() + await supabase.removeAllChannels() + await other.removeAllChannels() +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..094c0b7 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + projects: [ + { + test: { + name: "unit", + include: ["tests/**/*.test.ts"], + exclude: ["tests/e2e/**"], + }, + }, + { + test: { + name: "e2e", + include: ["tests/e2e/**/*.test.ts"], + globalSetup: ["tests/e2e/global-setup.ts"], + setupFiles: ["tests/e2e/setup.ts"], + hookTimeout: 120_000, + testTimeout: 30_000, + // The e2e tests share a single database that is truncated between + // tests, so files must not run in parallel against it. + fileParallelism: false, + }, + }, + ], + }, +}) From 14eb49ef4c12ba74e02f2077d61ae40cc38e017f Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 28 Aug 2026 14:28:44 +0200 Subject: [PATCH 2/6] Remove replica identity full from all tables. --- .../migrations/00000000000000_e2e_schema.sql | 67 +++++++++++-------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/supabase/migrations/00000000000000_e2e_schema.sql b/supabase/migrations/00000000000000_e2e_schema.sql index c4f69b0..e1166d2 100644 --- a/supabase/migrations/00000000000000_e2e_schema.sql +++ b/supabase/migrations/00000000000000_e2e_schema.sql @@ -3,7 +3,6 @@ -- Column types mirror the Zod schemas in tests/test.utils.ts (ids are numeric, -- so bigint identity keeps z.number() valid). This schema is intended for the -- local Supabase stack the e2e suite boots — it is NOT a production schema. - create table public.users ( id bigint generated by default as identity primary key, name text not null, @@ -25,44 +24,54 @@ create table public.users_todos ( ); -- Realtime: publish the tables so postgres_changes events fire for subscribers. -alter publication supabase_realtime add table public.users; -alter publication supabase_realtime add table public.todos; -alter publication supabase_realtime add table public.users_todos; +alter publication supabase_realtime +add + table public.users; + +alter publication supabase_realtime +add + table public.todos; --- REPLICA IDENTITY FULL so DELETE payloads carry the key columns in `old` --- (the adapter derives the collection key from payload.old on delete). -alter table public.users replica identity full; -alter table public.todos replica identity full; -alter table public.users_todos replica identity full; +alter publication supabase_realtime +add + table public.users_todos; -- Test-only posture: enable RLS with fully permissive policies. RLS must be ON -- (not disabled) because Realtime's Postgres Changes uses SELECT policies to -- decide what to broadcast — with RLS off, anon subscribers receive nothing. -- The permissive policies let the anon key both CRUD via PostgREST and receive -- every change. Do NOT copy this posture into a real application. -alter table public.users enable row level security; -alter table public.todos enable row level security; -alter table public.users_todos enable row level security; +alter table + public.users enable row level security; + +alter table + public.todos enable row level security; + +alter table + public.users_todos enable row level security; -create policy "e2e allow all" on public.users - for all to public using (true) with check (true); -create policy "e2e allow all" on public.todos - for all to public using (true) with check (true); -create policy "e2e allow all" on public.users_todos - for all to public using (true) with check (true); +create policy "e2e allow all" on public.users for all to public using (true) with check (true); + +create policy "e2e allow all" on public.todos for all to public using (true) with check (true); + +create policy "e2e allow all" on public.users_todos for all to public using (true) with check (true); -- Reset helper called by the test setup (via the service_role key) between -- tests. TRUNCATE has no PostgREST endpoint, so it is wrapped in a function. -- security definer runs it as the owner; execute is restricted to service_role. -create or replace function public.reset_e2e() -returns void -language sql -security definer -set search_path = '' -as $$ - truncate table public.users_todos, public.todos, public.users - restart identity cascade; -$$; +create +or replace function public.reset_e2e() returns void language sql security definer +set + search_path = '' as $ $ truncate table public.users_todos, + public.todos, + public.users restart identity cascade; + +$ $; + +revoke execute on function public.reset_e2e() +from + public, + anon, + authenticated; -revoke execute on function public.reset_e2e() from public, anon, authenticated; -grant execute on function public.reset_e2e() to service_role; +grant execute on function public.reset_e2e() to service_role; \ No newline at end of file From 0427f0932b863e93199be9adb6a3489a808a14a8 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 28 Aug 2026 14:34:06 +0200 Subject: [PATCH 3/6] Save the migration unformatted. --- .../migrations/00000000000000_e2e_schema.sql | 65 +++++++------------ 1 file changed, 25 insertions(+), 40 deletions(-) diff --git a/supabase/migrations/00000000000000_e2e_schema.sql b/supabase/migrations/00000000000000_e2e_schema.sql index e1166d2..20ebfc6 100644 --- a/supabase/migrations/00000000000000_e2e_schema.sql +++ b/supabase/migrations/00000000000000_e2e_schema.sql @@ -3,6 +3,7 @@ -- Column types mirror the Zod schemas in tests/test.utils.ts (ids are numeric, -- so bigint identity keeps z.number() valid). This schema is intended for the -- local Supabase stack the e2e suite boots — it is NOT a production schema. + create table public.users ( id bigint generated by default as identity primary key, name text not null, @@ -24,54 +25,38 @@ create table public.users_todos ( ); -- Realtime: publish the tables so postgres_changes events fire for subscribers. -alter publication supabase_realtime -add - table public.users; - -alter publication supabase_realtime -add - table public.todos; - -alter publication supabase_realtime -add - table public.users_todos; +alter publication supabase_realtime add table public.users; +alter publication supabase_realtime add table public.todos; +alter publication supabase_realtime add table public.users_todos; -- Test-only posture: enable RLS with fully permissive policies. RLS must be ON -- (not disabled) because Realtime's Postgres Changes uses SELECT policies to -- decide what to broadcast — with RLS off, anon subscribers receive nothing. -- The permissive policies let the anon key both CRUD via PostgREST and receive -- every change. Do NOT copy this posture into a real application. -alter table - public.users enable row level security; - -alter table - public.todos enable row level security; - -alter table - public.users_todos enable row level security; +alter table public.users enable row level security; +alter table public.todos enable row level security; +alter table public.users_todos enable row level security; -create policy "e2e allow all" on public.users for all to public using (true) with check (true); - -create policy "e2e allow all" on public.todos for all to public using (true) with check (true); - -create policy "e2e allow all" on public.users_todos for all to public using (true) with check (true); +create policy "e2e allow all" on public.users + for all to public using (true) with check (true); +create policy "e2e allow all" on public.todos + for all to public using (true) with check (true); +create policy "e2e allow all" on public.users_todos + for all to public using (true) with check (true); -- Reset helper called by the test setup (via the service_role key) between -- tests. TRUNCATE has no PostgREST endpoint, so it is wrapped in a function. -- security definer runs it as the owner; execute is restricted to service_role. -create -or replace function public.reset_e2e() returns void language sql security definer -set - search_path = '' as $ $ truncate table public.users_todos, - public.todos, - public.users restart identity cascade; - -$ $; - -revoke execute on function public.reset_e2e() -from - public, - anon, - authenticated; - -grant execute on function public.reset_e2e() to service_role; \ No newline at end of file +create or replace function public.reset_e2e() +returns void +language sql +security definer +set search_path = '' +as $$ + truncate table public.users_todos, public.todos, public.users + restart identity cascade; +$$; + +revoke execute on function public.reset_e2e() from public, anon, authenticated; +grant execute on function public.reset_e2e() to service_role; From 563d7b13b7ee8702d77a3ee84a408dc01eddf46e Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 28 Aug 2026 14:48:10 +0200 Subject: [PATCH 4/6] test: add e2e coverage for filters, update/delete, and realtime update/delete Add reads (WHERE pushdown + queryOnce), mutations (update/delete via PostgREST), and realtime (update/delete from another client, the delete exercising REPLICA IDENTITY FULL) tests. Extract the shared liveUsers and WAIT helpers into e2e.utils so all e2e files reuse them. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/e2e.utils.ts | 27 ++++++++++++++- tests/e2e/mutations.test.ts | 60 ++++++++++++++++++++++++++++++++ tests/e2e/reads.test.ts | 42 ++++++++++++++++++++++ tests/e2e/realtime.test.ts | 69 +++++++++++++++++++++++++++++++++++++ tests/e2e/smoke.test.ts | 24 +------------ 5 files changed, 198 insertions(+), 24 deletions(-) create mode 100644 tests/e2e/mutations.test.ts create mode 100644 tests/e2e/reads.test.ts create mode 100644 tests/e2e/realtime.test.ts diff --git a/tests/e2e/e2e.utils.ts b/tests/e2e/e2e.utils.ts index e4aae53..3c86fa2 100644 --- a/tests/e2e/e2e.utils.ts +++ b/tests/e2e/e2e.utils.ts @@ -1,6 +1,6 @@ import type { StandardSchemaV1 } from "@standard-schema/spec" import { createClient, type SupabaseClient } from "@supabase/supabase-js" -import { createCollection } from "@tanstack/db" +import { createCollection, liveQueryCollectionOptions } from "@tanstack/db" import { QueryClient } from "@tanstack/query-core" import { supabaseCollectionOptions } from "../../src/index" import { todosSchema, usersSchema, usersTodosSchema } from "../test.utils" @@ -71,3 +71,28 @@ export const makeUsersTodosCollection = (options: CollectionOptions = {}) => usersTodosSchema, options ) + +// Shared polling window for awaiting async PostgREST/realtime propagation. +export const WAIT = { timeout: 15_000, interval: 200 } as const + +export type UsersCollection = ReturnType< + typeof makeUsersCollection +>["collection"] + +// An on-demand collection only loads when a live query drives demand. This live +// query also creates the active query that the realtime channel attaches to, so +// tests subscribe through it and read the results back from `live`. +export const liveUsers = (base: UsersCollection) => { + const options = liveQueryCollectionOptions({ + query: (q) => + q.from({ row: base }).select(({ row }) => ({ + id: row.id, + name: row.name, + email: row.email, + active: row.active, + })), + }) + return createCollection( + options as Extract + ) +} diff --git a/tests/e2e/mutations.test.ts b/tests/e2e/mutations.test.ts new file mode 100644 index 0000000..64d1c2c --- /dev/null +++ b/tests/e2e/mutations.test.ts @@ -0,0 +1,60 @@ +import { expect, test, vi } from "vitest" +import { liveUsers, makeUsersCollection, WAIT } from "./e2e.utils" + +test("updates a row through PostgREST and reflects the server row", async () => { + const { collection, supabase } = makeUsersCollection() + const live = liveUsers(collection) + + await live.preload() + await vi.waitFor(() => expect(live.size).toBe(2), WAIT) + + const alice = live.toArray.find((user) => user.name === "Alice") + const key = String(alice?.id) + + const tx = collection.update(key, (draft) => { + draft.name = "Alice Updated" + }) + await tx.isPersisted.promise + + await vi.waitFor( + () => + expect(live.toArray.find((user) => user.id === alice?.id)?.name).toBe( + "Alice Updated" + ), + WAIT + ) + + const { data, error } = await supabase + .from("users") + .select("*") + .eq("id", alice?.id) + .single() + expect(error).toBeNull() + expect(data?.name).toBe("Alice Updated") + + await live.cleanup() + await collection.cleanup() +}) + +test("deletes a row through PostgREST", async () => { + const { collection, supabase } = makeUsersCollection() + const live = liveUsers(collection) + + await live.preload() + await vi.waitFor(() => expect(live.size).toBe(2), WAIT) + + const bob = live.toArray.find((user) => user.name === "Bob") + const tx = collection.delete(String(bob?.id)) + await tx.isPersisted.promise + + await vi.waitFor( + () => expect(live.toArray.some((user) => user.id === bob?.id)).toBe(false), + WAIT + ) + + const { data } = await supabase.from("users").select("*").eq("id", bob?.id) + expect(data).toEqual([]) + + await live.cleanup() + await collection.cleanup() +}) diff --git a/tests/e2e/reads.test.ts b/tests/e2e/reads.test.ts new file mode 100644 index 0000000..41f5b8e --- /dev/null +++ b/tests/e2e/reads.test.ts @@ -0,0 +1,42 @@ +import { createCollection, eq, liveQueryCollectionOptions } from "@tanstack/db" +import { expect, test, vi } from "vitest" +import { queryOnce } from "../../src/index" +import { makeUsersCollection, WAIT } from "./e2e.utils" + +test("pushes a WHERE filter down to PostgREST", async () => { + const { collection } = makeUsersCollection() + + // Seeded fixture: Alice is active, Bob is not — so active=true returns one row. + const options = liveQueryCollectionOptions({ + query: (q) => + q + .from({ row: collection }) + .where(({ row }) => eq(row.active, true)) + .select(({ row }) => ({ id: row.id, name: row.name })), + }) + const live = createCollection( + options as Extract + ) + + await live.preload() + await vi.waitFor(() => expect(live.size).toBe(1), WAIT) + expect(live.toArray[0]?.name).toBe("Alice") + + await live.cleanup() + await collection.cleanup() +}) + +test("queryOnce runs a one-shot filtered query", async () => { + const { collection, supabase } = makeUsersCollection() + + const rows = await queryOnce( + (q) => + q.from({ user: collection }).where(({ user }) => eq(user.active, true)), + supabase + ) + + expect(rows).toHaveLength(1) + expect(rows[0]?.name).toBe("Alice") + + await collection.cleanup() +}) diff --git a/tests/e2e/realtime.test.ts b/tests/e2e/realtime.test.ts new file mode 100644 index 0000000..d7ba99c --- /dev/null +++ b/tests/e2e/realtime.test.ts @@ -0,0 +1,69 @@ +import type { SupabaseClient } from "@supabase/supabase-js" +import { expect, test, vi } from "vitest" +import { liveUsers, makeSupabase, makeUsersCollection, WAIT } from "./e2e.utils" + +// Waits until the adapter's realtime channel for `users` is actually joined, so +// changes written afterwards are guaranteed to be captured. +const waitForUsersChannel = (supabase: SupabaseClient) => + vi.waitFor(() => { + const channel = supabase + .getChannels() + .find((c) => c.topic === "realtime:users") + expect(channel?.state).toBe("joined") + }, WAIT) + +test("receives realtime updates made by another client", async () => { + const { collection, supabase } = makeUsersCollection({ realtime: true }) + const live = liveUsers(collection) + + await live.preload() + await vi.waitFor(() => expect(live.size).toBe(2), WAIT) + await waitForUsersChannel(supabase) + + const alice = live.toArray.find((user) => user.name === "Alice") + const other = makeSupabase() + const { error } = await other + .from("users") + .update({ name: "Alice Realtime" }) + .eq("id", alice?.id) + expect(error).toBeNull() + + await vi.waitFor( + () => + expect(live.toArray.find((user) => user.id === alice?.id)?.name).toBe( + "Alice Realtime" + ), + WAIT + ) + + await live.cleanup() + await collection.cleanup() + await supabase.removeAllChannels() + await other.removeAllChannels() +}) + +// Exercises the delete path, which relies on REPLICA IDENTITY FULL so the +// realtime payload carries the key columns in `old`. +test("receives realtime deletes made by another client", async () => { + const { collection, supabase } = makeUsersCollection({ realtime: true }) + const live = liveUsers(collection) + + await live.preload() + await vi.waitFor(() => expect(live.size).toBe(2), WAIT) + await waitForUsersChannel(supabase) + + const bob = live.toArray.find((user) => user.name === "Bob") + const other = makeSupabase() + const { error } = await other.from("users").delete().eq("id", bob?.id) + expect(error).toBeNull() + + await vi.waitFor( + () => expect(live.toArray.some((user) => user.id === bob?.id)).toBe(false), + WAIT + ) + + await live.cleanup() + await collection.cleanup() + await supabase.removeAllChannels() + await other.removeAllChannels() +}) diff --git a/tests/e2e/smoke.test.ts b/tests/e2e/smoke.test.ts index 27778d9..7553ae7 100644 --- a/tests/e2e/smoke.test.ts +++ b/tests/e2e/smoke.test.ts @@ -1,27 +1,5 @@ -import { createCollection, liveQueryCollectionOptions } from "@tanstack/db" import { expect, test, vi } from "vitest" -import { makeSupabase, makeUsersCollection } from "./e2e.utils" - -const WAIT = { timeout: 15_000, interval: 200 } as const - -type UsersCollection = ReturnType["collection"] - -// An on-demand collection only loads when a live query drives demand. This live -// query also creates the active query that the realtime channel attaches to. -const liveUsers = (base: UsersCollection) => { - const options = liveQueryCollectionOptions({ - query: (q) => - q.from({ row: base }).select(({ row }) => ({ - id: row.id, - name: row.name, - email: row.email, - active: row.active, - })), - }) - return createCollection( - options as Extract - ) -} +import { liveUsers, makeSupabase, makeUsersCollection, WAIT } from "./e2e.utils" test("reads seeded rows through PostgREST", async () => { const { collection } = makeUsersCollection() From 4313ba772c2e7e1be334e6c098fd98d87139d600 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 28 Aug 2026 14:49:25 +0200 Subject: [PATCH 5/6] docs: correct realtime delete comment after replica identity change The delete payload carries the key via the default replica identity (the collection key is the primary key), not REPLICA IDENTITY FULL. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/realtime.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/e2e/realtime.test.ts b/tests/e2e/realtime.test.ts index d7ba99c..ff1aee3 100644 --- a/tests/e2e/realtime.test.ts +++ b/tests/e2e/realtime.test.ts @@ -42,8 +42,9 @@ test("receives realtime updates made by another client", async () => { await other.removeAllChannels() }) -// Exercises the delete path, which relies on REPLICA IDENTITY FULL so the -// realtime payload carries the key columns in `old`. +// Exercises the delete path, where the adapter derives the collection key from +// the realtime payload's `old`. That works here because the collection key is +// the primary key, which the default replica identity always includes. test("receives realtime deletes made by another client", async () => { const { collection, supabase } = makeUsersCollection({ realtime: true }) const live = liveUsers(collection) From f375f45813797e6120ac168edc250f37b3e38d1c Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 28 Aug 2026 21:31:51 +0200 Subject: [PATCH 6/6] refactor: streamline e2e suite with fixtures and single-RPC seeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-test collection/live-query/cleanup ceremony with lazy test.extend fixtures whose teardown runs even when a test fails — trailing cleanup calls previously leaked websockets on assertion failure. Fold seeding into reset_e2e() (one RPC per test instead of three round trips), warm realtime up against a dedicated e2e_warmup table instead of sentinel rows in users, use createLiveQueryCollection to drop the singleResult cast, read creds via inject() instead of bridging through process.env, dissolve smoke.test.ts into the topic files, and raise the e2e testTimeout so the innermost failing waiter reports before the test times out. Co-Authored-By: Claude Fable 5 --- .../migrations/00000000000000_e2e_schema.sql | 21 ++- tests/e2e/e2e.utils.ts | 166 ++++++++++-------- tests/e2e/global-setup.ts | 31 ++-- tests/e2e/mutations.test.ts | 80 +++++---- tests/e2e/reads.test.ts | 54 +++--- tests/e2e/realtime.test.ts | 80 ++++----- tests/e2e/setup.ts | 38 +--- tests/e2e/smoke.test.ts | 86 --------- vitest.config.ts | 5 +- 9 files changed, 255 insertions(+), 306 deletions(-) delete mode 100644 tests/e2e/smoke.test.ts diff --git a/supabase/migrations/00000000000000_e2e_schema.sql b/supabase/migrations/00000000000000_e2e_schema.sql index 20ebfc6..47d48e4 100644 --- a/supabase/migrations/00000000000000_e2e_schema.sql +++ b/supabase/migrations/00000000000000_e2e_schema.sql @@ -24,10 +24,19 @@ create table public.users_todos ( primary key (user_id, todo_id) ); +-- Scratch table used only by the global-setup realtime warm-up: it inserts +-- rows here until a postgres_changes event is actually delivered, proving the +-- WAL -> realtime pipeline is live without touching the tables tests read. +-- Deliberately not truncated by reset_e2e(). +create table public.e2e_warmup ( + id bigint primary key +); + -- Realtime: publish the tables so postgres_changes events fire for subscribers. alter publication supabase_realtime add table public.users; alter publication supabase_realtime add table public.todos; alter publication supabase_realtime add table public.users_todos; +alter publication supabase_realtime add table public.e2e_warmup; -- Test-only posture: enable RLS with fully permissive policies. RLS must be ON -- (not disabled) because Realtime's Postgres Changes uses SELECT policies to @@ -37,6 +46,7 @@ alter publication supabase_realtime add table public.users_todos; alter table public.users enable row level security; alter table public.todos enable row level security; alter table public.users_todos enable row level security; +alter table public.e2e_warmup enable row level security; create policy "e2e allow all" on public.users for all to public using (true) with check (true); @@ -44,9 +54,13 @@ create policy "e2e allow all" on public.todos for all to public using (true) with check (true); create policy "e2e allow all" on public.users_todos for all to public using (true) with check (true); +create policy "e2e allow all" on public.e2e_warmup + for all to public using (true) with check (true); --- Reset helper called by the test setup (via the service_role key) between --- tests. TRUNCATE has no PostgREST endpoint, so it is wrapped in a function. +-- Reset-and-seed helper called by the test setup (via the service_role key) +-- before every test. TRUNCATE has no PostgREST endpoint, so it is wrapped in a +-- function; seeding here keeps the reset to a single transactional round trip. +-- restart identity guarantees Alice gets id 1 and Bob id 2 in every test. -- security definer runs it as the owner; execute is restricted to service_role. create or replace function public.reset_e2e() returns void @@ -56,6 +70,9 @@ set search_path = '' as $$ truncate table public.users_todos, public.todos, public.users restart identity cascade; + insert into public.users (name, email, active) values + ('Alice', 'alice@test.com', true), + ('Bob', 'bob@test.com', false); $$; revoke execute on function public.reset_e2e() from public, anon, authenticated; diff --git a/tests/e2e/e2e.utils.ts b/tests/e2e/e2e.utils.ts index 3c86fa2..89e7b79 100644 --- a/tests/e2e/e2e.utils.ts +++ b/tests/e2e/e2e.utils.ts @@ -1,98 +1,116 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec" +/** biome-ignore-all lint/correctness/noEmptyPattern: vitest fixtures without dependencies must destructure an empty context object */ import { createClient, type SupabaseClient } from "@supabase/supabase-js" -import { createCollection, liveQueryCollectionOptions } from "@tanstack/db" +import { createCollection, createLiveQueryCollection } from "@tanstack/db" import { QueryClient } from "@tanstack/query-core" +import { test as baseTest, expect, inject, vi } from "vitest" import { supabaseCollectionOptions } from "../../src/index" -import { todosSchema, usersSchema, usersTodosSchema } from "../test.utils" +import { usersSchema } from "../test.utils" -type E2eEnv = { - url: string - anonKey: string -} - -export const getEnv = (): E2eEnv => { - const url = process.env.SUPABASE_URL - const anonKey = process.env.SUPABASE_ANON_KEY - - if (!(url && anonKey)) { - throw new Error( - "Missing e2e environment (SUPABASE_URL / SUPABASE_ANON_KEY). The global setup should populate these." - ) - } - - return { url, anonKey } -} +// Shared polling window for awaiting async PostgREST/realtime propagation. +export const WAIT = { timeout: 15_000, interval: 200 } as const -// A fresh Supabase client. Each realtime "other user" write in the tests uses a -// separate client so the change arrives purely over realtime. -export const makeSupabase = (): SupabaseClient => { - const { url, anonKey } = getEnv() - return createClient(url, anonKey) -} - -type CollectionOptions = { - realtime?: boolean -} +// A fresh Supabase client using the anon key, exactly like a browser would. +const makeSupabase = (): SupabaseClient => + createClient(inject("supabaseUrl"), inject("supabaseAnonKey")) // Each collection gets its own QueryClient: the realtime channel registry in // src/db.ts is keyed by QueryClient, so a fresh one per collection prevents // channels leaking across tests. -const buildCollection = ( - tableName: string, - keys: Array & string>, - schema: TSchema, - { realtime = false }: CollectionOptions -) => { +const makeUsersCollection = ({ realtime = false } = {}) => { const supabase = makeSupabase() - const queryClient = new QueryClient() const collection = createCollection( supabaseCollectionOptions({ - tableName, - keys, - schema, + tableName: "users", + keys: ["id"], + schema: usersSchema, supabase, - queryClient, + queryClient: new QueryClient(), realtime, }) ) - return { collection, supabase, queryClient } + return { collection, supabase } } -export const makeUsersCollection = (options: CollectionOptions = {}) => - buildCollection("users", ["id"], usersSchema, options) - -export const makeTodosCollection = (options: CollectionOptions = {}) => - buildCollection("todos", ["id"], todosSchema, options) +type UsersContext = ReturnType +type UsersCollection = UsersContext["collection"] -export const makeUsersTodosCollection = (options: CollectionOptions = {}) => - buildCollection( - "users_todos", - ["user_id", "todo_id"], - usersTodosSchema, - options +// An on-demand collection only loads when a live query drives demand. This live +// query also creates the active query that the realtime channel attaches to. +const liveUsers = (base: UsersCollection) => + createLiveQueryCollection((q) => + q.from({ row: base }).select(({ row }) => ({ + id: row.id, + name: row.name, + email: row.email, + active: row.active, + })) ) -// Shared polling window for awaiting async PostgREST/realtime propagation. -export const WAIT = { timeout: 15_000, interval: 200 } as const +type LiveUsersCollection = ReturnType -export type UsersCollection = ReturnType< - typeof makeUsersCollection ->["collection"] +// Waits until the adapter's realtime channel for the table is actually joined, +// so changes written afterwards are guaranteed to be captured. Coupled to the +// adapter naming its channel after the table (supabase.channel(tableName) in +// src/db.ts), which supabase-js exposes under the "realtime:" topic prefix. +const waitForChannel = (supabase: SupabaseClient, table: string) => + vi.waitFor(() => { + const channel = supabase + .getChannels() + .find((c) => c.topic === `realtime:${table}`) + expect(channel?.state).toBe("joined") + }, WAIT) -// An on-demand collection only loads when a live query drives demand. This live -// query also creates the active query that the realtime channel attaches to, so -// tests subscribe through it and read the results back from `live`. -export const liveUsers = (base: UsersCollection) => { - const options = liveQueryCollectionOptions({ - query: (q) => - q.from({ row: base }).select(({ row }) => ({ - id: row.id, - name: row.name, - email: row.email, - active: row.active, - })), - }) - return createCollection( - options as Extract - ) +const preloadSeeded = async (live: LiveUsersCollection) => { + await live.preload() + // The reset_e2e() seed always yields exactly Alice and Bob. + await vi.waitFor(() => expect(live.size).toBe(2), WAIT) } + +// Fixtures are lazy: a test only pays for what it destructures. Teardown runs +// in reverse initialization order and ALWAYS runs, even when the test fails — +// unlike trailing cleanup calls in a test body. +export const test = baseTest.extend<{ + /** Base users collection + its anon client. */ + users: UsersContext + /** Preloaded live query over `users`, seeded rows awaited. */ + usersLive: LiveUsersCollection + /** Like `users` but with realtime enabled. */ + rtUsers: UsersContext + /** Preloaded live query over `rtUsers`, realtime channel joined. */ + rtUsersLive: LiveUsersCollection + /** A second anon client acting as "another user" for realtime writes. */ + other: SupabaseClient +}>({ + users: async ({}, use) => { + const ctx = makeUsersCollection() + await use(ctx) + await ctx.collection.cleanup() + await ctx.supabase.removeAllChannels() + }, + usersLive: async ({ users }, use) => { + const live = liveUsers(users.collection) + await preloadSeeded(live) + await use(live) + await live.cleanup() + }, + rtUsers: async ({}, use) => { + const ctx = makeUsersCollection({ realtime: true }) + await use(ctx) + await ctx.collection.cleanup() + await ctx.supabase.removeAllChannels() + }, + rtUsersLive: async ({ rtUsers }, use) => { + const live = liveUsers(rtUsers.collection) + await preloadSeeded(live) + // Wait until the realtime channel is joined before the test writes, + // otherwise a change could be published before the subscription exists. + await waitForChannel(rtUsers.supabase, "users") + await use(live) + await live.cleanup() + }, + other: async ({}, use) => { + const client = makeSupabase() + await use(client) + await client.removeAllChannels() + }, +}) diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index da5638e..6743b6e 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -11,12 +11,12 @@ const STOP_TIMEOUT_MS = 120_000 // Realtime warm-up: a freshly started realtime container needs a moment before // its Postgres replication is fully wired, so the first subscription can miss -// changes. We subscribe and repeatedly insert a sentinel row until an event is -// actually delivered, guaranteeing realtime is live before any test runs. +// changes. We subscribe to the dedicated e2e_warmup scratch table and upsert +// rows into it until an event is actually delivered, guaranteeing realtime is +// live before any test runs — without ever touching the tables tests read. const WARMUP_TIMEOUT_MS = 60_000 const WARMUP_ATTEMPTS = 40 const WARMUP_POKE_INTERVAL_MS = 1000 -const WARMUP_SENTINEL_ID = 2_000_000_000 const delay = (ms: number): Promise => new Promise((resolve) => { @@ -52,7 +52,15 @@ const readStatus = async (): Promise => { STOP_TIMEOUT_MS ) return JSON.parse(stdout) as SupabaseStatus - } catch { + } catch (error) { + // A missing pnpm/CLI binary is a setup problem, not a stopped stack — + // surface it instead of falling through to a confusing `supabase start`. + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error( + "Could not run `pnpm exec supabase` — is pnpm on PATH and did you run `pnpm install`?" + ) + } + // Non-zero exit (stack not running) or non-JSON output: treat as no stack. return null } } @@ -61,16 +69,12 @@ const warmUpRealtime = async (url: string, anonKey: string): Promise => { const client = createClient(url, anonKey) let received = false - // supabase-js insert resolves with { error } instead of throwing, so failed - // pokes (e.g. a leftover sentinel) are simply retried on the next attempt. + // Upserts never conflict with rows left over from a previous run of the same + // stack, and every upsert produces a WAL change (INSERT or UPDATE) for the + // listener below. supabase-js resolves with { error } instead of throwing. const poke = async (): Promise => { for (let attempt = 0; attempt < WARMUP_ATTEMPTS && !received; attempt++) { - await client.from("users").insert({ - id: WARMUP_SENTINEL_ID + attempt, - name: "warmup", - email: `warmup-${attempt}@e2e.local`, - active: true, - }) + await client.from("e2e_warmup").upsert({ id: attempt }) await delay(WARMUP_POKE_INTERVAL_MS) } } @@ -90,7 +94,7 @@ const warmUpRealtime = async (url: string, anonKey: string): Promise => { .channel("e2e-warmup") .on( "postgres_changes", - { event: "INSERT", schema: "public", table: "users" }, + { event: "*", schema: "public", table: "e2e_warmup" }, () => { received = true clearTimeout(timer) @@ -105,7 +109,6 @@ const warmUpRealtime = async (url: string, anonKey: string): Promise => { }) } finally { await client.removeAllChannels() - await client.from("users").delete().gte("id", WARMUP_SENTINEL_ID) } } diff --git a/tests/e2e/mutations.test.ts b/tests/e2e/mutations.test.ts index 64d1c2c..964ac0a 100644 --- a/tests/e2e/mutations.test.ts +++ b/tests/e2e/mutations.test.ts @@ -1,60 +1,80 @@ -import { expect, test, vi } from "vitest" -import { liveUsers, makeUsersCollection, WAIT } from "./e2e.utils" +import { expect, vi } from "vitest" +import { test, WAIT } from "./e2e.utils" -test("updates a row through PostgREST and reflects the server row", async () => { - const { collection, supabase } = makeUsersCollection() - const live = liveUsers(collection) +test("inserts a row through PostgREST and reflects the server row", async ({ + users, + usersLive, +}) => { + const tx = users.collection.insert({ + id: 100, + name: "Carol", + email: "carol@test.com", + active: true, + }) + await tx.isPersisted.promise + + await vi.waitFor( + () => + expect(usersLive.toArray.some((user) => user.name === "Carol")).toBe( + true + ), + WAIT + ) + expect(usersLive.toArray.find((user) => user.name === "Carol")?.id).toBe(100) - await live.preload() - await vi.waitFor(() => expect(live.size).toBe(2), WAIT) + // The row really exists in the database. + const { data, error } = await users.supabase + .from("users") + .select("*") + .eq("email", "carol@test.com") + .single() + expect(error).toBeNull() + expect(data?.name).toBe("Carol") +}) - const alice = live.toArray.find((user) => user.name === "Alice") - const key = String(alice?.id) +test("updates a row through PostgREST and reflects the server row", async ({ + users, + usersLive, +}) => { + const alice = usersLive.toArray.find((user) => user.name === "Alice") - const tx = collection.update(key, (draft) => { + const tx = users.collection.update(String(alice?.id), (draft) => { draft.name = "Alice Updated" }) await tx.isPersisted.promise await vi.waitFor( () => - expect(live.toArray.find((user) => user.id === alice?.id)?.name).toBe( - "Alice Updated" - ), + expect( + usersLive.toArray.find((user) => user.id === alice?.id)?.name + ).toBe("Alice Updated"), WAIT ) - const { data, error } = await supabase + const { data, error } = await users.supabase .from("users") .select("*") .eq("id", alice?.id) .single() expect(error).toBeNull() expect(data?.name).toBe("Alice Updated") - - await live.cleanup() - await collection.cleanup() }) -test("deletes a row through PostgREST", async () => { - const { collection, supabase } = makeUsersCollection() - const live = liveUsers(collection) +test("deletes a row through PostgREST", async ({ users, usersLive }) => { + const bob = usersLive.toArray.find((user) => user.name === "Bob") - await live.preload() - await vi.waitFor(() => expect(live.size).toBe(2), WAIT) - - const bob = live.toArray.find((user) => user.name === "Bob") - const tx = collection.delete(String(bob?.id)) + const tx = users.collection.delete(String(bob?.id)) await tx.isPersisted.promise await vi.waitFor( - () => expect(live.toArray.some((user) => user.id === bob?.id)).toBe(false), + () => + expect(usersLive.toArray.some((user) => user.id === bob?.id)).toBe(false), WAIT ) - const { data } = await supabase.from("users").select("*").eq("id", bob?.id) + const { data } = await users.supabase + .from("users") + .select("*") + .eq("id", bob?.id) expect(data).toEqual([]) - - await live.cleanup() - await collection.cleanup() }) diff --git a/tests/e2e/reads.test.ts b/tests/e2e/reads.test.ts index 41f5b8e..e2e774c 100644 --- a/tests/e2e/reads.test.ts +++ b/tests/e2e/reads.test.ts @@ -1,42 +1,42 @@ -import { createCollection, eq, liveQueryCollectionOptions } from "@tanstack/db" -import { expect, test, vi } from "vitest" +import { createLiveQueryCollection, eq } from "@tanstack/db" +import { expect, vi } from "vitest" import { queryOnce } from "../../src/index" -import { makeUsersCollection, WAIT } from "./e2e.utils" +import { test, WAIT } from "./e2e.utils" -test("pushes a WHERE filter down to PostgREST", async () => { - const { collection } = makeUsersCollection() +test("reads seeded rows through PostgREST", ({ usersLive }) => { + const names = usersLive.toArray.map((user) => user.name).sort() + expect(names).toEqual(["Alice", "Bob"]) +}) +test("pushes a WHERE filter down to PostgREST", async ({ users }) => { // Seeded fixture: Alice is active, Bob is not — so active=true returns one row. - const options = liveQueryCollectionOptions({ - query: (q) => - q - .from({ row: collection }) - .where(({ row }) => eq(row.active, true)) - .select(({ row }) => ({ id: row.id, name: row.name })), - }) - const live = createCollection( - options as Extract + const live = createLiveQueryCollection((q) => + q + .from({ row: users.collection }) + .where(({ row }) => eq(row.active, true)) + .select(({ row }) => ({ id: row.id, name: row.name })) ) - await live.preload() - await vi.waitFor(() => expect(live.size).toBe(1), WAIT) - expect(live.toArray[0]?.name).toBe("Alice") - - await live.cleanup() - await collection.cleanup() + // finally (not fixture teardown) so this live query is cleaned up before the + // `users` fixture tears down the base collection it depends on. + try { + await live.preload() + await vi.waitFor(() => expect(live.size).toBe(1), WAIT) + expect(live.toArray[0]?.name).toBe("Alice") + } finally { + await live.cleanup() + } }) -test("queryOnce runs a one-shot filtered query", async () => { - const { collection, supabase } = makeUsersCollection() - +test("queryOnce runs a one-shot filtered query", async ({ users }) => { const rows = await queryOnce( (q) => - q.from({ user: collection }).where(({ user }) => eq(user.active, true)), - supabase + q + .from({ user: users.collection }) + .where(({ user }) => eq(user.active, true)), + users.supabase ) expect(rows).toHaveLength(1) expect(rows[0]?.name).toBe("Alice") - - await collection.cleanup() }) diff --git a/tests/e2e/realtime.test.ts b/tests/e2e/realtime.test.ts index ff1aee3..e467388 100644 --- a/tests/e2e/realtime.test.ts +++ b/tests/e2e/realtime.test.ts @@ -1,27 +1,32 @@ -import type { SupabaseClient } from "@supabase/supabase-js" -import { expect, test, vi } from "vitest" -import { liveUsers, makeSupabase, makeUsersCollection, WAIT } from "./e2e.utils" +import { expect, vi } from "vitest" +import { test, WAIT } from "./e2e.utils" -// Waits until the adapter's realtime channel for `users` is actually joined, so -// changes written afterwards are guaranteed to be captured. -const waitForUsersChannel = (supabase: SupabaseClient) => - vi.waitFor(() => { - const channel = supabase - .getChannels() - .find((c) => c.topic === "realtime:users") - expect(channel?.state).toBe("joined") - }, WAIT) +// The rtUsersLive fixture guarantees the realtime channel is joined before the +// test body runs, so writes from the `other` client always arrive as events. -test("receives realtime updates made by another client", async () => { - const { collection, supabase } = makeUsersCollection({ realtime: true }) - const live = liveUsers(collection) +test("receives realtime inserts made by another client", async ({ + rtUsersLive, + other, +}) => { + const draft = { id: 200, name: "Zoe", email: "zoe@test.com", active: true } + const { error } = await other.from("users").insert(draft as unknown as never) + expect(error).toBeNull() + + await vi.waitFor( + () => + expect(rtUsersLive.toArray.some((user) => user.name === "Zoe")).toBe( + true + ), + WAIT + ) +}) - await live.preload() - await vi.waitFor(() => expect(live.size).toBe(2), WAIT) - await waitForUsersChannel(supabase) +test("receives realtime updates made by another client", async ({ + rtUsersLive, + other, +}) => { + const alice = rtUsersLive.toArray.find((user) => user.name === "Alice") - const alice = live.toArray.find((user) => user.name === "Alice") - const other = makeSupabase() const { error } = await other .from("users") .update({ name: "Alice Realtime" }) @@ -30,41 +35,30 @@ test("receives realtime updates made by another client", async () => { await vi.waitFor( () => - expect(live.toArray.find((user) => user.id === alice?.id)?.name).toBe( - "Alice Realtime" - ), + expect( + rtUsersLive.toArray.find((user) => user.id === alice?.id)?.name + ).toBe("Alice Realtime"), WAIT ) - - await live.cleanup() - await collection.cleanup() - await supabase.removeAllChannels() - await other.removeAllChannels() }) // Exercises the delete path, where the adapter derives the collection key from // the realtime payload's `old`. That works here because the collection key is // the primary key, which the default replica identity always includes. -test("receives realtime deletes made by another client", async () => { - const { collection, supabase } = makeUsersCollection({ realtime: true }) - const live = liveUsers(collection) - - await live.preload() - await vi.waitFor(() => expect(live.size).toBe(2), WAIT) - await waitForUsersChannel(supabase) +test("receives realtime deletes made by another client", async ({ + rtUsersLive, + other, +}) => { + const bob = rtUsersLive.toArray.find((user) => user.name === "Bob") - const bob = live.toArray.find((user) => user.name === "Bob") - const other = makeSupabase() const { error } = await other.from("users").delete().eq("id", bob?.id) expect(error).toBeNull() await vi.waitFor( - () => expect(live.toArray.some((user) => user.id === bob?.id)).toBe(false), + () => + expect(rtUsersLive.toArray.some((user) => user.id === bob?.id)).toBe( + false + ), WAIT ) - - await live.cleanup() - await collection.cleanup() - await supabase.removeAllChannels() - await other.removeAllChannels() }) diff --git a/tests/e2e/setup.ts b/tests/e2e/setup.ts index 3394407..c4db09b 100644 --- a/tests/e2e/setup.ts +++ b/tests/e2e/setup.ts @@ -1,39 +1,19 @@ -import { createClient, type SupabaseClient } from "@supabase/supabase-js" +import { createClient } from "@supabase/supabase-js" import { beforeEach, inject } from "vitest" -// Bridge the anon credentials published by the global setup into the environment -// so the shared helpers (e2e.utils.ts) can read them from process.env. -process.env.SUPABASE_URL = inject("supabaseUrl") -process.env.SUPABASE_ANON_KEY = inject("supabaseAnonKey") - -// Data setup uses the service_role (admin) client, which bypasses RLS. -const admin: SupabaseClient = createClient( +// Data reset uses the service_role (admin) client, which bypasses RLS. +const admin = createClient( inject("supabaseUrl"), inject("supabaseServiceRoleKey"), { auth: { persistSession: false } } ) -const assertNoError = (label: string, error: { message: string } | null) => { +// reset_e2e() truncates all test tables (restart identity) and re-seeds the +// fixture rows in a single transactional round trip, so every test starts from +// the same state: Alice (id 1, active) and Bob (id 2, inactive). +beforeEach(async () => { + const { error } = await admin.rpc("reset_e2e") if (error) { - throw new Error(`e2e seed: ${label} failed: ${error.message}`) + throw new Error(`e2e reset failed: ${error.message}`) } -} - -// Reset to a known, deterministic fixture before every test. reset_e2e() runs -// TRUNCATE ... RESTART IDENTITY, so seeded rows always start at id 1. -beforeEach(async () => { - const { error: resetError } = await admin.rpc("reset_e2e") - assertNoError("reset", resetError) - - const { error: usersError } = await admin.from("users").insert([ - { name: "Alice", email: "alice@test.com", active: true }, - { name: "Bob", email: "bob@test.com", active: false }, - ]) - assertNoError("seed users", usersError) - - const { error: todosError } = await admin.from("todos").insert([ - { title: "Buy milk", description: "From the store", completed: false }, - { title: "Walk dog", description: "Around the block", completed: true }, - ]) - assertNoError("seed todos", todosError) }) diff --git a/tests/e2e/smoke.test.ts b/tests/e2e/smoke.test.ts deleted file mode 100644 index 7553ae7..0000000 --- a/tests/e2e/smoke.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { expect, test, vi } from "vitest" -import { liveUsers, makeSupabase, makeUsersCollection, WAIT } from "./e2e.utils" - -test("reads seeded rows through PostgREST", async () => { - const { collection } = makeUsersCollection() - const live = liveUsers(collection) - - await live.preload() - await vi.waitFor(() => expect(live.size).toBe(2), WAIT) - - const names = live.toArray.map((user) => user.name).sort() - expect(names).toEqual(["Alice", "Bob"]) - - await live.cleanup() - await collection.cleanup() -}) - -test("inserts a row through PostgREST and reflects the server row", async () => { - const { collection, supabase } = makeUsersCollection() - const live = liveUsers(collection) - - await live.preload() - await vi.waitFor(() => expect(live.size).toBe(2), WAIT) - - // The adapter POSTs the row, then writes the server's returned row back into - // the collection; the live query reflects it. - const tx = collection.insert({ - id: 100, - name: "Carol", - email: "carol@test.com", - active: true, - }) - await tx.isPersisted.promise - - await vi.waitFor( - () => expect(live.toArray.some((user) => user.name === "Carol")).toBe(true), - WAIT - ) - const carol = live.toArray.find((user) => user.name === "Carol") - expect(carol?.id).toBe(100) - - // The row really exists in the database. - const { data, error } = await supabase - .from("users") - .select("*") - .eq("email", "carol@test.com") - .single() - expect(error).toBeNull() - expect(data?.name).toBe("Carol") - - await live.cleanup() - await collection.cleanup() -}) - -test("receives realtime inserts made by another client", async () => { - const { collection, supabase } = makeUsersCollection({ realtime: true }) - const live = liveUsers(collection) - - await live.preload() - await vi.waitFor(() => expect(live.size).toBe(2), WAIT) - - // Wait until the realtime channel is actually joined before writing, otherwise - // the change could be published before the subscription is established. - await vi.waitFor(() => { - const channel = supabase - .getChannels() - .find((c) => c.topic === "realtime:users") - expect(channel?.state).toBe("joined") - }, WAIT) - - // A second client acts as "another user" so the change arrives over realtime. - const other = makeSupabase() - const draft = { id: 200, name: "Zoe", email: "zoe@test.com", active: true } - const { error } = await other.from("users").insert(draft as unknown as never) - expect(error).toBeNull() - - await vi.waitFor( - () => expect(live.toArray.some((user) => user.name === "Zoe")).toBe(true), - WAIT - ) - - await live.cleanup() - await collection.cleanup() - await supabase.removeAllChannels() - await other.removeAllChannels() -}) diff --git a/vitest.config.ts b/vitest.config.ts index 094c0b7..18f076c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,7 +17,10 @@ export default defineConfig({ globalSetup: ["tests/e2e/global-setup.ts"], setupFiles: ["tests/e2e/setup.ts"], hookTimeout: 120_000, - testTimeout: 30_000, + // A realtime test can chain several WAIT-sized (15s) poll windows; + // keep the test timeout above the worst-case sum so the innermost + // failing waiter reports before the whole test times out. + testTimeout: 60_000, // The e2e tests share a single database that is truncated between // tests, so files must not run in parallel against it. fileParallelism: false,