helipod
Reference

Migrate from Convex

Use helipod migrate to port a Convex project's imports, then work through its report.

A Convex app already looks like a helipod app. Same query and mutation handlers, the same defineSchema/defineTable schema, the same client hooks. Porting one is mostly a matter of rewriting imports, not rewriting your logic.

helipod migrate automates the mechanical part: import specifiers, package.json, _generated/. For everything it can't do for you, it gives you a concrete, file-and-line list.

Canonical imports in helipod are @helipod/*, never convex/* (see What is helipod?). helipod migrate also renames your app's function directory from convex/ to helipod's own default, helipod/, as part of the same run. Migrating moves your app onto the native import surface; it does not make helipod execute convex/* imports unchanged. There's no compatibility shim to fall back on. The codemod is the whole migration.

This page covers two independent things under one command: helipod migrate rewrites a Convex project's code, and helipod migrate export/import moves an app's data between storage topologies. Most projects only need the first.

helipod migrate migrate export migrate import Convex project Migrated code + MIGRATION-REPORT.md Running deployment dump.json New deployment

What stays the same

Nothing about your function bodies needs to change:

  • query/mutation/action still come from ./_generated/server. Your function bodies don't move.
  • schema.ts keeps its shape (it just relocates from convex/ to helipod/ along with the rest of the directory, see the rename step below). defineSchema, defineTable, and indexes work the same way; only the import specifier moves.
  • useQuery/useMutation keep the same signatures, imported from @helipod/client/react instead of convex/react.

Migrating a project's code

Before you run it

The command detects a Convex project by either signal being present at the project root:

  • convex/schema.ts exists, or
  • package.json has a convex dependency.

It then refuses to touch a dirty git working tree:

$ helipod migrate
refusing to migrate: /path/to/app has uncommitted changes (commit/stash first, or pass --force)

Commit or stash first, or pass --force to proceed anyway.

No git repo at all?

If the project directory isn't a git repo, migrate prints a warning and proceeds unconditionally. There's no dirty check to run, and in that case a bad migration has no git checkout to undo it:

warning: /path/to/app is not a git repo, changes will be made in place with no easy revert

The dirty check runs against the parent of --dir (the project root, one level above your Convex app directory), the same directory package.json and helipod.config.ts live in.

Running it

terminal
helipod migrate --from convex --dir convex
FlagDefaultMeaning
--from <source>convexMigration source. Only convex ships today. See The source-adapter seam below.
--dir <path>convexThe app directory to migrate.
--dry-runoffCompute the plan and write MIGRATION-REPORT.md, but make no other changes.
--forceoffProceed even with an uncommitted working tree.

--dry-run is the safe way to preview a migration: it still writes the full report, but skips every file edit, every scaffold, and _generated/ regeneration entirely. Your convex/ tree is left byte-for-byte as it was.

$ helipod migrate --dry-run --force
[dry-run] 3 files would change, 1 scaffolded. See MIGRATION-REPORT.md

A real run reports what it actually did, and how many items still need you:

$ helipod migrate --force
migrated 3 files. 2 item(s) need manual attention, see MIGRATION-REPORT.md

What it actually does, in order

Rename the functions directory

Runs right after detection, on the untouched tree, and before anything else. If the source directory (--dir, default convex) differs from where helipod itself would put it (helipod/ by default, or your project's own functionsDir in helipod.config.ts), the whole directory is renamed there first: git mv inside a git repo, falling back to a plain filesystem rename if that fails. If the target directory already exists, the migration refuses to proceed rather than overwrite it. Every step from here on operates on the renamed directory. On --dry-run, the rename is only reported as pending; the directory itself is left untouched.

Walk the functions directory

It recurses into every subdirectory except _generated and node_modules, collecting every .ts/.tsx file (under the renamed directory, if a rename just happened).

Rewrite imports and scan for divergences

Each file gets its import specifiers rewritten (see The import codemod below) and is scanned for divergences it does not auto-fix (see The divergence scan below).

Edit package.json

It drops the convex dependency and any @convex-dev/* dependency, then adds, at version "latest", whichever of @helipod/values/@helipod/client the rewrite actually introduced into your source. Only those two packages are auto-added. See the scheduler gotcha below for what isn't.

Scaffold helipod.config.ts, only if you use crons

This step runs only if it detected a crons.ts file or a cronJobs(...) call anywhere in your source, and only if helipod.config.ts doesn't already exist. An existing config file is never overwritten.

Write MIGRATION-REPORT.md

The report is written at the project root before anything else, so even a later regeneration failure leaves you with it.

Regenerate _generated/

It deletes the app's existing _generated/ directory outright. A real Convex app ships _generated/{server.js,server.d.ts,api.js,api.d.ts,dataModel.d.ts}, and if those stale .js files were left in place, a JS-first module resolver would pick the stale server.js (which still imports the now-uninstalled "convex/server") over the regenerated .ts. It then loads the rewritten project and regenerates fresh typed Doc/Id/api and server helpers, through the same loadFunctionsDir → push → writeGenerated pipeline helipod codegen uses.

If codegen fails, usually because an action-needed item wasn't fixed yet (for example, a .withIndex left in place makes the load itself throw), the command exits 1. Everything up to that point, the import rewrite, package.json, the scaffold, and the report, has already been written:

imports migrated, but codegen failed: <error>
See MIGRATION-REPORT.md; fix the flagged items, then run `helipod codegen`.

The import codemod

The rewrite is symbol-aware, not a blind string replace. It operates on the quoted module specifier, so import, export … from, require(), and dynamic import() are all handled, and it inspects which named symbols a convex/server import pulls in before deciding how (or whether) to rewrite it.

Three specifiers are unambiguous and always rewritten wherever they appear, quoted, in the file:

FromTo
"convex/values""@helipod/values"
"convex/react""@helipod/client/react"
"convex/browser""@helipod/client"

"convex/server" is different. Convex overloads that one module for schema builders, HTTP routing, and crons, which map to three different places in helipod:

convex/server importRewrite
Only defineSchema/defineTable"@helipod/values"
Only httpRouter/httpAction"./_generated/server"
Anything else (including a mix, or cronJobs)Left unchanged, flagged action-needed with the specific fix
convex/schema.ts (before)
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({ notes: defineTable({ body: v.string() }) });
helipod/schema.ts (after migrate)
import { defineSchema, defineTable } from "@helipod/values";
import { v } from "@helipod/values";

export default defineSchema({ notes: defineTable({ body: v.string() }) });

./_generated/server imports (query, mutation, action, httpAction) are never touched. A Convex app already imports those from the same relative path helipod uses.

A mixed convex/server import, say import { defineSchema, cronJobs } from "convex/server", is never guessed at. It's left exactly as written and reported action-needed with a fix naming each symbol's real home. Any remaining bare "convex/server" occurrence the codemod's two passes didn't match (a default import, an export * from, a require()) is also caught and flagged, so nothing silently slips through as unsupported-by-omission.

The divergence scan

Independent of the import rewrite, every walked file is also scanned line by line for Convex runtime patterns that helipod's engine doesn't accept, even after the import moves. Every finding names the exact file and line.

Action-needed. A real helipod equivalent exists, but you adapt the call site by hand:

Convex patternFix
.withIndex(...)No .withIndex, use ctx.db.query(table, "index").eq(f, v).gte(f, v).order("asc"|"desc").collect()
ctx.db.patch(...)No patch, read the doc, spread-merge, ctx.db.replace(id, { ...doc, ...changes })
.paginate(...)paginate({ cursor, pageSize, maxScan? }) returns { page, nextCursor, hasMore, scanCapped }
ctx.auth / getUserIdentity()Identity is a string token via a context provider (e.g. @helipod/auth's ctx.auth), not a JWT-claims object
crons.ts file, or any cronJobs(...) callCompose defineScheduler() in helipod.config.ts; use cronJobs()

Unsupported. No automatic path; you decide how, or whether, to proceed:

Convex patternWhy
@convex-dev/auth, or an import from "convex/auth"Not auto-translated, use @helipod/auth or an external JWT/OIDC provider
app.use(...) (Convex Components), or a convex.config.ts fileConvex Components don't map 1:1. Recompose the equivalent helipod component(s) via helipod.config.ts
.vectorIndex(...) / .searchIndex(...)Full-text and vector search are not shipped in helipod

.withIndex/ctx.db.patch/.paginate/ctx.auth are matched anywhere they appear as a substring on a line, so, for example, a comment mentioning .paginate( is flagged too. The scanner is line-based, not AST-based, so it's intentionally over-inclusive rather than risking a missed real call.

Reading the report

MIGRATION-REPORT.md groups every finding by severity, with a one-line count summary up top. A section is omitted entirely if it has no entries:

MIGRATION-REPORT.md
# Helipod migration report

5 auto-fixed, 3 action-needed, 1 unsupported.

## Auto-fixed (5)

- `convex/`: renamed to helipod/. **Fix:** Your backend functions now live in helipod/. Imports inside that folder are relative and did not change.
- `helipod/schema.ts:1`: import "convex/server" (schema). **Fix:** rewritten to "@helipod/values"
- `helipod/schema.ts:2`: import "convex/values". **Fix:** rewritten to "@helipod/values"
- `helipod/messages.ts:1`: import "convex/react". **Fix:** rewritten to "@helipod/client/react"
- `helipod/http.ts:1`: import "convex/server" (http). **Fix:** rewritten to "./_generated/server"

## Action needed (3)

- `helipod/messages.ts:14`: .withIndex(...) query. **Fix:** Helipod has no .withIndex, use ctx.db.query(table, "index").eq(f, v).gte(f, v).order("asc"|"desc").collect()
- `helipod/messages.ts:22`: ctx.db.patch(...). **Fix:** Helipod has no patch, read the doc, spread-merge, ctx.db.replace(id, { ...doc, ...changes })
- `helipod/crons.ts:1`: Convex crons (cronJobs). **Fix:** Compose defineScheduler() in helipod.config.ts and use cronJobs() from "@helipod/scheduler"

## Unsupported (1)

- `helipod/search.ts:9`: vector/search index. **Fix:** search/vector is not yet supported in Helipod (see roadmap)

Every finding, including the auto-fixed ones, is listed for visibility, not just the ones that need work. It's a full diff of what the tool touched, in one file, without needing git diff.

The scheduler scaffold doesn't wire your crons for you

Scaffold only

If migrate detects a crons.ts file (or a bare cronJobs(...) call anywhere), it scaffolds a starting helipod.config.ts (skipped if one already exists), but it doesn't wire your actual cron definitions into it. That part is on you, in two small steps below.

helipod.config.ts (scaffolded)
import { defineConfig } from "@helipod/component";
import { defineScheduler } from "@helipod/scheduler";

// Convex crons map to Helipod's scheduler component. Move your cron definitions into
// a convex/crons.ts using cronJobs() from "@helipod/scheduler".
export default defineConfig({ components: [defineScheduler()] });

Two things to do by hand from there, per Scheduling:

Add the packages yourself

Add @helipod/scheduler and @helipod/component to package.json yourself. Unlike @helipod/values/@helipod/client, the scaffold's own dependencies aren't auto-added: the package.json edit only reacts to what the import-rewrite pass introduced into your walked source files, and the scaffolded config file isn't part of that pass.

Wire your actual crons in

cronJobs() in helipod is imported from ./_generated/server (not @helipod/scheduler directly), and the registry it builds does nothing until it's passed to defineScheduler({ crons }):

helipod/crons.ts
import { cronJobs } from "./_generated/server";

const crons = cronJobs();
crons.interval("cleanup", { minutes: 5 }, internal.maintenance.purge, {});

export default crons;
helipod.config.ts
import { defineConfig } from "@helipod/component";
import { defineScheduler } from "@helipod/scheduler";
import crons from "./helipod/crons";

export default defineConfig({ components: [defineScheduler({ crons })] });

The report's action-needed entry for crons points you at exactly this.

Going deeper

Moving data between deployments

helipod migrate export and helipod migrate import move an app's actual rows between two running deployments. Most commonly this means moving between a portable SQLite/Postgres/container deployment and a Cloudflare DO-native one, since those two store data in physically different topologies and data never teleports between them on its own.

Both are HTTP clients, modeled on helipod deploy, that call a running deployment's bearer-gated admin endpoints (GET /_admin/export, POST /_admin/import), authenticated with the same HELIPOD_ADMIN_KEY the dashboard and helipod deploy --allow-deploy use. Both the container serve path and the Cloudflare DO host expose the same /_admin/* handler, so one client works in either direction. A stopped plain-SQLite file has no HTTP endpoint of its own: point a throwaway helipod serve/helipod dev at its data directory first, export from that, then tear it down.

helipod migrate export --url <source-url> --out dump.json
helipod migrate import --url <target-url> --in dump.json
FlagApplies toDefaultMeaning
--url <url>both(required)The deployment to export from / import into
--out <file>export(required)Where to write the dump
--in <file>import(required)The dump file to read
--admin-key <key>both$HELIPOD_ADMIN_KEYOverrides the env var

HELIPOD_ADMIN_KEY (env or --admin-key) is required for both verbs. Missing it fails immediately, before any network call:

$ helipod migrate export --url http://localhost:3210 --out dump.json
 HELIPOD_ADMIN_KEY is required (or pass --admin-key)

A wrong key against a real deployment gets a clean 401, not a stack trace:

$ helipod migrate export --url http://localhost:3210 --out dump.json --admin-key wrong
 unauthorized, check HELIPOD_ADMIN_KEY / --admin-key

Export

$ HELIPOD_ADMIN_KEY=… helipod migrate export --url https://source.example.com --out dump.json
 exported 5 documents, 2 index rows dump.json

The dump is a self-describing JSON file:

dump.json (shape)
{
  "format": "helipod-migration-dump",
  "documents": [ /* every document across every table, with its real _id and _creationTime */ ],
  "indexUpdates": [ /* … */ ],
  "tableNumbers": { "messages": 3, "users": 4 }
}

Import

$ HELIPOD_ADMIN_KEY=… helipod migrate import --url https://target.example.com --in dump.json
 imported 5 documents, 2 index rows

Import is not a merge

Import targets a fresh deployment. It is not a merge into existing data, and it is single-shard only. Deploy the matching schema on the target first (same tables, same declared shape). Import gates on a table-number collision guard and rejects outright, with everything left untouched, if the dump's table numbers don't match the target's:

$ helipod migrate import --url https://target.example.com --in dump.json --admin-key
 import failed: wrong table number for "messages" (dump has 12348, target has 3)

A rejected import never applies partially. The target is exactly as it was before the call. Once a dump imports cleanly, the target is immediately live: new mutations commit on top of the imported rows without a timestamp collision, and every row, including _id and _creationTime, reads back byte-identical to the source.

After migrating

Work through MIGRATION-REPORT.md

Go top to bottom. Every action-needed and unsupported entry names the exact file, line, and fix.

Run helipod dev and exercise the app

A broken action-needed fix (for example a leftover .withIndex) surfaces as a load error immediately, not a silent runtime divergence.

Port your tests, if you have them

helipod migrate rewrites only your app's own function-directory source (renamed from convex/ to helipod/ as part of the same run). It does not touch convex-test call sites. Switch those to @helipod/test yourself. The fixture shape (createTestHelipod) is different enough that it isn't auto-rewritten.

Finish the scheduler config, if one was scaffolded

Finish wiring it per the crons section above and add its packages to package.json.

Compatibility at a glance

One table for "will my app port?". Every "not available" row is exactly what the divergence scan flags at the call site, so nothing here surprises you at runtime.

FeatureConvexhelipod
Queries / mutations / actions / internal functionsYesShipped, same ./_generated/server authoring shape
Argument and return validators (v.*)YesShipped
ctx.db.get / insert / replace / deleteYesShipped
ctx.db.patchYesNot available: read, spread-merge, replace
.withIndex / .filter(q => ...) / .first() / .unique()YesNot available: chain .eq/.gt/.gte/.lt/.lte/.order/.where/.take(n) on ctx.db.query(table, index)
Pagination{ page, isDone, continueCursor }Shipped, named { page, hasMore, nextCursor, scanCapped }
Reactive subscriptionsYesShipped, range-precise invalidation
Optimistic updatesYesShipped, Convex-verbatim withOptimisticUpdate (promise resolves at commit, see below)
Durable offline mutationsNo first-party equivalentShipped: durable outbox, client-supplied ids, cross-tab rendering
HTTP actions (httpRouter/httpAction)YesShipped (no automatic CORS, path params, or middleware, by design)
ctx.scheduler.runAfter/runAt and cronsYesShipped via the @helipod/scheduler component, composed explicitly
Durable workflows@convex-dev/workflowShipped via @helipod/workflow, including saga/compensation
File storage (ctx.storage)YesShipped (two-phase uploads; store/get are action-only)
AuthConvex Auth, or third-party JWT@helipod/auth (sessions, OAuth, JWT/OIDC, MFA, passkeys); Convex Auth itself doesn't port
Convex Components (app.use)YesDifferent model: compose @helipod/* components in helipod.config.ts
Full-text search / vector searchYesNot built; the migrator flags usage as unsupported

Honest compatibility notes

Near-free, by design, not a coincidence. helipod's runtime already executes Convex-shaped query/mutation/action functions natively, so migrating is overwhelmingly an import-specifier rewrite plus _generated/ regeneration, not a rewrite of your handlers, your schema shape, or your client code.

The divergences that do exist are real, not cosmetic, and the scanner is built specifically to catch every one of them at the call site rather than let them surface later as a runtime bug:

  • No ctx.db.patch. helipod's writer is insert/replace/delete only. A Convex patch call becomes a read, spread-merge, and replace. See Mutations.
  • No .withIndex(...). helipod's query builder is ctx.db.query(table, index) with chained .eq/.gt/.gte/.lt/.lte/.order/.where. See Queries.
  • A mutation's promise resolves at commit, not at gate-time. If your client code relied on a Convex-specific gate-time resolution timing for optimistic-update sequencing, revisit it against Optimistic updates's documented promise-timing migration note.
  • No full-text or vector search. .searchIndex(...)/.vectorIndex(...) have no helipod equivalent. The scan flags every occurrence as unsupported rather than silently dropping it.
  • Crons need an explicit component. cronJobs() only takes effect once composed into helipod.config.ts via defineScheduler({ crons }). Convex's implicit registration doesn't carry over.
  • ctx.auth is a string token, not a JWT-claims object, resolved via a context provider (e.g. @helipod/auth) rather than baked into the runtime.
  • Convex Components and Convex Auth don't map 1:1. Recompose the equivalent helipod component(s): @helipod/auth, @helipod/scheduler, @helipod/workflow, @helipod/triggers, @helipod/notifications, via helipod.config.ts.

@helipod/* is the canonical surface, permanently. This isn't a temporary migration artifact. helipod's own default function directory is helipod/, not convex/; convex/ was never the target, only where a Convex app starts from, and helipod migrate renames it as part of the same run. There's no alias path back to convex/* imports to maintain: once migrated, you're simply writing a native helipod app.

  • What is helipod?: why the canonical surface is @helipod/*, not convex/*.
  • CLI reference: the terse flag reference for every helipod subcommand, including migrate/migrate export/migrate import.
  • Testing: @helipod/test vs. convex-test.
  • Scheduling: the full cronJobs()/defineScheduler() surface a migrated crons.ts needs.
  • Cloudflare: why migrate export/import exists, to move data between the portable and DO-native storage topologies.
  • Queries and Mutations: the real ctx.db surface a migrated app's .withIndex/ctx.db.patch call sites need to target.

On this page