NotusBase

2. Migrating from Convex

Every difference below was checked against the code, and the ones marked measured were run against the binary. What could not be checked is under "Not verified" at the end. License reminder: the Convex backend is FSL. NotusBase copies behavior, never code.

What stays the same

What changes in the tooling

ConvexNotusBase
npx convex devnotusbase dev (starts the server, deploys again on every saved file, generates types). The address and the data folder come from notusbase.json, which notusbase init writes
npx convex deploynotusbase deploy (generates types, runs tsc, bundles and deploys; no downtime). --plan shows what would change without deploying
npx convex codegennotusbase codegen
npx convex run fn '{}'notusbase run fn '{}'. Every command finds the local server through notusbase.json; a remote one goes through --to production (an entry in remotes of notusbase.json; the first time, ssh asks to confirm the new host, its own host-key question, and a known host, or StrictHostKeyChecking no, asks nothing), --url or NOTUSBASE_URL, with the key in --key or NOTUSBASE_DEPLOY_KEY
npx convex env setnotusbase env (lists the names and the environment of each; --values shows the values), notusbase env set <name> (reads the value from the terminal, without echo, or from stdin), notusbase env remove <name>, or the _env table in the dashboard. Changing a value re-executes all subscriptions
npx convex logsnotusbase logs (follows live; --since reads the past from pb_data/notusbase.log)
npx convex export/importnotusbase export / notusbase import (--replace replaces, --skip leaves tables and components out). The zip is the same
Convex dashboardPocketBase dashboard at /_/: data, status and functions, with arguments and logs. Variables live in notusbase env
CONVEX_URLThe NotusBase server address. The client speaks the same protocol at /api/notusbase/sync (and /api/<version>/sync)
Dashboard deploy keyDerived from the secret in pb_data. On the same machine the commands find the key on their own; from outside, notusbase deploy-key shows it, or use a PocketBase superuser
Managed cloudOne binary (notusbase) and the pb_data folder. Backup, proxy, TLS and updates are yours, with scripts for each in the Linux package

Without "use node", the server does not need Node. With the directive, it needs Node on the server (NOTUSBASE_NODE) and, for external packages, NOTUSBASE_NODE_PATH.

Without the directive, each npm package is resolved as in Convex, with the convex, module and browser conditions, the browser, module and main fields and the browser field map. That is how stripe and jose work in the version without node:*. The difference is process.env.NODE_ENV: Convex pins "production" in the bundle, and here the value comes from notusbase env.

What does not exist

ItemStatus
components (defineApp, app.use, defineComponent, components.*)Runs, in part (since 2026-11-11). convex/convex.config.ts with app.use(...) installs a component from npm, unchanged, and components.<name> calls it. Each instance has its own tables (component__<name>__<table> in the database), its functions cannot be called by a client, and only the ones it declares public can be called by the project. A component schedules its own functions with ctx.scheduler, reads its scheduled jobs with ctx.db.system.get, has its own crons.ts, and calls back the project's functions through function handles (createFunctionHandle). A component has its own environment variables: inside its queries and mutations process.env holds only what .use(child, { env: { NAME: "text" } }) or { env: { NAME: app.env.OTHER } } gave it, and nothing of the project's; the env has to be written in place. Inside a component's action, and while its files load, process.env is still the project's, and the validators a component declares for its variables are not checked. Tested with @convex-dev/rate-limiter (default mode), @convex-dev/stripe (webhook and queries), @convex-dev/better-auth, @convex-dev/crons, @convex-dev/action-retrier, @convex-dev/workpool, @convex-dev/resend, @convex-dev/migrations (0.3.8) and @convex-dev/workflow (0.4.10: steps, steps in parallel, a step that fails, sleep and cancel, the lists of workflows and steps, cleanup; events, nested workflows and restart were not tested). Also run as two real projects install them (2026-11-11): a local component made with @convex-dev/better-auth 0.12.4 and createApi, and @convex-dev/resend 0.2.5 with the workpool 0.4.8 inside it, whose runSnapshotQuery goes through Convex's raw 1.0/runUdf syscall; and @convex-dev/stripe 0.1.2. Its data goes in and out with notusbase import and notusbase export in the Convex zip, and the panel lists its tables in a group of its own. Not yet, inside a component: ctx.storage, http.ts, "use node", ctx.db.system.query, ctx.db.vars.commitTs and httpPrefix. That leaves out, today, aggregate. @convex-dev/resend was tested on Linux only and on NotusBase only, against a local server answering as api.resend.com (the package has that address fixed in its code), never against Resend itself; outside production the server only lets it out with NOTUSBASE_FETCH_HOSTS=api.resend.com. For those there are the modules in notusbase/modules, embedded in the binary: ratelimit (@convex-dev/rate-limiter), queue (workpool and action-retrier), stripe and migrations
paginator and stream of convex-helpersRun (since 2026-11-11). They walk an index bounding it by _id after _creationTime, because in Convex every index ends in _creationTime, _id. Here it does too: the id rises with the creation time, so documents of the same millisecond come out in id order, and a range may go down to _creationTime and _id. @convex-dev/migrations at its current version, which uses the paginator, runs in the suite
Per-call action logDecided not to build: v8go has no promise hook. The action log is attributed by execution slice, so concurrent calls in the same isolate may swap lines
Range conflicts between mutationsDecided not to build: conflicts are per key, so two mutations on the same index key may retry unnecessarily. It only costs performance
Ordering of null and boolean in a JSON columnDecided not to build: in an optional field, null and booleans sort after numbers (in Convex, null comes first)
null in a JSON field through the PocketBase dashboardThe dashboard does not distinguish null from absent; through functions it works
Quotas, paid plan, teams, preview deployments, metrics dashboardDo not exist. There is /api/notusbase/stats and the status and functions screens at /_/
Vector search at scaleIt is a linear scan of whatever passes the filter: fine for hundreds of thousands of vectors
Request streaming in httpActionThe body is buffered (up to 20 MiB). The response can be a stream

What behaves differently

Ids and types

ctx.db.get("table", id)

Both forms work, in get, patch, replace and delete: with the id alone and with the table name before it. An id from another table gives an error, as in Convex.

Writes and conflicts

Reads and filters

Schema

Errors

Actions, scheduler, HTTP

Authentication

Files

Data

Migration checklist

  1. Copy convex/ to a new project and run npm install convex typescript. Do not change the convex version for no reason; tested with 1.49.0.
  2. Look for what does not exist: components that need what NotusBase does not give a component yet (see the table above; the call fails saying what is missing), ctx.db.get(/patch(/replace(/delete( with two arguments, a cursor assumed to be a stable string, interpretation of the id format.
  3. Run notusbase dev, which shows error details. Read the warnings about module state and diverging columns.
  4. Set the variables. The deploy lists them: warning: N variables read in process.env with no value on the server: .... Set each one with notusbase env set <name>, which asks for the value. A read that already allows for no value is left out of the list: process.env.X ?? "default", || "default", &&, the test of an if or a ? :, and a default in const { X = "default" } = process.env.
  5. Check the crons. notusbase sql "select name, next from _crons" shows each one from convex/crons.ts with its next run. They run in UTC.
  6. Write a test that depends on the result, not the status: see notusbase/example/test.mjs.
  7. Swap CONVEX_URL in the clients for the NotusBase address. Test reconnection, server crash and subscription.
  8. Review the limits in 04 against your heaviest queries: the 1 s of a query or mutation includes the database here.
  9. Move the data: npx convex export --include-file-storage, notusbase import with --skip for what NotusBase does not import, and check the per-table counts it prints.
  10. Production: proxy, backup, secret and origins, and the rules in 03.

Not verified