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
- The
convexnpm package, unchanged:convex/values,convex/server, theConvexClientandConvexHttpClientclients and theconvex/reacthooks. The sync route accepts any client version. - Folders and files:
convex/schema.ts,convex/*.ts,convex/_generated/,convex/http.ts,convex/crons.ts,convex/auth.config.ts,convex.json. query,mutation,action,internal*,httpAction,v.*validators,ConvexError,ctx.runQuery/runMutation/runAction,ctx.scheduler,ctx.storage,ctx.auth.getUserIdentity(),paginate,withIndex,withSearchIndex,ctx.vectorSearch,process.env,"use node".- Reactivity: subscriptions re-execute on their own and deliver the new value, without reconnecting. Every session sees all queries at the same instant (per epoch).
- One transaction per mutation, with read-your-own-writes, rollback on error and retry on conflict (OCC).
- Export and import in the same zip as
npx convex export, in both directions, with the same_ids.
What changes in the tooling
| Convex | NotusBase |
|---|---|
npx convex dev | notusbase 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 deploy | notusbase deploy (generates types, runs tsc, bundles and deploys; no downtime). --plan shows what would change without deploying |
npx convex codegen | notusbase 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 set | notusbase 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 logs | notusbase logs (follows live; --since reads the past from pb_data/notusbase.log) |
npx convex export/import | notusbase export / notusbase import (--replace replaces, --skip leaves tables and components out). The zip is the same |
| Convex dashboard | PocketBase dashboard at /_/: data, status and functions, with arguments and logs. Variables live in notusbase env |
CONVEX_URL | The NotusBase server address. The client speaks the same protocol at /api/notusbase/sync (and /api/<version>/sync) |
| Dashboard deploy key | Derived 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 cloud | One 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
| Item | Status |
|---|---|
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-helpers | Run (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 log | Decided 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 mutations | Decided 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 column | Decided 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 dashboard | The dashboard does not distinguish null from absent; through functions it works |
| Quotas, paid plan, teams, preview deployments, metrics dashboard | Do not exist. There is /api/notusbase/stats and the status and functions screens at /_/ |
| Vector search at scale | It is a linear scan of whatever passes the filter: fine for hundreds of thousands of vectors |
Request streaming in httpAction | The body is buffered (up to 20 MiB). The response can be a stream |
What behaves differently
Ids and types
- Id format:
table:identifier(measured:tasks:06gwnncd74fdbvr881dx). In Convex it is an opaque string. Code that interprets the id (splitting it, comparing a prefix, storing it elsewhere) must not rely on the format.v.id("table")checks the table by prefix. The identifier has 20 characters and rises with the creation time, like a ULID (48 bits of milliseconds and 52 random, lowercase base 32): sorting by_idis sorting by creation. A document imported from Convex keeps the id it had. A_storageid stays random, because knowing it is what gives access to the file. _creationTime: an integer in milliseconds (measured:1793978499901). In Convex it is a number with a fraction.- An optional field becomes a JSON column. SQL NULL is
undefined, the textnullisnull. The consequence is the type ordering (above) and the slightly smaller 1 MiB ceiling (see 04). NaN,Infinity,-Infinity,-0: stored and returned as in Convex, in the same order (-Infinity, the numbers,-0,0,Infinity,NaN), in indexes, filters, live queries, arguments, return values, export and import. One exception:-0in a required top-levelv.number()orv.float64()field becomes0, because that field is a numeric column; thereeq(-0)finds nothing andeq(0)finds both. In an optional,v.any(), union or nested field-0stays-0. In the database, a numeric column holds±Infinityas REAL andNaNas the text'NaN'; a JSON column holds them as{"$float": ...}, as the Convex export writes them. The data screen shows them as the textNaN,Infinityand-Infinity, and saving such a record from that screen fails until the number is changed.- Field name starting with
$: rejected (Field name $x starts with a '$', which is reserved.). - Field order in the document may differ. Do not rely on it.
- String comparison:
gt,lteandeqcompare the whole value. Convex treats"a"as a prefix of"a\0b".
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
- A short mutation never fails on conflict. If it takes less than 20 ms and conflicts, it runs serially inside the writer. A long mutation tries up to 8 times in parallel and returns a conflict error. Details in 04.
- Single writer. There is one SQLite writer with group commit (up to 256 mutations per transaction). Write throughput is that of one core; reads scale with isolates.
- Mutation retried on reconnect: the server keeps the result of requests for one to two hours. A reconnected client resends its unanswered mutations first, in order, with the ids they had: those the server already received from that client get the stored result, however long the client takes to send them; the first request with a new id ends the resends, and from then on requests run without a lookup. After a server restart the process does not know the client: what it sends in the first 10 s after a reconnect (counted on arrival, not counting the time the server stops reading because the client's queue is full) is looked up among the results stored before the restart, on this reconnect and on the next ones, until those results are two hours old. A client that comes back after a restart and after those 10 s with an unanswered mutation runs the mutation again.
ctx.runMutationinside a mutation is atomic: if the call fails, only what it wrote is undone, including jobs it scheduled, even if the error is caught. Two parallelrunMutationcalls inside a mutation give an error.- Module state. A module variable is shared between calls (the isolate is reused). The deploy detects a module variable written by a function, warns, and that code starts running in a fresh isolate per call. A component's client created at the top of a file, as the package's guide writes it, is not counted:
new RateLimiter(components.rateLimiter, ...), that is, a class that comes from a package and takescomponents.<name>as its first argument. Calling its methods keeps the isolate reused; writing to a property of the client or replacing the variable still counts, and so does a class of the project. This is a net, not a proof: the deploy reads the project's code, not the packages', so a package whose client keeps data of the call on itself would hand it to the next call. The clients of@convex-dev/rate-limiter,@convex-dev/stripeand@convex-dev/better-authwere read and keep only their configuration, and a test calls each one as two users through the same isolate (notusbase/app/components/clients). A value a file copies fromprocess.envwhen it loads, the Stripe client's key included, followsnotusbase env set: when the environment changes, the code loads again in every isolate and in the"use node"process, and calls already running finish where they were.Math.randomis seeded and the clock stops during the function, as in Convex.
Reads and filters
filterbecomes aWHEREin SQLite. The discarded row never reaches JS, but it counts towards the 32,000-read limit, as in Convex (measured: with the ceiling at 5, afilterover 12 rows that returns 1 fails withToo many documents read). Its bytes do not count towards the 16 MiB.paginate: key-based cursor. In a mutation and in search it sees its own writes, each in its place. The cursor ends in the document's id. A cursor made before 2026-11-11 ended in a row number and is refused as a cursor of another query.- Text search: SQLite FTS5, kept in the same transaction as the write, so a query never sees a row without its index entry. Words joined by OR, the last one also as a prefix, ranked by FTS5's bm25. Pending writes of the mutation itself are seen, but without stripping accents when comparing (the FTS5 result strips them). Up to 1,024 results. A subscription runs again on any write to a row that passes its
eqfilters, whether or not the text has the words. ChangingsearchFieldunder the same index name rebuilds the index on deploy, writes waiting while it reads the table (twice if the deploy then fails and the old index is rebuilt), anddeploy --plansays so with the rows and an estimate measured on an M4. - Reactivity range: when in doubt the range gets bigger, so a query may re-execute unnecessarily, never miss an update. With 256 or more dirty documents on one key in the same epoch, it re-executes without testing the range.
- Shared query: subscriptions with the same path and arguments run once for everyone, as long as the function does not read
ctx.auth. If it starts reading it, each user gets their own query, for all arguments, until the next deploy. - Stale read in the official client:
ConvexClient.queryanswers from what it stored until the next transition when the same query has just finished. This is the client's behavior, not the server's. To read right after writing, useonUpdate(orConvexHttpClient).
Schema
- The server creates the table, column, index and
createdat boot and on deploy, only adding. A column that left the schema while documents still hold a value in it blocks the deploy, as in Convex (measured:column outside schema.ts: tasks.priority, with a value in 12 documents (refusal)). Once every document is empty there, the deploy passes, the field disappears from the documents and the column stays in the table: tables and columns are never dropped. The way through is in Changing the schema with data. A search or vector index removed from the code is dropped. - The deploy reads the whole table, in a separate isolate, when its type changed, and rejects a schema that the documents do not satisfy.
defineSchema(..., { schemaValidation: false })turns it off. A mutation from the old code, running during the deploy, can still write something the validation did not see. - Writes through the PocketBase dashboard go through the schema validator.
- An index on a nested field works:
.index("x", ["meta.level"])becomes an expression index (nested_<table>_<name>). notusbase_*tables belong to the server and do not show up inctx.db.query. Only_scheduled_functionsand_storageshow up, throughctx.db.system.
Errors
- In production, only
ConvexErrorcarries data to the client. Any other error becomes[Request ID: …] Server Error, and the detail goes to the log with the same id. That is how Convex works, but here it also applies to argument validation errors (measured:ArgumentValidationErrorand an invalid id arrive asServer Error). The reason goes to the log, with the rejected value hidden:notusbase logs --since 5m --text <Request ID>finds it. WithNOTUSBASE_DEV_ERRORS=1ornotusbase dev, the detail reaches the client (table in the quick start). - The error log carries
convex/file.ts:line:column(source map);ConvexErrorgenerates no stack.
Actions, scheduler, HTTP
- Action: 1 s of CPU per stretch between
awaits, 10 min in total. Action isolates are shared: whoever blows CPU or memory takes down the other actions in the same isolate (they come back with an error). In Convex each action is isolated. - A scheduled action runs at most once; if the server dies midway, it is left as failed. A scheduled mutation runs exactly once. The job joins the mutation's transaction, so if it rolls back, the job never existed.
- Cron in UTC. The next run is stored in the database, so a restart neither repeats nor advances it. A run that has not finished does not get a second copy.
httpActionlives at the root, next to the PocketBase routes (/api/...,/_/). Avoid paths that collide with them.fetchfrom inside an action does not reach loopback, private network or link-local (errorinternal destination ... refused; allow withNOTUSBASE_FETCH_ALLOW).notusbase devallows loopback, so a local test server works there and not inserve."use node"runs in a single Node process with all actions on the same event loop, with a memory ceiling for the whole process. It does not readpb_data, only writes toHOME, andfetchfollows the filter above, but it does not contain hostile code: run only code you wrote or reviewed.
Authentication
ctx.auth.getUserIdentity()works in all four contexts. It accepts (a) a PocketBase token from collections listed inNOTUSBASE_AUTH_COLLECTIONS(tokenIdentifier=pocketbase|users|<id>,subject= record id) and (b) a provider JWT inauth.config.ts(customJwtor OIDC).- Verified-token cache: 30 s. An expired token drops the identity at the right time; the session switches user without reconnecting.
- A PocketBase login that stops being valid drops at once. Deleting the record, or changing its password or
tokenKey(dashboard, records API,e.app.Deletein Go), refuses its token over HTTP right away, without waiting for the cache, and each open WebSocket session with that identity getsAuthErrorand closes: its subscriptions stop and the client must authenticate again. A token fromauth.config.ts(OIDC,customJwt) is not a record here and holds until itsexp, as in Convex. Deleting a whole login collection does not go through the record hooks: its open sessions hold until the token expires. - There is no verified
@convex-dev/auth. External providers (Clerk, Auth0) go throughauth.config.ts; see "Not verified". - Signup: closed by default in
notusbase serve, on every start.NOTUSBASE_SIGNUP=1opens it, ifusersis inNOTUSBASE_AUTH_COLLECTIONS.notusbase devalready turns both on, for testing. - Login collections: at most one PocketBase collection,
usersby default. The dashboard, the deploy and the environment do not create another, and the server refuses to start ifNOTUSBASE_AUTH_COLLECTIONSnames a collection that is not an auth collection. To separate audiences, use roles in a table, such asmembersin the modeling guide. If the app brings its ownuserstable, like Convex Auth, PocketBase's login collection steps aside and login lives in the library, checked throughauth.config.ts. - Client login: with
ConvexProviderWithAuthand your ownuseAuth.@convex-dev/authdoes not work here: a project's login is PocketBase'suserscollection, or the system's own code. PocketBase's routes and a signed-in client are in Login. The PocketBase token comes out withiatandjti, so the official client renews it on its own.
Files
generateUploadUrlsigns a 1 h, single-use link. The file goes to disk atpb_data/storage/_storage(outside the main SQLite) and a row in_storageholds the metadata. Deleting joins the transaction; the file leaves the disk after the commit.GETservesRange, with CSPsandboxandnosniff.ctx.storage.getUrlreturns a URL on the server itself.- The storage id is
_storage:<identifier>, in the sametable:identifierformat as the others.v.id("_storage")accepts it; code that passes the bare identifier fails validation.
Data
notusbase importreads the Convex zip with the same_ids, references,int64, bytes and files. The tables of a component installed inconvex/convex.config.tsgo into that component, with the same ids, andnotusbase exportwrites them back under_components/(since 2026-11-11). A component the project does not install, Convex Auth tables andusersare left out, with a warning and--skip._creationTimekeeps the fraction it had, and so the order: a table that receives ids from Convex gets a column with the exact time and an index on it, and reads by_creationTime, _idas Convex does. The NotusBase zip goes into Convex withnpx convex import --replace-all(measured on the local backend).- An external
sqlite3cannot runintegrity_checkon the live database because it does not knownotusbase_key: usenotusbase check --dir. - The database is SQLite with
synchronous=NORMALand a 1 s sync: a power loss can lose up to ~1 s of acknowledged commits . Self-hosted Convex with the same setting has the same window; cloud Convex does not.
Migration checklist
- Copy
convex/to a new project and runnpm install convex typescript. Do not change theconvexversion for no reason; tested with 1.49.0. - 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, acursorassumed to be a stable string, interpretation of the id format. - Run
notusbase dev, which shows error details. Read the warnings about module state and diverging columns. - Set the variables. The deploy lists them:
warning: N variables read in process.env with no value on the server: .... Set each one withnotusbase 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 anifor a? :, and a default inconst { X = "default" } = process.env. - Check the crons.
notusbase sql "select name, next from _crons"shows each one fromconvex/crons.tswith its next run. They run in UTC. - Write a test that depends on the result, not the status: see notusbase/example/test.mjs.
- Swap
CONVEX_URLin the clients for the NotusBase address. Test reconnection, server crash and subscription. - Review the limits in 04 against your heaviest queries: the 1 s of a query or mutation includes the database here.
- Move the data:
npx convex export --include-file-storage,notusbase importwith--skipfor what NotusBase does not import, and check the per-table counts it prints. - Production: proxy, backup, secret and origins, and the rules in 03.
Not verified
- An external authentication provider (Clerk/Auth0) with
auth.config.ts, and@convex-dev/auth. - Importing a production Convex export (it only ran with fake data on the local backend).
@convex-dev/*packages that are not components.- The
queuemodule against the original component on a real Convex: it only ran on NotusBase. - That Convex also hides validation errors in production: I read it in the documentation, I did not run it.