Custom Tables and Model Extension
The pre-built ERP tables cover general business processes, but every industry has its own data — gyms track body measurements, travel agencies track tour packages, tutoring centers track class attendance. This chapter covers how to extend the model.
Three extension paths
| Path | Suited to | Queryable | Type-constrained | Supports relations |
|---|---|---|---|---|
custom_data JSONB | A few unstructured extras | Limited | No | No |
| Extension fields | New columns on existing ERP tables | Yes | Yes | Yes |
| Custom tables | Entirely new entities | Yes | Yes | Yes |
The decision is simple: is this an attribute of an existing entity, or a new entity? A "membership card number" on a customer is the former (extension field); "class attendance records" is the latter (custom table).
Custom tables are organization-level
This is the most important — and most often misunderstood — property:
Custom tables belong to the organization, not to an application.
Every Custom App under the same organization, plus the organization's own Data Center interface, sees the same tables and the same rows.
That imposes one discipline: check for a reusable existing table before creating one. A single "customers" table should not fragment into two disconnected copies because two applications each created their own.
Dual naming: display name and physical name
Every table and every column has two names:
| Display name | Physical name | |
|---|---|---|
| Chosen by | You | Generated from the display name, or specified explicitly |
| Mutable | Any time | Never, once created |
| Character set | Anything | ASCII only |
| Used for | Interface labels | API identity, relation targets |
All APIs and SDKs address existing tables and columns by physical name. Display names are just labels — renaming one breaks nothing.
Physical-name generation avoids three namespaces: existing custom tables in the same organization, SQL reserved words, and the platform's built-in ERP table names. A display name of "Customers" in an empty environment yields customers_2, not customers, because the latter belongs to the ERP.
If you specify a physical name that collides with any of those, the system reports the conflict and names which category it hit — it does not silently rename.
Column types
| Type | Notes | Extra contract |
|---|---|---|
text | Text | |
number | Numeric | |
boolean | Boolean | |
date | Date | |
datetime | Timestamp | |
select | Single choice | Requires an option set; values are constrained at the database level |
relation | Relation | See below |
json | Structured data | |
image | Image | See below |
Every table automatically carries system columns (identifier, created time, updated time). These cannot be deleted or retyped, and do not count toward the column quota.
relation columns
A relation can point at two kinds of target: another custom table, or a built-in ERP table.
That means an "attendance record" custom table can have a "student" column pointing directly at the ERP customers table — no duplicated customer data, no hand-maintained mapping.
image columns
Image columns provide the full pipeline: uploads route through the platform (rather than the browser writing straight to storage), reads return short-lived signed URLs, and cross-organization access control applies. Images count toward the organization's storage quota.
Who can change structure
Structural operations are administrators only (users holding system.admin): creating, altering, and dropping tables and columns, plus ERP extension field definitions.
Data operations (record CRUD) and structure reads remain at ordinary development permission (builder.access).
What's governed is the shape of the schema, not its use. This check runs at the entry layer, and all three entry points (REST API, SDK, AI tooling) enforce it identically — the AI channel cannot route around the REST gate.
Deletion is two-stage
Dropping a table or column takes two steps, enforced server-side rather than by a front-end confirmation dialog:
- Impact preview — the system reports what the deletion affects: how many rows, which relations point at it
- Physical-name confirmation — you must type the physical name to proceed
Deletion is irreversible. The design deliberately makes accidents hard.
Three entry points, one service layer
Every operation on custom tables — structural and data — funnels into a single service layer, shared by three entry points:
| Entry point | Serving | Identity source |
|---|---|---|
| REST API | Organization users (Data Center UI) | The signed-in user's session |
SDK (ctx.db) | Custom App runtime | The application's runtime identity + scope |
| Builder tooling | The AI development assistant | The Builder session's organization identity |
That service layer is the sole home of the guardrails. Entry points do not reimplement validation, and they do not bypass it:
- Tenant isolation — every operation is anchored to an organization; cross-organization access reports "not found" without revealing whether the resource exists
- Serialized structural changes — DDL takes an organization-level lock, so concurrent table creation cannot tear
- Quotas — per-organization table limits and per-table column limits, checked inside the lock so concurrency cannot slip past
- Type and value validation — column types, option sets, defaults, relation targets
- Error translation — database-level failures (unique violations, not-null violations, foreign key violations, dependent objects) are translated into structured codes and actionable messages rather than raw technical errors
Any new entry point should call this layer rather than assembling its own SQL.
Reaching custom tables from the SDK
def execute(ctx):
# List the organization's custom tables
tables = ctx.db.list_tables()
# Query (by physical name)
rows = ctx.db.query_table('attendance', filters=[
{'column': 'class_date', 'op': 'gte', 'value': '2026-08-01'}
])
# Insert
ctx.db.insert_row('attendance', {
'student_id': ctx.params['customer_id'], # relation to ERP customers
'class_date': '2026-08-10',
'status': 'present',
})
# Update / delete
ctx.db.update_row('attendance', row_id, {'status': 'late'})
ctx.db.delete_row('attendance', row_id)
The front end uses the built-in src/db.ts SDK with equivalent semantics. See Chapter 8.
ERP extension fields
Extension fields add real columns to existing ERP tables. Unlike custom_data, they are queryable, filterable, type-constrained, and can be authorized through the reference system.
Use them when the data is an intrinsic attribute of an existing entity and you need to filter or sort by it — for example, adding "membership expiry" to customers so you can query "members expiring this month".
Dropping an extension field follows the same two-stage flow with an impact preview.