query / mutation / action), and reactive queries.
Most of your code moves with small edits.
The one real change is access control. Convex has no policy layer: you write the
auth check by hand inside every function. Pylon has declarative policies with
default-deny. Moving to Pylon means writing each rule once per entity instead of
in every function, and an entity with no policy is blocked rather than open.
Why Pylon fits
defineTablewithvvalidators maps almost one-to-one toentity()withfield.queryandmutationhandlers withctx.dbmap to Pylonqueryandmutationhandlers withctx.db.useQueryreactive hooks map todb.useQuery.- Scheduling, actions, and file storage all have direct equivalents.
- Declarative policies. In Convex, an unguarded function is a potential IDOR:
it is open unless its author remembered the check. In Pylon you write a
policy()per entity, it is enforced on every read and write, and the default is deny. This is the main reason the move is worth it. - Server-side rendering: file-based routes, one binary serving frontend and API.
- Auth built in (email/password, magic code, OAuth, guest), so you do not wire up a separate provider.
Concept map
Migrating your schema
defineTable becomes entity, and the v validators become field builders.
Convex (schema.ts):
app.ts):
v.id("table")becomesfield.id("Entity").- Convex’s automatic
_creationTimehas no equivalent, so add an explicitcreatedAt: field.datetime().defaultNow(). v.union(v.literal("draft"), v.literal("live"))becomesfield.enum(["draft", "live"]). A complexv.object/v.unionbecomesfield.json().
Access control: from per-function checks to policies
This is the migration’s real work, and its payoff. In Convex you check the identity and throw by hand in every function. Convex (in each function):db.useQuery("Post", ...) and ctx.db reads return
only the rows the caller may see, and writes are checked, without a per-function
guard. Because Pylon is default-deny, an entity with no policy is fully blocked,
so a forgotten rule fails closed instead of leaking.
Keep in a function only the checks a policy cannot express: multi-step business
rules, or gates on data the policy engine does not resolve. For an org-membership
gate, use ctx.requireMember(orgId, { role: [...] }) inside the function.
Migrating reads and writes
The server API is nearly the same shape.useQuery(api.posts.list, args) becomes
db.useQuery("Post", {...}), and useMutation(api.posts.create) becomes
db.useMutation("create") or the optimistic db.useEntity("Post").
Migrating your data
1. Export.npx convex export --path ./out writes a snapshot_<ts>.zip. Each
table is a <table>/documents.jsonl file, one JSON document per line, including
_id and _creationTime.
_id values are opaque strings. Pylon ids are
40-character lowercase hex, so you cannot reuse them directly (Pylon rejects a bad
id with INVALID_ID). Use one of two methods:
- Derive the id (simplest). Convert each
_idto a Pylon id with a fixed function, and apply the same function to everyv.idreference.sha1(_id)gives 40 lowercase hex characters. - Map the id. Generate a new Pylon id per row with
generateId()from@pylonsync/sync, keep an old-to-new map, and rewrite references in a second pass.
_creationTime (a millisecond number) to your createdAt field with
new Date(row._creationTime).toISOString().
3. Import into Pylon. Read each documents.jsonl line by line and send batches
to the admin batch endpoint (admin token; bypasses per-entity policies).
_storage/ folder holds each blob. Upload each to
Pylon’s file storage and rewrite the stored ids.
5. Users. How users are stored depends on your Convex auth. With Convex Auth,
users are a users table you export like any other. With a third-party provider,
export the users from that provider. Import each into your Pylon User entity,
keyed by email. Sessions do not transfer, so users sign in again.
Migrating auth
In a Convex function you readctx.auth.getUserIdentity() and use
identity.subject. In Pylon you read ctx.auth.userId.
For sign-in, Pylon has the flows built in:
- Email and password:
POST /api/auth/password/registerand/password/login. - Magic code:
POST /api/auth/magic/sendthen/magic/verify. - OAuth:
GET /api/auth/login/:provider?callback=<url>(Google, GitHub, Apple, Microsoft, and about 20 more). - Guest:
POST /api/auth/guest.
setSessionToken(token), which also re-syncs the
replica for the new user.
Scheduling, actions, and storage
ctx.scheduler.runAfter(ms, fn, args)andrunAtmap to Pylon’s scheduler. Acrons.tscronJobs()table maps to Pylon crons.action({...})for external I/O maps to a Pylonaction, which also callsctx.runQuery/ctx.runMutation.ctx.storage.generateUploadUrl()andgetUrl(id)map to the Pylon files provider.
What does not map cleanly
Decide on these before you commit:- Business-logic authorization. Simple ownership checks become policies. A multi-step rule that a policy cannot express stays as an explicit check inside the Pylon function.
- Discriminated unions.
v.union(v.literal(...))maps tofield.enum([...]); a richer union maps tofield.json()and you validate it in a function. - The automatic
_creationTime. Pylon has no automatic creation timestamp, so add acreatedAtfield. - Opaque ids in client code. Convex
Id<"table">values become plain Pylon string ids after the conversion above.
Get started
- Create a new Pylon app:
npm create @pylonsync/pylon@latest my-app. - Turn each
defineTableinto anentity, and write apolicy()for every entity to replace the per-function checks. - Run
npx convex export, the conversion, and the/api/batchimport against a staging app first. - Point the client at Pylon:
init, the auth calls,db.useQuery, anddb.useEntity. - Run
pylon deploy, or self-host the single binary.