Importing
Studio → Import connects to another backend, shows you what is in it, and copies what you select while streaming progress. The headline is passwords: Supabase and Postgres bcrypt hashes survive the move, so your users do not have to reset anything. Every other source is honest about what it cannot carry.
What each source brings
| Source | Tables | Users | Passwords | Files | Policies |
|---|---|---|---|---|---|
| Supabase | with types, keys and indexes | auth.users | preserved bcrypt | with project URL + service key | translated where possible |
| Postgres | with types, keys and indexes | if auth.users exists | preserved if bcrypt | n/a | translated where possible |
| Appwrite | collections → tables | yes | lost argon2 | yes | none to translate |
| Firebase | collections → tables, sampled | yes | lost keyed scrypt | with storageBucket | none to translate |
| SQL dump | CREATE TABLE + COPY/INSERT | if auth.users is in the dump | preserved if bcrypt | n/a | none to translate |
Passwords, exactly
This is the part people get burned by, so here it is per source.
Supabase and Postgres — preserved
Supabase stores a bcrypt hash in auth.users.encrypted_password. Baselyra copies
it verbatim behind a bcrypt$ marker. When such a user signs in,
verification recognises the marker and asks Postgres to check it —
select $1 = crypt($2, $1), using pgcrypto, which is already installed. bcrypt is
not reimplemented anywhere in this codebase. On the first successful
sign-in the row is quietly re-hashed to scrypt, so the bcrypt rows retire
themselves as your users come back.
Nobody has to reset a password. Nobody is emailed. The migration is invisible.
Appwrite and Firebase — lost
Appwrite's Users API never returns a password hash, and it hashes with argon2 by default. Firebase uses a modified scrypt keyed with a per-project signer key held by Google — even with the hash you cannot verify a password without that key.
Imported accounts are created with an unusable password marker. They exist, their
email and metadata are intact, and they cannot sign in until they go through
password recovery. The inspect step says so in warnings before you start, and
the run repeats it. Plan for it:
# after the import, for each imported address
curl -s -X POST "$URL/auth/v1/recover" -H 'content-type: application/json' \
-d '{"email":"'"$EMAIL"'"}'
SQL dump — preserved if bcrypt
If the dump contains auth.users, each row's hash is classified.
$2a$/$2b$ values are bcrypt and are preserved exactly as above.
$argon2… and anything unrecognised become unusable passwords, and the inspect
step warns.
Source configuration
Every route takes { source, config }.
{"source":"supabase",
"config":{
"connectionString":"postgresql://postgres:PASSWORD@db.abcdefgh.supabase.co:5432/postgres",
"restUrl":"https://abcdefgh.supabase.co",
"serviceKey":"eyJ…"}}
connectionString (aliases url, databaseUrl) is required and must start
with postgres:// or postgresql://. Use the direct connection string, not
the transaction-mode pooler — a server-side cursor needs a session.
restUrl and serviceKey are optional and only used to download storage
objects; without them, bucket and object metadata is imported but the file bytes
are not, and the run says so. sslmode=require in the URL relaxes certificate
verification, which is what a self-hosted Supabase behind its own certificate needs;
verify-ca and verify-full are left strict.
{"source":"appwrite",
"config":{
"endpoint":"https://cloud.appwrite.io/v1",
"projectId":"…",
"apiKey":"…"}}
The API key needs read scopes for databases, users and storage.
Collections become tables, attributes become columns, and the three Appwrite system
fields become id, created_at and updated_at. An attribute type Baselyra
does not recognise is imported as text rather than dropped.
{"source":"firebase",
"config":{
"projectId":"my-project",
"databaseId":"(default)",
"accessToken":"ya29.…",
"storageBucket":"my-project.appspot.com"}}
gcloud config set project my-project
gcloud auth print-access-token
Firestore has no schema, so column types are inferred from a sample of each
collection. A field that is an integer in half the documents and a string in the
other half is widened to jsonb; maps, arrays and geopoints are jsonb too. A
field that appears only outside the sample will not have a column. Check the
inspect result before you run, and expect to tidy types afterwards. Without
storageBucket, Cloud Storage is not part of the import.
{"source":"sqldump","config":{"sql":"<the whole file as a string>"}}
Plain-text pg_dump output only. A custom-format archive — it starts with
PGDMP — is refused with an explanation: re-export with
pg_dump --format=plain, or restore it somewhere and import from the database
directly. Both COPY … FROM stdin blocks and INSERT statements are read.
The API
| Route | Does |
|---|---|
POST /admin/v1/import/test | {source, config} → {ok, detail, version?} |
POST /admin/v1/import/inspect | {source, config} → the inspect result |
POST /admin/v1/import/run | {source, config, selection} → text/event-stream |
GET /admin/v1/import/history | ?limit= → past runs |
All four require a Studio token or the service key, and all four require the
import.run capability — which owner and admin hold and viewer does
not. A connection string is a credential, so even reading the history is part of
running an import rather than a read.
Start with test. It is cheap and reports the source's version:
curl -s -X POST "$URL/admin/v1/import/test" \
-H "authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"source":"supabase","config":{"connectionString":"postgresql://…"}}'
{"ok":true,"detail":"connected","version":"PostgreSQL 15.6"}
Then inspect:
{"tables":[{"key":"public.posts","schema":"public","name":"posts","rowEstimate":48213,
"columns":[{"name":"id","type":"bigint","nullable":false,"default":null,"autoIncrement":true}],
"primaryKey":["id"],"foreignKeys":[],"indexes":[],"hasRls":true,
"policies":["posts_read_published"]}],
"users":{"count":1204,"hashAlgorithm":"bcrypt"},
"buckets":[{"id":"avatars","public":true,"objectCount":900,"totalBytes":41231884}],
"warnings":["public.audit_log has no primary key and will be imported without one"]}
selection names exactly what to copy. tables holds the key values from
the inspect result, and at least one of tables, users or buckets must be
non-empty.
{"mode":"create",
"schema":"public",
"tables":["public.posts","public.comments"],
"users":true,
"buckets":["avatars"],
"objects":true}
mode | Behaviour |
|---|---|
create (default) | Refuses to overwrite a target table that already holds rows, naming the table in the error |
replace | Truncates the target first |
How a run behaves
- One transaction per table. A cancellation or a bad row leaves that table empty rather than half-filled. Tables already finished stay finished.
- Streamed, never buffered. Postgres sources read through a server-side
cursor and insert in batches of 1000 rows — fewer if the table is wide enough that
1000 rows would exceed the protocol's 65535 bind-parameter limit. A ten-million-row
table does not materialise in memory. The bulk copy also raises
statement_timeoutto unlimited for the duration of a table, because a bulk load is meant to take longer than an API request. - Files three at a time, streamed source → disk. A file that fails is
recorded in
failedObjectsand the run continues; one unreadable object does not end a migration of ten thousand. - Closing the connection cancels. The in-flight table's transaction rolls
back and nothing further starts. The run is recorded as
cancelled. - Users are inserted
on conflict do nothing. An account that already exists here is left exactly as it is. - Non-uuid user ids get new ones.
auth.users.idis a uuid; a source that numbers users differently gets a fresh id, with the original kept inapp_metadata.provider_idso the two can still be matched up. - Nothing logs a secret. Connection strings, API keys and service keys
are redacted from the audit entry, the
control.import_runsrow, the progress stream and every error message — both by key name and by pattern-matching credential shapes inside free text, because a driver error quoting the connection string it failed on is the usual way one escapes.
Progress
data: {"phase":"connect","item":"source","done":0,"total":0,"rowsCopied":0,"warnings":[]}
data: {"phase":"schema","item":"posts","done":1,"total":4,"rowsCopied":0,"warnings":[]}
data: {"phase":"rows","item":"posts","done":1,"total":4,"rowsCopied":48000,"warnings":[]}
: heartbeat
data: {"phase":"constraints","item":"posts","done":1,"total":4,"rowsCopied":91234,"warnings":[]}
data: {"phase":"users","item":"auth.users","done":1204,"total":1204,"rowsCopied":91234,"warnings":[]}
data: {"phase":"done","item":"succeeded","done":1,"total":1,"rowsCopied":91234,
"summary":{"tables":[{"name":"public.posts","rows":48000}],"rowsCopied":91234,
"users":1204,"objects":900,"failedObjects":[],"warnings":[]}}
Phases run in this order: connect, schema, rows, constraints,
users, objects, then done — or error. A comment heartbeat every 15
seconds keeps the pipe warm, because a proxy with a 300-second timeout would
otherwise cut a long table copy.
Every run also writes a control.import_runs row, readable through
GET /admin/v1/import/history and shown in the Studio.
Imported tables arrive with RLS on
ALTER DEFAULT PRIVILEGES grants DML on every new table in public to
anon and authenticated, so a table is world-readable from the moment
CREATE TABLE returns. The importer therefore enables row level security on each
target table before a single row is inserted, and never turns it off.
For Postgres and Supabase sources it then tries to recreate the source's policies:
| Case | Outcome |
|---|---|
| A policy that translates | Created as it was |
| A policy naming a role that does not exist here | Skipped, with a warning |
| A policy that fails to create | Skipped, with a warning |
The common failure is a helper the source had and this instance does not.
auth.is_admin() is the usual one; replace it with
(auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'.
What does not come across
- Triggers, functions, views, extensions, and sequences' current values. Tables, columns, defaults, primary keys, foreign keys and indexes only.
- Supabase edge functions, storage image transformations, OAuth identities, webhooks and cron jobs. The first three do not exist here at all.
- Appwrite functions, teams and messaging.
- Firebase security rules, Cloud Functions and the Realtime Database — Firestore only.
After the import
Check the policies on every imported table
RLS is already on; what may be missing are the policies. Run the audit query — a table with RLS on and zero policies is invisible to your app until you write one. Nothing else matters until this is done.
Read
warningsandfailedObjectsin the summaryIf passwords were lost, send recovery emails
Before you switch traffic over, not after.
Re-check inferred types on a Firestore import
Enable realtime on the tables that need it
select baselyra.enable_realtime('public.messages');Re-point your client at the new URL and anon key
Failure modes
| What you see | Why | Fix |
|---|---|---|
Could not read the source: … | Credentials or the network | Run test first — it is cheap and reports the source's version |
A table is missing from inspect | Internal schemas are excluded, and views are not imported | Materialise the view, or import the underlying tables |
… already holds N rows | mode is create and the target is not empty | Deselect that table, or switch to replace |
The stream stops with no done event | A proxy timeout below the 15-second heartbeat | Raise proxy_read_timeout / ProxyTimeout — the shipped vhosts use 300s |
Rows copied but REST answers 404 for the table | The catalog cache. A run invalidates it on success. | Give it a minute |
| Imported users cannot sign in | Their hashes could not be carried | Password recovery — the inspect warnings said so before the run |
| A pooler connection string times out mid-copy | A server-side cursor needs a session, and the transaction-mode pooler does not give it one | Use the direct connection string |
403 on /admin/v1/import/* | The Studio account is a viewer | Import needs import.run, which owner and admin have |