Baselyra Docs

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

SourceTablesUsersPasswordsFilesPolicies
Supabasewith types, keys and indexesauth.userspreserved bcryptwith project URL + service keytranslated where possible
Postgreswith types, keys and indexesif auth.users existspreserved if bcryptn/atranslated where possible
Appwritecollections → tablesyeslost argon2yesnone to translate
Firebasecollections → tables, sampledyeslost keyed scryptwith storageBucketnone to translate
SQL dumpCREATE TABLE + COPY/INSERTif auth.users is in the dumppreserved if bcryptn/anone 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

RouteDoes
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}
modeBehaviour
create (default)Refuses to overwrite a target table that already holds rows, naming the table in the error
replaceTruncates the target first

How a run behaves

source supabase · postgres appwrite · firebase sqldump inspect tables, users, files one run, in this order — the order is the point 1 schema — every table first, RLS on before any row 2 rows — one transaction per table, batched 3 constraints — keys and indexes, validated once 4 users — on conflict do nothing 5 objects — three at a time, streamed to disk baselyra data: {"phase":"rows","item":"posts","done":2,"total":4,"rowsCopied":48000} text/event-stream, with a comment heartbeat every 15 s
The pipeline. Schema first, then rows, then constraints — creating every table before any data means a child can load before its parent, and adding keys at the end validates each once over a finished table instead of row by row.
  • 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_timeout to 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 failedObjects and 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.id is a uuid; a source that numbers users differently gets a fresh id, with the original kept in app_metadata.provider_id so 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_runs row, 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:

CaseOutcome
A policy that translatesCreated as it was
A policy naming a role that does not exist hereSkipped, with a warning
A policy that fails to createSkipped, 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

  1. 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.

  2. Read warnings and failedObjects in the summary

  3. If passwords were lost, send recovery emails

    Before you switch traffic over, not after.

  4. Re-check inferred types on a Firestore import

  5. Enable realtime on the tables that need it

    select baselyra.enable_realtime('public.messages');
  6. Re-point your client at the new URL and anon key

Failure modes

What you seeWhyFix
Could not read the source: …Credentials or the networkRun test first — it is cheap and reports the source's version
A table is missing from inspectInternal schemas are excluded, and views are not importedMaterialise the view, or import the underlying tables
… already holds N rowsmode is create and the target is not emptyDeselect that table, or switch to replace
The stream stops with no done eventA proxy timeout below the 15-second heartbeatRaise proxy_read_timeout / ProxyTimeout — the shipped vhosts use 300s
Rows copied but REST answers 404 for the tableThe catalog cache. A run invalidates it on success.Give it a minute
Imported users cannot sign inTheir hashes could not be carriedPassword recovery — the inspect warnings said so before the run
A pooler connection string times out mid-copyA server-side cursor needs a session, and the transaction-mode pooler does not give it oneUse the direct connection string
403 on /admin/v1/import/*The Studio account is a viewerImport needs import.run, which owner and admin have

Edit this page Report a problem

Esc
navigate open Esc close