Skip to content

Models ​

Every business application begins with a question: what are the things your system tracks? A model is your answer. It defines a data entity once, and from that single definition the framework produces a database table, a REST API, form inputs, list columns, and permission gates.

You describe the shape of your data. The framework handles the mechanics of storing, serving, and rendering it.

Defining a model ​

typescript
// modules/sales/models/customer.ts
import { defineModel, field } from 'rangka';

export default defineModel({
  name: 'customer',
  label: 'Customer',
  naming: 'customer_name',
  traits: ['timestamped'],
  fields: {
    customer_name: field.string({ required: true }),
    email: field.string(),
    phone: field.string(),
    type: field.enum(['Individual', 'Company'], { default: 'Individual' }),
    credit_limit: field.decimal({ precision: 18, scale: 2 }),
    is_active: field.boolean({ default: true }),
    notes: field.text(),
  },
  indexes: [{ fields: ['email'], unique: true }],
});

This is a complete entity. The framework handles the table creation, the API endpoints, the form layout, and the list columns.

Configuration ​

FieldTypeDescription
namestringModel name (becomes {module}.{name})
labelstringHuman-readable name for the UI
namingstringWhich field to use as the record's display title
scopestringAuto-filter queries by user's active scope (e.g. 'core.company')
auditLogbooleanTrack all changes to records
traitsTrait[]Behaviors to mix in (see Traits)
fieldsRecord<string, FieldConfig>Field definitions
indexesIndexConfig[]Database indexes

Field types ​

Primitives ​

typescript
fields: {
  name:         field.string({ maxLength: 140 }),
  description:  field.text(),
  quantity:     field.int(),
  rate:         field.decimal({ precision: 18, scale: 6 }),
  is_active:    field.boolean({ default: true }),
  posting_date: field.date(),
  created_at:   field.datetime(),
  status:       field.enum(['Draft', 'Active', 'Closed']),
  metadata:     field.json(),
}

Money ​

typescript
fields: {
  grand_total: field.money(),
  tax_amount:  field.money(),
}

The frontend formats these as currency. The app layer handles which currency applies.

Sequence ​

Auto-incrementing identifiers with formatting:

typescript
fields: {
  voucher_no: field.sequence({
    prefix: 'INV-',
    digits: 5,
  }),
  // Generates: INV-00001, INV-00002, ...
}

Attachment ​

typescript
fields: {
  logo:      field.attachment({ accept: ['image/*'], maxSize: '5mb' }),
  documents: field.attachments({ accept: ['application/pdf'], maxCount: 10 }),
}

Tree ​

Hierarchical data with configurable storage:

typescript
fields: {
  parent: field.tree({ strategy: 'materialized_path' }),
}

Strategies: materialized_path, nested_set, closure_table.

Relationships ​

A foreign key to another model. Renders as a searchable dropdown in the UI:

typescript
fields: {
  customer: field.link('sales.customer', { required: true }),
}

Has Many (one-to-many) ​

Records in another model that point back. This does not create a column in the current table, but lets the API include related records via ?include=addresses:

typescript
fields: {
  addresses: field.hasMany('sales.customer_address', { foreignKey: 'customer_id' }),
}

Children (parent-child) ​

A tighter version of has-many. Children are edited inline with the parent, saved in a single transaction, and cascade-deleted:

typescript
fields: {
  items: field.children('sales.order_item', { foreignKey: 'order_id' }),
}

Many-to-Many ​

Through a junction table:

typescript
fields: {
  tags: field.manyToMany('core.tag', { through: 'sales.order_tag' }),
}

The target model is stored in another field:

typescript
fields: {
  reference_type: field.string(),
  reference: field.dynamicLink('reference_type'),
}

Field options ​

All fields share these base options:

typescript
{
  required: true,
  label: 'Display Name',
  hidden: true,          // Don't show in default views
  readOnly: true,        // Can't be edited through the API
  default: 'value',
  validation: {
    format: 'email',
    min: 0,
    max: 100,
    pattern: '^[A-Z]',
    message: 'Custom error message',
  },
}

Traits ​

Traits add standard fields and behaviors to a model.

timestamped adds created_at, updated_at, created_by, and updated_by, automatically managed. The created_by and updated_by fields are links to core.user.

soft_delete adds archived_at. DELETE becomes an archive operation. List and get queries exclude archived records by default. Updates and deletes on archived records are blocked.

For status fields and state transitions, define your own enum field and use actions backed by services to handle the logic.

Computed fields ​

Some values do not come from user input. They are derived from other fields. A total is the sum of line items. An outstanding balance is the difference between what was invoiced and what was paid. Computed fields let you express these relationships declaratively:

typescript
fields: {
  items: field.children('sales.order_item', { foreignKey: 'order_id' }),
  total: field.computed({
    depends: ['items.amount'],
    compute: (doc) => doc.items?.reduce((sum, item) => sum + (item.amount || 0), 0) ?? 0,
  }),
  outstanding: field.computed({
    depends: ['total', 'paid_amount'],
    compute: (doc) => (doc.total || 0) - (doc.paid_amount || 0),
  }),
}

Computed fields are virtual. They are evaluated when the record is read and not persisted to the database. They do not create columns.

Naming ​

Controls which field shows up as the record's title (in link fields, breadcrumbs, lists):

typescript
naming: 'customer_name'; // string field as title
naming: 'invoice_number'; // sequence field as title

Extensions ​

Other modules can add fields and hooks to your models without modifying them:

typescript
import { defineExtension, field } from 'rangka';

export default defineExtension('sales.customer', {
  fields: {
    loyalty_points: field.int({ default: 0 }),
    tier: field.enum(['Bronze', 'Silver', 'Gold']),
  },
});

Extended fields merge into the model's schema. They appear in the API, forms, and lists alongside the base fields.

What a model becomes ​

From a single defineModel(), the framework produces:

LayerWhat you get
DatabaseTable with typed columns, foreign keys, indexes
REST APIGET/POST/PUT/DELETE /api/{module}/{model} with filtering, sorting, pagination
PermissionsCRUD + field-level access control
Form UIInput fields matched to types (text, date picker, dropdown, file upload)
List UITable columns with formatting and sorting
ValidationRequired fields, type checking, relationship integrity