Models define your data structure. Each model becomes a database table.
{
"name": "customer",
"fields": {
"name": { "type": "string", "required": true },
"email": { "type": "email", "unique": true }
}
}This creates a customers table with id, name, email, created_at, updated_at, created_by, updated_by, active columns.
| Type | SQLite | PostgreSQL | MySQL | Description |
|---|---|---|---|---|
string |
TEXT | VARCHAR(n) | VARCHAR(n) | Short text. Use max for length. |
text |
TEXT | TEXT | TEXT | Long text |
integer |
INTEGER | INTEGER | INTEGER | Whole number. min/max for range. |
decimal |
REAL | DECIMAL(18,n) | DECIMAL(18,n) | Number with decimals. precision for decimal places. |
boolean |
INTEGER | BOOLEAN | BOOLEAN | True/false |
date |
TEXT | DATE | DATE | Date only |
datetime |
TEXT | TIMESTAMPTZ | DATETIME | Date + time |
selection |
TEXT | VARCHAR(50) | VARCHAR(50) | Enum. Requires options array. |
email |
TEXT | VARCHAR(255) | VARCHAR(255) | Email (validated) |
uuid |
TEXT | UUID | CHAR(36) | UUID v4 (auto-validated) |
ip |
TEXT | INET | VARCHAR(45) | IPv4 or IPv6 address |
ipv6 / ip:v6 |
TEXT | INET | VARCHAR(45) | Strict IPv6 only |
year |
INTEGER | SMALLINT | YEAR | 4-digit year (1900-2100) |
color |
TEXT | VARCHAR(30) | VARCHAR(30) | CSS color (#hex, rgb, hsl) |
currency |
REAL | DECIMAL(18,n) | DECIMAL(18,n) | Money amount with currency resolution |
vector |
TEXT | VECTOR(n) | JSON | Embedding vector. Requires dimensions. |
binary |
BLOB | BYTEA | LONGBLOB | Raw binary data |
json:object |
TEXT | JSONB | JSON | JSON object (validated as object) |
json:array |
TEXT | JSONB | JSON | JSON array (validated as array) |
many2one |
TEXT | UUID | CHAR(36) | FK to another model. Requires model. |
one2many |
- | - | - | Reverse FK. Requires model + inverse. No column created. |
many2many |
- | - | - | Junction table. Requires model. |
json |
TEXT | JSONB | JSON | Arbitrary JSON data |
file |
TEXT | VARCHAR(500) | VARCHAR(500) | File path/URL |
computed |
- | - | - | Virtual field. Requires computed expression. No column created. |
| Option | Type | Description |
|---|---|---|
required |
bool | NOT NULL constraint |
unique |
bool | UNIQUE constraint |
default |
any | Default value. Use "now" for current timestamp. |
max |
int | Max length (string) or max value (integer) |
min |
int | Min value (integer) |
precision |
int | Decimal places (decimal) |
max_size |
string | Max file size, e.g. "5MB" (file) |
label |
string | Display label for UI |
options |
string[] | Enum values (selection) |
model |
string | Related model name (many2one, one2many, many2many) |
inverse |
string | Inverse FK field name (one2many) |
computed |
string | Computation expression (computed). Uses expr-lang/expr engine with full function library (68+ built-in + go-json stdlib). Supports arithmetic, string functions, ternary, aggregates over child collections (sum("lines.subtotal")). |
auto |
bool | Auto-set value (datetime) |
storage |
string | Storage hint override (e.g. "bigint", "smallint", "numeric", "longtext") |
scale |
int | Decimal scale (alias for precision in decimal/currency) |
hidden |
bool | Exclude from auto-generated views |
display_field |
string | Override target model's title_field for many2one display |
currency_field |
string | Field name containing currency code (for currency type) |
dimensions |
int | Vector dimensions (required for vector type) |
number_format |
object | Number formatting config (thousand_separator, decimal_separator, symbol, symbol_position) |
models |
string[] | Allowed morph target models (for morph_to type) |
morph |
string | Morph name identifier (for polymorphic relations) |
Control auto-generated columns and behavior at the model level:
{
"name": "order",
"version": true,
"timestamps": true,
"timestamps_by": true,
"soft_deletes": true,
"tenant_scoped": true,
"fields": { ... }
}| Option | Type | Default | Columns Generated |
|---|---|---|---|
version |
bool | false |
version INTEGER NOT NULL DEFAULT 1 — optimistic locking |
timestamps |
bool | true |
created_at, updated_at |
timestamps_by |
bool | true |
created_by, updated_by |
soft_deletes |
bool | false |
deleted_at (nullable datetime) |
soft_deletes_by |
bool | false |
deleted_by (UUID FK) |
tenant_scoped |
bool | true |
tenant_id (when tenant.enabled = true). Set false for shared tables. |
When enabled, every update requires the client to send the current version in the request body. The engine checks WHERE version = ? and increments it atomically. If another user modified the record first, the update returns HTTP 409 Conflict with "record has been modified by another user".
# Read record (version: 3)
curl http://localhost:8080/api/order/abc123
# Update with version check
curl -X PUT http://localhost:8080/api/order/abc123 \
-H "Content-Type: application/json" \
-d '{"name": "Updated", "version": 3}'
# → Success: version becomes 4
# Stale update (version 3 already changed)
curl -X PUT http://localhost:8080/api/order/abc123 \
-d '{"name": "Conflict", "version": 3}'
# → 409 ConflictWhen enabled, DELETE sets deleted_at = NOW() and active = false instead of removing the row. Records with deleted_at IS NOT NULL are excluded from FindAll, Count, and Sum queries.
These are two separate concepts:
active— A business field. An inactive record still appears in lists but cannot be used in selections/dropdowns. User-controlled toggle.soft_deletes— Recycle bin. A deleted record is hidden from all queries. Can be restored.
The repository provides two sets of query methods:
| Method | Filter | Use Case |
|---|---|---|
FindAll / FindByID / Count / Sum / Paginate |
Excludes soft-deleted (deleted_at IS NULL) |
Default queries |
FindAllActive / FindActive / CountActive / SumActive / PaginateActive |
active = true + excludes soft-deleted |
Dropdowns, selections, active-only views |
Every model automatically gets:
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key (auto-generated) |
active |
boolean | Business active flag (default true) |
created_at |
datetime | Set on creation (when timestamps: true, default) |
updated_at |
datetime | Set on every update (when timestamps: true, default) |
created_by |
UUID FK | User who created the record (when timestamps_by: true, default) |
updated_by |
UUID FK | User who last updated (when timestamps_by: true, default) |
version |
integer | Optimistic lock counter (only when version: true) |
deleted_at |
datetime | Soft delete timestamp (only when soft_deletes: true) |
deleted_by |
UUID FK | User who deleted the record (only when soft_deletes_by: true) |
You never need to define these in your JSON.
"customer_id": { "type": "many2one", "model": "customer", "required": true }Creates a UUID foreign key column.
"lines": { "type": "one2many", "model": "order_line", "inverse": "order_id" }No column created. Resolved by querying order_lines WHERE order_id = this.id.
"tags": { "type": "many2many", "model": "tag" }Creates a junction table model_tags with both FKs.
Five morph types allow a model to relate to multiple different model types through a single relation.
"commentable": { "type": "morph_to", "models": ["post", "video"] }Creates two columns: commentable_type (VARCHAR) + commentable_id (VARCHAR(36)). models is optional — if omitted, any model is allowed.
"avatar": { "type": "morph_one", "model": "image", "morph": "imageable" }No column created. Queries child table: WHERE imageable_type = 'user' AND imageable_id = this.id.
"comments": { "type": "morph_many", "model": "comment", "morph": "commentable" }No column created. Same as morph_one but returns array.
"tags": { "type": "morph_to_many", "model": "tag", "morph": "taggable" }Creates junction table taggables with columns: id, tag_id, taggable_id, taggable_type.
"posts": { "type": "morph_by_many", "model": "post", "morph": "taggable" }No table created — uses the same junction table from morph_to_many.
Configure in bitcode.toml to use short aliases instead of model names:
[morph_map]
post = "p"
video = "v"Or in module.json:
{ "morph_map": { "post": "p", "video": "v" } }Default: model name used as-is (no config needed).
await bitcode.db.morphAttach("post", postId, "tags", [tagId1, tagId2]);
await bitcode.db.morphDetach("post", postId, "tags", [tagId1]);
await bitcode.db.morphSync("post", postId, "tags", [tagId1, tagId2, tagId3]);POST /api/v1/{module}/{model}/:id/:relation/attach { "ids": ["1", "2"] }
POST /api/v1/{module}/{model}/:id/:relation/detach { "ids": ["1"] }
POST /api/v1/{module}/{model}/:id/:relation/sync { "ids": ["1", "2", "3"] }
- No automatic cascade delete (use model events for cleanup)
- No morph-aware record rules (parent record rules not applied during morph_to loading)
- MongoDB: morph relations loaded via standalone methods, not eager loading (planned for Phase 6C)
"record_rules": [
{ "groups": ["sales.user"], "domain": [["created_by", "=", "{{user.id}}"]] },
{ "groups": ["sales.manager"], "domain": [] }
]sales.usergroup: can only see records they createdsales.managergroup: can see all records (empty domain = no filter)
Record rules are enforced automatically when auth: true on the API.
{
"name": "vip_customer",
"inherit": "customer",
"fields": {
"vip_level": { "type": "selection", "options": ["gold", "platinum"] },
"discount_rate": { "type": "decimal", "default": 0.1 }
}
}Adds fields to the parent model's table.
"indexes": [
["customer_id", "order_date"],
["status"]
]Creates database indexes for query performance.