Migrating
Move the project you already have.
Studio → Import connects to a Supabase project,
a plain Postgres database, Appwrite, Firebase or a pg_dump file; shows you exactly
what is in it; and copies what you tick while streaming progress. The part people get burned by
is passwords, so that is where this page starts.
Passwords
Whether your users have to reset anything
Supabase and Postgres store bcrypt. Baselyra copies the hash verbatim behind a
bcrypt$ marker, and when such a user signs in it asks Postgres to verify it —
select $1 = crypt($2, $1) through 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 imported hashes retire themselves as your users come back. Nobody is
emailed. The migration is invisible.
Supabase, Postgres, and bcrypt dumps
Everyone signs in with the password they already had. Nothing to announce, nothing to support, no drop-off.
Appwrite
Appwrite’s Users API never returns a password hash, and it hashes with argon2 by default. Accounts arrive with email and metadata intact and an unusable password marker; recovery is the only way in.
Firebase
Firebase uses a modified scrypt keyed with a per-project signer key that Google holds. Even with the hash there is nothing to verify against. Same outcome: recovery.
encrypted_password is
set to a marker that parses as no scheme at all. Verification rejects it after burning an
equivalent scrypt derivation, so the response timing does not distinguish those accounts from any
other — which is what stops the import from becoming an account-enumeration oracle.
# One recovery email per imported address. The endpoint answers 200
# whatever happens, so a bad address in your list is not a failure.
while read EMAIL; do
curl -sS -X POST "$URL/auth/v1/recover" \
-H 'content-type: application/json' \
-d "{\"email\":\"$EMAIL\"}"
done < addresses.txt
The run
Four calls, and one of them streams
POST
routes also require at least the admin platform role.-
Test the connection
POST /admin/v1/import/testanswers with the source’s version and whether the credentials work, before you have committed to anything. For a Postgres source use the direct connection string, not the transaction-mode pooler — a server-side cursor needs a session. -
Inspect what is there
POST /admin/v1/import/inspectreturns every table with its row estimate, columns, primary key, foreign keys, indexes, whether RLS is on and its policies; the user count and which hash algorithm they use; the buckets with object counts and total bytes; and a list of warnings. Read the warnings. They say which passwords will not survive. -
Select and run
POST /admin/v1/import/runtakes a selection — the tables you ticked, and whether to bring users and buckets — and answerstext/event-stream. Phases arrive in order:connect,schema,rows,constraints,users,objects,done. A comment heartbeat every fifteen seconds keeps a proxy from cutting a long table copy. -
Read the history
GET /admin/v1/import/historyreturns every run with its summary and its warnings, fromcontrol.import_runs. The Studio shows the same list. Nothing about a migration is only in a log line you already scrolled past.
Per source
What each one needs, and what it costs you
{
"source": "supabase",
"config": {
// The DIRECT connection string, not the transaction pooler.
"connectionString": "postgresql://postgres:PASSWORD@db.abcdefgh.supabase.co:5432/postgres",
// Optional, and only used to download storage objects.
"restUrl": "https://abcdefgh.supabase.co",
"serviceKey": "eyJ…"
}
}
Tables with types, keys and indexes; auth.users with bcrypt
hashes intact; policies translated where possible. Without restUrl and
serviceKey, bucket and object metadata is imported but the file bytes
are not, and the run says so.
{
"source": "postgres",
"config": {
"connectionString": "postgresql://user:pass@db.internal:5432/appdb"
}
}
Any Postgres. Users come across if the source has an
auth.users table, and their passwords survive if they are bcrypt; anything else
— argon2, or a scheme Baselyra does not recognise — becomes an unusable password,
and the inspect step warns before you start. sslmode=require in the URL relaxes
certificate verification, which is what a self-hosted source behind its own certificate
needs; verify-ca and verify-full stay strict.
{
"source": "appwrite",
"config": {
"endpoint": "https://cloud.appwrite.io/v1",
"projectId": "…",
// read scopes for databases, users and storage
"apiKey": "…"
}
}
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. Passwords cannot come; plan the recovery mail-out.
{
"source": "firebase",
"config": {
"projectId": "my-project",
"databaseId": "(default)",
// gcloud auth print-access-token
"accessToken": "ya29.…",
"storageBucket": "my-project.appspot.com"
}
}
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, and a field that appears only outside the sample gets no column at
all. Read the inspect result, and expect to tidy types afterwards.
{ "source": "sqldump",
"config": { "sql": "<the whole file as a string>" } }
Plain-text pg_dump output only. Both COPY … FROM
stdin blocks and INSERT statements are read. A custom-format archive
— it starts with PGDMP — is refused with an explanation rather than a
parse error: re-export with pg_dump --format=plain, or restore it somewhere and
import from the database directly.
Behaviour
How a run treats your data
It refuses to overwrite
The default mode is
create, which refuses a target table that already holds rows and names it
in the error. replace truncates first, and you have to ask for it. That refusal
is the guard between “import into an empty database” and “silently destroy
what is already here”.
Schema, then rows, then constraints
Every table is created before any data moves, so a child can load before its parent; foreign keys and indexes are added at the end and validated once over a finished table rather than row by row. Faster, and it sidesteps ordering problems entirely.
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 a thousand rows — fewer if the table is wide enough that a thousand rows would exceed the protocol’s 65,535 bind-parameter limit. A ten-million-row table does not materialise in memory.
Files three at a time
Streamed source to disk. A file that fails is
recorded in failedObjects and the run continues, because one unreadable object
should not end a migration of ten thousand.
RLS comes back on
Every imported table has row level security enabled. Policies are translated where the source had any — and where it did not, you have a table with RLS on and no policy, which denies everything until you write one. That is the safe failure direction.
Afterwards
The checklist nobody sends you
Audit every table for policies
An imported table has RLS on. If no policy came with it, nothing can read it until you write one — which is correct, and will look like a bug for about ten minutes.
Re-create what did not come
Database functions and triggers, extensions, edge or cloud functions, scheduled jobs and provider-specific settings are not copied. The run log names every one it skipped.
Send recovery mail if the source was Appwrite or Firebase
And tell people it is coming, so it does not read as a phishing attempt.
Check the inferred types on a Firebase import
A sampled schema is a starting point. Widen or narrow columns before you build on them.
Point a staging client at it before you cut over
Run your three most awkward queries. One level of embedded resources is supported; a nested-two-deep select is not, and you would rather learn that now.
docker compose down -v is the undo.
Start with a copy
Nothing here touches your source database.
A Postgres or Supabase source is read inside a
repeatable read read only transaction, through a server-side cursor; the API
sources are read through their own read APIs. The riskiest thing a run can do is fill a disk on
the machine you are importing into.