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..47d48e4 --- /dev/null +++ b/supabase/migrations/00000000000000_e2e_schema.sql @@ -0,0 +1,79 @@ +-- 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) +); + +-- 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 +-- 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.e2e_warmup 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.e2e_warmup + for all to public using (true) with check (true); + +-- 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 +language sql +security definer +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; +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..89e7b79 --- /dev/null +++ b/tests/e2e/e2e.utils.ts @@ -0,0 +1,116 @@ +/** 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, 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 { usersSchema } from "../test.utils" + +// Shared polling window for awaiting async PostgREST/realtime propagation. +export const WAIT = { timeout: 15_000, interval: 200 } as const + +// 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 makeUsersCollection = ({ realtime = false } = {}) => { + const supabase = makeSupabase() + const collection = createCollection( + supabaseCollectionOptions({ + tableName: "users", + keys: ["id"], + schema: usersSchema, + supabase, + queryClient: new QueryClient(), + realtime, + }) + ) + return { collection, supabase } +} + +type UsersContext = ReturnType +type UsersCollection = UsersContext["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) => + createLiveQueryCollection((q) => + q.from({ row: base }).select(({ row }) => ({ + id: row.id, + name: row.name, + email: row.email, + active: row.active, + })) + ) + +type LiveUsersCollection = ReturnType + +// 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) + +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 new file mode 100644 index 0000000..6743b6e --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,158 @@ +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 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 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 (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 + } +} + +const warmUpRealtime = async (url: string, anonKey: string): Promise => { + const client = createClient(url, anonKey) + let received = false + + // 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("e2e_warmup").upsert({ id: attempt }) + 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: "*", schema: "public", table: "e2e_warmup" }, + () => { + received = true + clearTimeout(timer) + resolve() + } + ) + .subscribe((status) => { + if (status === "SUBSCRIBED") { + poke().catch(() => undefined) + } + }) + }) + } finally { + await client.removeAllChannels() + } +} + +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/mutations.test.ts b/tests/e2e/mutations.test.ts new file mode 100644 index 0000000..964ac0a --- /dev/null +++ b/tests/e2e/mutations.test.ts @@ -0,0 +1,80 @@ +import { expect, vi } from "vitest" +import { test, WAIT } from "./e2e.utils" + +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) + + // 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") +}) + +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 = users.collection.update(String(alice?.id), (draft) => { + draft.name = "Alice Updated" + }) + await tx.isPersisted.promise + + await vi.waitFor( + () => + expect( + usersLive.toArray.find((user) => user.id === alice?.id)?.name + ).toBe("Alice Updated"), + WAIT + ) + + const { data, error } = await users.supabase + .from("users") + .select("*") + .eq("id", alice?.id) + .single() + expect(error).toBeNull() + expect(data?.name).toBe("Alice Updated") +}) + +test("deletes a row through PostgREST", async ({ users, usersLive }) => { + const bob = usersLive.toArray.find((user) => user.name === "Bob") + + const tx = users.collection.delete(String(bob?.id)) + await tx.isPersisted.promise + + await vi.waitFor( + () => + expect(usersLive.toArray.some((user) => user.id === bob?.id)).toBe(false), + WAIT + ) + + const { data } = await users.supabase + .from("users") + .select("*") + .eq("id", bob?.id) + expect(data).toEqual([]) +}) diff --git a/tests/e2e/reads.test.ts b/tests/e2e/reads.test.ts new file mode 100644 index 0000000..e2e774c --- /dev/null +++ b/tests/e2e/reads.test.ts @@ -0,0 +1,42 @@ +import { createLiveQueryCollection, eq } from "@tanstack/db" +import { expect, vi } from "vitest" +import { queryOnce } from "../../src/index" +import { test, WAIT } from "./e2e.utils" + +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 live = createLiveQueryCollection((q) => + q + .from({ row: users.collection }) + .where(({ row }) => eq(row.active, true)) + .select(({ row }) => ({ id: row.id, name: row.name })) + ) + + // 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 ({ users }) => { + const rows = await queryOnce( + (q) => + q + .from({ user: users.collection }) + .where(({ user }) => eq(user.active, true)), + users.supabase + ) + + expect(rows).toHaveLength(1) + expect(rows[0]?.name).toBe("Alice") +}) diff --git a/tests/e2e/realtime.test.ts b/tests/e2e/realtime.test.ts new file mode 100644 index 0000000..e467388 --- /dev/null +++ b/tests/e2e/realtime.test.ts @@ -0,0 +1,64 @@ +import { expect, vi } from "vitest" +import { test, WAIT } from "./e2e.utils" + +// 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 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 + ) +}) + +test("receives realtime updates made by another client", async ({ + rtUsersLive, + other, +}) => { + const alice = rtUsersLive.toArray.find((user) => user.name === "Alice") + + const { error } = await other + .from("users") + .update({ name: "Alice Realtime" }) + .eq("id", alice?.id) + expect(error).toBeNull() + + await vi.waitFor( + () => + expect( + rtUsersLive.toArray.find((user) => user.id === alice?.id)?.name + ).toBe("Alice Realtime"), + WAIT + ) +}) + +// 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 ({ + rtUsersLive, + other, +}) => { + const bob = rtUsersLive.toArray.find((user) => user.name === "Bob") + + const { error } = await other.from("users").delete().eq("id", bob?.id) + expect(error).toBeNull() + + await vi.waitFor( + () => + expect(rtUsersLive.toArray.some((user) => user.id === bob?.id)).toBe( + false + ), + WAIT + ) +}) diff --git a/tests/e2e/setup.ts b/tests/e2e/setup.ts new file mode 100644 index 0000000..c4db09b --- /dev/null +++ b/tests/e2e/setup.ts @@ -0,0 +1,19 @@ +import { createClient } from "@supabase/supabase-js" +import { beforeEach, inject } from "vitest" + +// Data reset uses the service_role (admin) client, which bypasses RLS. +const admin = createClient( + inject("supabaseUrl"), + inject("supabaseServiceRoleKey"), + { auth: { persistSession: false } } +) + +// 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 reset failed: ${error.message}`) + } +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..18f076c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,31 @@ +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, + // 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, + }, + }, + ], + }, +})