Rules for writing a system on NotusBase
One page for whoever writes a system on NotusBase, human or AI. It ships inside the binary and says what that version does; the pages of the guide cited along the way explain each part at length.
NotusBase runs functions in the Convex format (convex/server, convex/values and the convex client, unchanged) on PocketBase and SQLite, in a single binary. What differs from Convex is in 2. Migrating from Convex.
Project
- Start with
notusbase init <folder> --port <port>,npm install,notusbase devandnpm test, as in the quick start.initbrings a schema, two functions and a one-checktest.mjs; tenants, members, login and the UI are the system's own.notusbase.jsonholds the address andpb_data, and only accepts a local address. - Three things in the project follow the binary, rewritten by
notusbase codegen,devanddeploywhen the binary changes:kit.mjs(do not edit it: an edited one is left as it is, with a warning, and stops following), the modules innode_modules/notusbase-modules, and this page. notusbase initwrites this page as the project'sCLAUDE.md, if there is not one yet, between the marksnotusbase:rules:startandnotusbase:rules:end. Only what is between the marks is rewritten: add the system's own rules after the end mark. ACLAUDE.mdwithout the marks is never touched.- What exists is answered by the binary, for its own version:
notusbase help functions(what a function can call),examples(code that runs),modules,kit,config(notusbase.json),variablesandcommands;notusbase <command> --helpbrings the long text and examples of one command. Look a name up there before using it. The same text is the reference page. convex/tsconfig.json:"lib": ["ES2022", ...]and"types": ["node"], the bundle target, asnotusbase initgenerates it. One generated earlier with ES2021 is raised on the nextcodegen: with it,tscrefuses.at().- Root
tsconfig.json, with Vite:"types": ["vite/client", "node"]. The UI imports_generated/api, which reaches the functions, and withoutnodetscdoes not knowprocess. - Do not edit
convex/_generated/: it is rebuilt on every publish. - A function that calls another of the same file through
internalorapi(ctx.scheduler.runAfter,ctx.runMutation,ctx.runQuery) gets its return type written on the handler (async (ctx, args): Promise<void> =>). Without ittscstops withTS7022, as in Convex, andnotusbase deployrefuses. - Convex components from npm run unchanged: install them in
convex/convex.config.tswithdefineAppandapp.use, and call them throughcomponents.<name>. Inside a component there is noctx.scheduler,ctx.storage,crons.ts,http.ts,"use node", function handle orctx.db.vars.commitTsyet: a component that needs one of them fails saying so. The built-in modules, below, need none of this. Create a component's client at the top of the file, as its guide shows (new RateLimiter(components.rateLimiter, ...)): it does not count as module state. Never store anything of a call on it. - Module state is shared across calls and across users: the isolate is reused, so a variable at the top of a file keeps what the last call left in it, for whoever calls next. Keep a call's data in the handler and in
ctx, never in a module variable, a cache object or a class field at the top of a file. The deploy looks for a module variable written by a function and runs that file in a fresh isolate per call, but it is a net, not a proof: it does not see an alias (const c = cache; c.x = 1), a write inside a library, or the call's data a component's client keeps on itself. More in 2. Migrating from Convex, "Module state".
Modules
Besides components, NotusBase has modules that ship inside the binary. There is no npm package to install: the import turns one on. notusbase init, codegen, dev and deploy write them to node_modules/notusbase-modules/<name>/, each with its README.md and the comments on every function, for tsc, the editor and you to read. npm install deletes that folder, and the project's postinstall (notusbase codegen) brings it back.
| Module | Import | For |
|---|---|---|
queue | notusbase-modules/queue | Work queue: parallelism limit, retries with backoff, notice on completion |
email | notusbase-modules/email | Transactional email through the queue (needs queue enabled) |
certificates | notusbase-modules/certificates | Client certificate (A1): fetch with mutual TLS and XML signature. A library: the import is enough |
Rate limits, Stripe and data migrations come from npm, as Convex components: @convex-dev/rate-limiter, @convex-dev/stripe and @convex-dev/migrations, installed with app.use as each package's README shows. Until 0.3 they were modules here (ratelimit, stripe, migrations); an import of one stops codegen and deploy naming the package to install.
A module with tables is enabled in two files, and then imported wherever it is used. With queue; email takes the same shape, with its own name:
// convex/schema.ts
import { queueTables } from "notusbase-modules/queue/schema";
export default defineSchema({ ...queueTables, /* yours */ });
// convex/queue.ts: the file takes the module's name
export * from "notusbase-modules/queue";
// convex/messages.ts, or any other file
import { Queue } from "notusbase-modules/queue";
There is no other path: notusbase/queue, @notusbase/queue, convex/queue and ./queue without the file above do not exist, and codegen and deploy stop saying so. What each module exports, with examples, is in node_modules/notusbase-modules/<name>/README.md.
Modeling
- Multi-customer system:
tenantIdon every data table and as the first field of every index. A single-user system goes without it, and the rule comes back before the second user. tenantIdcomes from the logged-in user, never from the argument. Afterctx.db.get(id), check the document'stenantId.- In
notusbase dev, a public query or mutation that returns documents with more than onetenantIdfrom the same table gets a[WARN]line in the log. A different field, an admin function that returns several customers on purpose, and turning it off:tenantinnotusbase.json(field,allow,off). Nothing runs in production. - A mutation writes to one tenant only. What crosses tenants becomes an idempotent job, scheduled in the same mutation.
- Every table that grows is read through
withIndex, withtake(n)orpaginate. Nevercollect()without a ceiling, norfilterwithout an index on a large table: as in Convex, a row the filter discards was read, and counts toward the 32,000-document ceiling. - A total the screen shows lives in a table, updated in the same mutation. Do not sum thousands of rows at read time.
- Files through
ctx.storage, with an upload link for large files. The document stores only thev.id("_storage"). - Changing the schema with data that matters takes three deploys: loosen, migrate, tighten. A new required field: make it
v.optional, fill it in, then make it required. The deploy rejects a document that does not satisfy the new schema. - The middle step is the
@convex-dev/migrationscomponent's, from npm: it goes through the table in batches, each in its own mutation, keeps the cursor, resumes after a stop and has a dry run (notusbase run migrations:run '{"fn": "migrations:<name>", "dryRun": true}'). One mutation of your own only reaches 16,000 writes. How to install it and define one: 3. Modeling. - Removing a field: the deploy rejects it while any document has a value in it. Make it
v.optional, publish, delete withpatchandundefined(a migration, on a large table), and remove it afterwards. Before publishing a schema change, runnotusbase deploy --plan. To undo a deploy,notusbase deploy --rollback [number]goes back to one of the last 10 bundles the server keeps (--historylists them,--planrehearses); it goes through the same checks and refuses if the old schema does not cover the data. - Details and examples: 3. Modeling.
Access and login
- The identity is
ctx.auth.getUserIdentity(). Usesubject, which is the record id inusers. Never trustemail. - A
memberstable (subject, role,tenantId, indexby_subject) links the person to what they see. Without a row there, the function returns nothing. - Everything the client does not call is
internalQuery,internalMutationorinternalAction. - A public function either reads the login and refuses when the identity is null, or says why it is open with
// notusbase-public: <reason>on the line above itsexport.notusbase deploy,--planand the dashboard's Functions screen list the public functions whose code never reachesgetUserIdentity()(directly or through a helper in the project); the functions ofconvex/auth.tsare the sign-in and stay out of the list. - Signup: open in
notusbase dev, closed on every start ofnotusbase serve.NOTUSBASE_SIGNUP=1opens it. The first user with signup closed comes fromnotusbase user add <email>, with the server up: it goes into the login collection that already applies (NOTUSBASE_AUTH_COLLECTIONS=users), and the command never creates one. - Never a password on the command line:
notusbase user addandnotusbase superuser upsert <email>, run in the project's folder, ask for it in the terminal, with no echo, or read it from stdin, on one line (< file). - In the UI, the login form uses
onSubmitwithpreventDefault. React 19'sformActionclears the fields after an error. Show the failure in your own text, notFailed to authenticate.
Errors
- A message for the user goes in
ConvexError, with a code:throw new ConvexError({ code: "NO_PERMISSION" }). The UI readse.data.code. - Any other error reaches the client as
[Request ID: …] Server Error, as in Convex. The reason, with file and line, stays innotusbase logs:notusbase logs --since 1d --text <id>, or the error pasted into the Text field of the functions screen. Innotusbase dev, or withNOTUSBASE_DEV_ERRORS=1, the detail shows up. - An argument error and an invalid id also become
Server Errorin production. The rejected value stays hidden in the log.
Secrets
- Create keys or tokens in an action and keep only the hash, through an internal mutation. A mutation's result is kept in
_requests_<hour>for 1 to 2 hours, to answer a repeated request, encrypted with a key only the client's session has: it is not in clear text in the database, the WAL or the backup. An action's result is not kept. - Environment variables through
notusbase env set, read fromprocess.env. The deploy warns about those read in the code with no value on the server, without blocking: check at the start of the function and fail with a clear message. - A client certificate (A1
.pfx) never goes in an environment variable:notusbase certificate set, thenfetchWithCertificateandsignXmlfromnotusbase-modules/certificates, by name, as in Client certificates. - A webhook in
http.tschecks a secret in the header. A new route lives outside/api/and/_/. The caller's address is inX-Forwarded-For, a single address written by NotusBase (the proxy's with--behind-proxy, the connection's without it): never decide access byHostor by "it came from 127.0.0.1".
- Use the
emailmodule (node_modules/notusbase-modules/email/README.md), withqueueenabled:email.send(ctx, { to, subject, html })in a mutation writes the request in its transaction, and the send goes out through the queue, with retries. Never call SMTP or a provider'sfetchinside the business mutation. EMAIL_PROVIDER(notusbase env set):smtpuses the SMTP enabled in the PocketBase panel;resendusesRESEND_API_KEYandEMAIL_FROM. Without it, in production, the email ends upfailedwith the message on how to configure it.- An email the app sends more than once is a template:
template({ args, subject, html, text?, example })with thehtmltag, which escapes the variables, listed innew Email({ templates, layout })and sent withemail.send(ctx, { to, template: "name", data }).passwordReset(url,name?,minutes?) andaccountInvite(url,name?,days?) ship ready. Always passdata: theexampleis only for the preview. An email that carries a secret (a token) getsredact: true, which clears its body fromemail_messagesonce sent; both ready templates already have it. Exportemail.previews()inconvex/email.tsso the panel's Email screen shows the preview. - Each email's status is in
email_messages(queued,sent,failed,captured). Undernotusbase devnothing goes out: it stayscaptured, visible in the panel.sentmeans the provider accepted it, not that the inbox received it.
Limits
Convex's, per call: 1 s wall clock in query and mutation, 1 s of CPU per stretch in an action and 10 min in total, 32,000 documents and 16 MiB read, 16,000 writes and 16 MiB written, 1 MiB documents, 1,000 scheduled jobs per mutation, 64 MiB of heap. Read and write errors can be caught; time and memory cannot. Bulk writes go in batches chained by ctx.scheduler.runAfter(0, ...). Full table in 4. Limits.
- A scheduled action runs at most once. A scheduled mutation runs exactly once. A step that has to happen is a mutation.
- Actions share an isolate. One that blows CPU or memory gets the error alone, and the isolate is replaced after its neighbors finish; if it blows again while they finish, they fail too. Keep actions short.
- An action's
fetchdoes not reach loopback, the private network or a link-local address withoutNOTUSBASE_FETCH_ALLOW.notusbase devallows loopback by itself;servedoes not.
Data and server
- The live database goes to disk every 1 s (
PB_WAL_SYNC_MS, 1000 by default inserveanddev; startup logs the value). Do not set 0: it turns off the sync, and a power loss can lose more than 1 s. - Continuous backup with
NOTUSBASE_BACKUP, on another disk. Each chain carries the uploaded files, andnotusbase restore <backup dir> <new dir>brings them back, into a new folder. - To reproduce a production bug, run
notusbase copy isolated --mask --backup <folder> --function module:function --args '{...}'in the project folder: it fetches the backup, swaps email, phone, name, CPF and CNPJ for fake ones (by field name, plusmask.fieldsinnotusbase.json, as"table.field": "email") and sweeps the free text, empties_envand credentials, starts isolated and runs the function. Storage files do not come along, and the failure log does not keep the arguments: pass--args. To develop on production's data,notusbase copy development(always masked); to rehearse a deploy,notusbase copy isolated --rehearse. It never starts with production's environment, S3, SMTP, webhook or alert variables: the command refuses (notusbase copy --help). notusbase servedoes not serve the UI. In development, Vite on another port with--origins; in production, Caddy.
How to test
- Test through the kit,
kit.mjsat the project root, like the project'stest.mjs:setupstarts a disposablenotusbase serveon a free port, with its data in the project'stmp/kit/, publishes, creates the test user and returns theConvexClientwith login. Do not rewrite startup or test login in every system. Everything the kit exports is described inkit.mjsitself.npm testtakes the binary fromNOTUSBASEor thePATH. - The kit's client does not print: a call the test expects to be refused arrives as the exception, with
e.datafor aConvexError. - More users with
t.addUser(); an internal function witht.run("module:function", args); a scheduled job witht.waitJobs(). The cron is not waited for:t.runCron(name)fires it right away and waits for its job. - Deadlines (reminder, overdue charge, 7-day cleanup):
t.advance("2h")(or"1h30m","3d", or ms) advances the test server's clock and waits for the due jobs and crons to finish. The functions'Date.now()andscheduledTimemove together; a cron that missed several occurrences runs once. Only on the kit's server (NOTUSBASE_TEST_CLOCK=1): in"use node", login tokens and file links the time is the real one. On the command line,notusbase cron run <name>andnotusbase job run <id>run a cron or a pending job now (deploy key); the dashboard has the "Run now" button. - With the
emailmodule, the kit captures instead of sending:t.emails()returns the captured emails, andt.emails({ status: "failed" })the ones that failed. Nothing leaves the test. - After
waitJobs, check through the subscription (onUpdate) or repeat withwaitUntil: aclient.queryright after may bring the result stored from a subscription. - A business error is checked in
e.data.code. The rest, innotusbase logs --since 5m, which takes a few seconds to show the line. - A large argument in
notusbase rungoes through@file.json, or-to read from stdin:argvstops near 1 MiB. - Data is checked with
notusbase sql "select ...", read-only. Optional fields,unionand objects come as JSON: usejson_extract.
AI with the NotusBase MCP
notusbase mcp is a development MCP server that ships inside the binary and runs on Node, with no dependencies. It lists the functions, public and internal, with their arguments and what they return, shows the schema, reads logs, runs a function, runs read-only SQL queries, lists variable names (never the values), shows the job and cron schedule, runs a cron or a pending job now, publishes (or only shows the plan), goes back to a saved version and, with docs, prints the same reference as notusbase help. It only talks to 127.0.0.1, localhost or [::1], and uses the deploy key from the local pb_data. Never point it at production.
notusbase init writes .mcp.json in the project, with the command notusbase mcp: Claude Code reads it from the project folder and asks once whether to use it, and claude mcp list shows whether it started. In a project from before that file, create it:
{ "mcpServers": { "notusbase": { "command": "notusbase", "args": ["mcp"] } } }
The address and pb_data come from notusbase.json; NOTUSBASE_URL, NOTUSBASE_DIR and NOTUSBASE_DEPLOY_KEY override them, and NOTUSBASE_PROJECT is the folder with convex/ when the server does not start from it. The tools need notusbase dev up, except docs. rollback changes what is published: it only runs with confirm: true, and without it returns the saved versions.