# Migrate from Supabase

This page moves a Supabase project to a Bastide project: the schema and data, the authentication users, the storage buckets and objects. Your application keeps `supabase-js`: it changes the project address and key.

## Before you start

- A Bastide project created with its backend (`project_create_with_backend`), **empty**: the importer refuses a target project that is already occupied, before writing anything.
- The direct connection address of the source database (role `postgres`, direct connection or session mode), its `service_role` key, and its `anon` key if you want to keep the legacy keys.
- An operator: during the invitation-only beta, the import is an operator action, not a registry operation. It opens the tenant database as administrator to install the extensions.
- Read the "Known incompatibilities" section below first: a client that relies on SAML, on the S3 endpoint or on `postgres_changes` does not move over as is.

## 1. Run the import

The source's secrets do not go through the command line, which `ps` shows to every user of the host: they are read from the environment.

```
SUPABASE_DB_URL='postgres://postgres:<password>@db.<ref>.supabase.co:5432/postgres' SUPABASE_SERVICE_ROLE_KEY='<service_role>' SUPABASE_ANON_KEY='<anon>' pnpm importer:supabase <bak_…> --stockage https://<ref>.supabase.co --rapport rapport.json
```

`--stockage` is required as soon as the source has a bucket; without it the import stops and says so. The order of the steps is not free: inventory and target judged empty, users, schema and data (policies and grants recomposed, in one transaction), storage (a checksum per object), catalogue comparison and row recount, report.

The exit code gives the outcome: `0` imported, `3` imported with discrepancies to read in the report, `1` interrupted or failed, `2` not done (missing argument or variable). On failure the importer undoes nothing: it writes into a new project, which you remove if needed; every step that writes does so in a transaction, and the report says which one stopped.

The importer writes nothing to the source: no token revoked, no switch to read-only.

## 2. Double run: run both sides before switching

As long as you do not switch, the source remains your live application. The procedure:

1. **Import** as above. The import is a copy at a given instant.
2. **Keep the original keys.** Supplied at import, `SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY` are stored by fingerprint and remain accepted for twelve months, every response carrying `Deprecation: true` and `Sunset` (see `LIM-09`). A copy of your application can therefore point at Bastide without changing its keys: only the project address changes.
3. **Run a copy of the application on Bastide**, next to the production that stays on Supabase: the same project address replaced, the same keys, the same journey. Replay your ordinary journeys: sign-up, confirmation, sign-in, read, write, upload.
4. **Compare.** Read the report (`--rapport`): catalogue compared, rows recounted, checksum of each object. Compare what your journeys return on both sides.
5. **Move to the `sb_publishable_…` and `sb_secret_…` keys** (`backend.key.create`) before the twelve months expire, then revoke the legacy keys.
6. **Switch.** There is no continuous replication: writes made on the source after the import do not follow. The source's read-only window (TR-194, `EX-MIG-140`) is planned for lot L2 and does not exist yet. To switch without loss, suspend the application's writes yourself, run the import into a NEW empty Bastide project, check the report, then change the production address.

A rollback before step 6 has no effect on the source: do nothing on the Supabase side, and remove the Bastide project.

## 3. Connect the application

Replace the project address and key. Supabase's paths are held with no rewriting: `https://<project>.<domain>/rest/v1/…`, `/auth/v1/…`, `/realtime/v1/…`. The `supabase-js` headers are Supabase's: the key in `apikey`, a session token in `Authorization`.

## Known incompatibilities

Each limit below is reproduced by a test (`pnpm limites`); the public page is <https://bastide.app/limites-supabase>.

- `LIM-01`: a REST response carries at most 1000 rows, truncated without error; paginate (`Range`, or `limit` and `offset`).
- `LIM-02`: a resource embeds only along a declared foreign key, otherwise 400 `PGRST200`; declare the key, or expose a view.
- `LIM-03`: in the pooler's transaction mode (the one of direct connections, the default), `NOTIFY` does not reach a client that does `LISTEN`; listen from a session-mode connection.
- `LIM-04`: in transaction mode, `pg_advisory_lock` does not exclude another connection of the same project; use `pg_advisory_xact_lock`, or session mode.
- `LIM-05`: in transaction mode, session state is not isolated (`SET`, temporary table, `WITH HOLD` cursor); use `SET LOCAL`, `ON COMMIT DROP`, or session mode.
- `LIM-06`: not served are enterprise SAML sign-in (`signInWithSSO`), identity linking (`linkIdentity`) and `/auth/v1/settings` (404, `chemin_auth_inconnu`). An enterprise sign-in goes through Keycloak over OpenID Connect. The twelve `signInWithOAuth` providers and two-factor authentication are served.
- `LIM-07`: the S3-compatible endpoint (`/storage/v1/s3`) is not served (404, `chemin_inconnu`); it will come with `EX-DATA-370` (lot L2). The `storage-js` storage is served.
- `LIM-08`: realtime serves broadcast and presence, not listening to database changes: a `postgres_changes` subscription is refused. Use `select realtime.diffuser_les_changements('public.my_table')`.
- `LIM-09`: the legacy `anon` and `service_role` keys are valid only for an imported project, its two original keys only, for twelve months. Any other gets 401 `cle_refusee`.
- `LIM-10`: `updateUser` only changes the password; an address or metadata is refused (400, `changement_d_adresse_ailleurs`, `metadonnees_non_rangees`). Use `POST /auth/v1/user/email`, and a project table for metadata.
- `LIM-11`: a table is granted to the API roles only if it carries row-level security AND at least one policy; the roles carry the database name as a prefix, so a `grant … to authenticated` written as is is refused (code `42704`).

Other measured gaps: the returned user does not carry `last_sign_in_at` (the field is absent, not null); backup codes, web3 and address change by the client are neither measured nor served. The pass rate of the official `supabase-js` integration suite (95% required by `EX-DATA-090`) has not been played yet: it assumes asymmetric keys with a JWKS endpoint, planned for lot L2.

The bricks under the data plane are six, pinned by digest (the bricks matrix): PostgreSQL 17, pgvector, Supavisor, PostgREST, imgproxy (free edition) and SeaweedFS. The proven client is `@supabase/supabase-js` 2.117.2. The import only carries over the extensions admitted by the target project's tier.

See also: [Migrate from Vercel](from-vercel.md), [Leave Bastide](leave.md).
