Skip to main content
Supabase and Pylon share the most: a relational store with a real schema and foreign keys, per-row access policies, auth, functions, and file storage. This is the closest migration of the set. The main work is turning your SQL schema into Pylon entities and your RLS policies into Pylon policies.

Why Pylon fits

  • Postgres tables with typed columns and foreign keys map to Pylon entities with typed fields and field.id relations.
  • Row Level Security policies map to per-entity policies, and Pylon evaluates them the same way (an existing-row read side and an incoming-write side).
  • Supabase Realtime maps to reactive queries against a local replica.
  • Supabase Auth (email/password, magic link, OAuth, anonymous) maps one-to-one to Pylon auth.
What Pylon adds or does differently:
  • Your schema and policies live in TypeScript, next to your app, not in SQL migration files and a separate RLS layer.
  • Hosted server functions (query / mutation / action) in TypeScript, instead of Deno Edge Functions and plpgsql.
  • Server-side rendering: file-based routes, one binary serving frontend and API.
  • Pylon runs on SQLite or Postgres and deploys with one command, or self-hosts as a single binary.

Concept map

Migrating your schema

Each Postgres table becomes a Pylon entity. Columns become typed fields, and a foreign key becomes a field.id. Supabase (SQL):
Pylon (app.ts):
Notes:
  • Map Postgres types to Pylon: textfield.string, int/bigintfield.int, numeric/floatfield.float, timestamptzfield.datetime, jsonbfield.json, boolfield.bool.
  • A references auth.users(id) foreign key becomes field.id("User"). Other table references become field.id("ThatEntity").
  • Postgres check constraints and defaults map to field modifiers (.optional(), .unique(), .default(...), .defaultNow()).

Migrating your policies

Supabase Row Level Security and Pylon policies are the same idea: a per-table read side and a per-table write side. One difference to audit: RLS is off by default on a new Supabase table, so any table where you never enabled it is fully open through the API. Pylon denies by default with no enable step, so every entity needs an explicit policy(). List your tables and check which ones never had RLS enabled, then decide whether each should be public or locked down. Supabase (RLS):
Pylon (policy(...)):
How to convert each policy:
  • auth.uid() maps to auth.userId. auth.jwt() claims (roles) map to auth.roles / auth.hasAnyRole(...).
  • using (...) is the existing-row side. It maps to allowRead, and to existing.* in the update and delete policy.
  • with check (...) is the incoming-write side. It maps to allowInsert and allowUpdate, evaluated on data.* (the row being written).
  • To lock an immutable field, use field.string().owner() instead of a with check that compares the new value to the old one.
  • A cross-table check (exists (select 1 from members ...)) maps to exists(Member where orgId == data.orgId and userId == auth.userId).

Migrating reads and writes

Reads. A supabase-js query becomes db.useQuery, which is reactive by default, so you do not wire up a separate realtime channel.
Writes. .insert/.update/.delete become the client entity hook or a server function.
One Supabase quirk to remember: .insert/.update/.delete do not return the affected rows unless you chain .select(). Pylon’s ctx.db.insert returns the new id, and db.useEntity paints the change locally.

Migrating your data

Supabase is Postgres, so export is standard SQL tooling. This is the easy part. 1. Export with pg_dump. Use the connection string from Dashboard → Connect.
2. Convert ids. Supabase ids are usually UUIDs. Pylon ids are 40-character lowercase hex, so you cannot reuse a UUID directly (Pylon rejects a bad id with INVALID_ID). Use one of two methods:
  • Derive the id (simplest). Convert each UUID to a Pylon id with a fixed function, and apply the same function to every foreign key. sha1(uuid) gives 40 lowercase hex characters. Derived ids are not time-ordered, so sort by your created_at fields.
  • Map the id. Generate a new Pylon id per row with generateId() from @pylonsync/sync, keep an old-to-new map, and rewrite foreign keys in a second pass.
3. Import into Pylon. The admin batch endpoint loads rows quickly and bypasses per-entity policies. It needs an admin token. Send batches of a few hundred rows.
4. Files. Download each object from Supabase Storage and upload it to Pylon’s file storage, then rewrite the stored paths and URLs. 5. Users. Supabase users live in auth.users. Import each into your Pylon User entity, keyed by email, and map the old user UUID to the new Pylon id the same way you map foreign keys. Sessions and password hashes do not transfer, so users sign in again with magic code or OAuth, where email is the stable key.

Migrating auth

Each Supabase auth method has a Pylon equivalent.
  • Magic link (signInWithOtp({ email })) maps to POST /api/auth/magic/send then /magic/verify.
  • OAuth (signInWithOAuth({ provider })) maps to a redirect to GET /api/auth/login/:provider?callback=<url>. Pylon supports Google, GitHub, Apple, Microsoft, and about 20 more providers.
  • Anonymous (signInAnonymously()) maps to a Pylon guest session (POST /api/auth/guest).
  • getUser() / onAuthStateChangeuser.id maps to db.useUser() on the client and ctx.auth.userId on the server.

What does not map cleanly

Decide on these before you commit:
  • Raw SQL and rpc. Supabase lets the client run stored procedures and, through the SQL editor, arbitrary SQL. Pylon’s data access is through entities and server functions, not arbitrary SQL from the client. Move plpgsql functions and complex queries into a Pylon query or mutation.
  • Database triggers. A plpgsql trigger that runs on write becomes logic inside the mutation that makes the write, or a scheduled job.
  • Postgres-only features. Views, materialized views, and extensions do not carry over as-is. Pylon has its own vector search, so pgvector maps to field.vector and ctx.db.vectorSearch.
  • RLS defaults. A table where RLS was never enabled was open; in Pylon it needs an explicit policy. Audit every table.
  • Realtime scope. Supabase realtime subscribes to a table and a filter string. Pylon’s reactive queries subscribe to the query itself, so you do not re-derive relevance on the client.
None of these blocks a typical app. Each one needs a decision, not a mechanical rewrite.

Get started

  1. Create a new Pylon app: npm create @pylonsync/pylon@latest my-app.
  2. Turn each table into an entity, and each RLS policy into a policy(). Audit tables that had RLS off.
  3. Run the pg_dump export, the conversion, and the /api/batch import against a staging app first.
  4. Point the client at Pylon: init, the auth calls, db.useQuery, and db.useEntity.
  5. Run pylon deploy, or self-host the single binary.