NotusBase

3. Modeling rules for products

For people building a product of their own, not a social network or a marketplace: the premise is that the data splits by customer, team or region. What is marked measured was run against the binary. The code for each rule is in the example project.

Rule 1 — tenantId on every table and every index, from the start

Why: the day a customer needs to move to another server or the day to delete a customer's data, the separation is already in the data. Later, separating requires migrating tables and functions.

// convex/schema.ts
orders: defineTable({
  tenantId: v.id("companies"),
  customer: v.string(),
  amountCents: v.number(),
  status: v.union(v.literal("open"), v.literal("paid"), v.literal("shipped"), v.literal("cancelled")),
})
  .index("by_tenant", ["tenantId"])
  .index("by_tenant_status", ["tenantId", "status"]),

Measured: tenantId does not cost reactivity. With a subscriber of tenant A and writes from tenant B, A's query did not re-execute a single time (5 writes from B → 0 re-executions), both with an index that starts with tenantId and with an unindexed filter. The reactivity range is precise. A's own write re-executed once in both cases. So the index rule is for read performance, not for reactivity.

Leak warning in notusbase dev

Forgetting the tenant filter in a query is the worst bug of a multi-customer system, and it raises no error: the screen just shows someone else's data. In notusbase dev (and only there), when a public query or mutation returns, from the same table, documents with more than one value of the customer field, the run gets a line in the function log:

[WARN] possible cross-tenant leak: orders:all returned, with a login, documents of table "orders" with 2 different values of tenantId. If the function returns several customers on purpose, list it in tenant.allow in notusbase.json
{ "tenant": { "field": "orgId", "allow": ["admin:*", "reports:allCustomers"], "off": false } }

Rule 2 — each transaction inside one group

Today a group lives in one database, and a mutation is one SQLite transaction. The day groups live on separate servers, a transaction does not cross two databases. So:

Rule 3 — files and logs outside the main database

A large database makes backup, checking and restore slower (checking needs free space equal to the database).

Rule 4 — reports and reads in SQL

A long SQL query straight on the live database holds up the checkpoint and competes for CPU and disk with the product. That is why product code has no SQL route: there is no ctx.sql. For operators, notusbase sql and the dashboard console read with row and time ceilings, one statement at a time, and reject writes. A v.id field stores table:id and the row's id has no prefix, so join through notusbase_id, which both know: JOIN teams te ON te.id = notusbase_id(t.tenantId). Plain sqlite3 on a restored copy does not know it; there, te.id = substr(t.tenantId, length('teams:') + 1). What works for reports:

  1. Report on a restored copy. Turn on NOTUSBASE_BACKUP=/folder, and when you need it:

    notusbase restore /backup/folder /new/folder
    sqlite3 /new/folder/data.db "select tenantId, count(*) from orders group by 1"

    Measured: the restored copy opens in plain sqlite3 and the columns have the schema's names (item(tenantId, n); documents in real columns, not in a JSON blob). Open it with plain sqlite3; -readonly and file:...?mode=ro failed on the restored WAL. The copy is as of the last WAL chunk: good for a report, not for a real-time decision. Use --until <RFC 3339> for a point in time. Never write to or checkpoint the live database.

  2. Derived data inside NotusBase. If the screen needs a total, keep it in a table updated by the mutation (reactive, typed and within the read limit). SQL has no reactivity.

  3. Export. notusbase export produces the npx convex export zip, with one folder per table (documents.jsonl), useful for an external ETL.

  4. Only required string and number become plain columns. v.optional, v.union, v.literal, objects and arrays become JSON columns; in SQL they show up as JSON text (measured in the example: status stores "paid" with the quotes, and receipt stores "_storage:..."). Use json_extract(status, '$') or compare with '"paid"'. An index on such a field is an expression index with notusbase_key(...) (nested_orders_by_tenant_status), which plain sqlite3 does not know: select tenantId, json_extract(status,'$'), count(*), sum(amountCents) from orders group by 1,2 ran without error on the example's database (server stopped), but a plan that depends on that index may fail. A field that was born optional stays a JSON column even after it becomes required, so the quotes stay too, and json_extract on an empty value gives malformed JSON. Changing the schema breaks the report without warning: version the SQL together with the code.

  5. Live integrity_check only through notusbase check --dir, because plain sqlite3 does not know notusbase_key.

Rule 5 — access and roles

Rule 6 — queries that do not blow the limit

Rule 7 — changing the schema with data

<a id="changing-the-schema-with-data"></a>

The deploy validates the documents that already exist against the new schema and refuses when they do not fit. With test-only data, delete pb_data. With data that matters, every change goes through the same three moves: loosen the schema, migrate the documents, tighten the schema. Each move is one deploy, and the system keeps running between them. The Convex component @convex-dev/migrations, from npm and unchanged, does the middle one in batches, with a dry run.

All three cases below were run on 2026-11-09 on a project with 12 tasks, under notusbase dev, which deploys on every saved file, with the migrations module that came before the component; the deploy messages are the same. On a server, each step ends with notusbase deploy.

Before the first migration, install the component once (npm install @convex-dev/migrations):

// convex/convex.config.ts
import { defineApp } from "convex/server";
import migrations from "@convex-dev/migrations/convex.config";
const app = defineApp();
app.use(migrations);
export default app;
// convex/migrations.ts
import { Migrations } from "@convex-dev/migrations";
import { components } from "./_generated/api";
import type { DataModel } from "./_generated/dataModel";

export const migrations = new Migrations<DataModel>(components.migrations);
export const run = migrations.runner();

A new required field

Going straight to priority: v.number() is refused:

Deploy refused: document tasks:... does not satisfy the new schema. Fix or delete the document, or make the field optional.
  1. Add the field as optional, priority: v.optional(v.number()), and make the code that creates documents write it. Deploy.

  2. Fill the old documents:

    export const fillPriority = migrations.define({
      table: "tasks",
      migrateOne: (ctx, task) => {
        if (task.priority === undefined) return { priority: 1 };
      },
    });
    notusbase run migrations:run '{"fn": "migrations:fillPriority", "dryRun": true}'
    notusbase run migrations:run '{"fn": "migrations:fillPriority"}'

    The dry run runs one batch and undoes it, and the log shows up to 10 { before, after } pairs. The real run goes on in the background, batch by batch; the same command again answers with its state. These commands, on the same migration, run in notusbase/app/components/migrations/test.mjs.

  3. Make it required, priority: v.number(). Deploy.

Renaming a field

text becomes title. The old field has to be emptied before it can leave the schema, and a required field cannot be emptied, so both are optional in the middle.

  1. In the schema, text: v.optional(v.string()) and title: v.optional(v.string()). The code writes title and reads task.title ?? task.text. Deploy.

  2. Move the value:

    export const renameText = migrations.define({
      table: "tasks",
      migrateOne: (ctx, task) => {
        if (task.text !== undefined) return { title: task.text, text: undefined };
      },
    });
    notusbase run migrations:run '{"fn": "migrations:renameText"}'
  3. In the schema, title: v.string() and no text; the code reads only title. Delete renameText in the same change: it mentions task.text, which no longer exists in the types, and tsc stops the deploy with TS2339: Property 'text' does not exist. Deploy:

    plan: column outside schema.ts: tasks.text, empty; it disappears from the documents and stays in the table

Removing a field

Taking a field out of the schema while documents still hold a value is refused, and this one the plan does show:

plan: column outside schema.ts: tasks.priority, with a value in 12 documents (refusal)
Error: the deploy would be refused: see the refusal lines above. Nothing was deployed
  1. Make the field optional and stop writing it. Deploy.
  2. Empty it, with a migration whose migrateOne returns { priority: undefined } for each document that still has a value. Run it.
  3. Remove the field from the schema, from every index that lists it, and delete the migration. Deploy.

What stays behind

One-screen summary

RuleIn one sentence
1tenantId on every table, first field of every index, derived from the login
2One mutation, one tenant; what crosses becomes an idempotent job
3Files through ctx.storage, audit with cleanup, logs in notusbase.log
4Reports on a restored copy; live totals in a table
5members, internal*, ConvexError, closed signup
6Index, take/paginate, batches
7A schema change with data goes in steps: optional, migrate, required