Skip to content

Add an entity

What you're doing

An entity is a database table you declare instead of writing SQL. You list its fields and their data types; the platform's Entity Engine generates the CREATE TABLE, the CRUD REST endpoints (/api/v1/entities/<name>/records), the grid query endpoint, and single-record reads. Zero persistence code.

One entity = one file under spk-assembly/metadata/entities/.

The complete example

spk-assembly/metadata/entities/oeq_equipment.json from the tutorial module — the real file:

json
{
  "entity": {
    "name": "oeq_equipment",
    "tableName": "oeq_equipment",
    "label": "{\"en\":\"Equipment\"}",
    "category": "office-equipment",
    "icon": "devices",
    "color": "#2563EB",
    "pkStrategy": "identity"
  },
  "fields": [
    { "fieldName": "asset_tag", "label": "\"Asset Tag\"", "dataType": "text", "nullable": false, "required": true, "isUnique": true, "isIndexed": true, "isDisplayField": true, "displayOrder": 1 },
    { "fieldName": "name", "label": "\"Name\"", "dataType": "text", "nullable": false, "required": true, "displayOrder": 2 },
    { "fieldName": "category_id", "label": "\"Category Id\"", "dataType": "long", "nullable": true, "required": false, "isIndexed": true, "displayOrder": 3 },
    { "fieldName": "category_name", "label": "\"Category\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 4 },
    { "fieldName": "serial_number", "label": "\"Serial Number\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 5 },
    { "fieldName": "status", "label": "\"Status\"", "dataType": "enum", "nullable": false, "required": true, "isIndexed": true, "defaultValue": "AVAILABLE", "typeParams": "{\"enumValues\":[\"AVAILABLE\",\"ASSIGNED\",\"MAINTENANCE\",\"RETIRED\"]}", "displayOrder": 6 },
    { "fieldName": "purchase_date", "label": "\"Purchase Date\"", "dataType": "date", "nullable": true, "required": false, "displayOrder": 7 },
    { "fieldName": "warranty_expiry_date", "label": "\"Warranty Expiry Date\"", "dataType": "date", "nullable": true, "required": false, "displayOrder": 8 },
    { "fieldName": "notes", "label": "\"Notes\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 9 }
  ]
}

Line by line

The entity block

  • name — lowercase, snake_case, starts with a letter. This is the table name and the id in every REST path. Prefix it (oeq_) so it never collides with another plugin's table.
  • label — this column is stored as JSON in the database, so the string must itself be valid JSON: "{\"en\":\"Equipment\"}" (an i18n object) — not"Equipment". A bare string fails the install with invalid input syntax for type json. This is the single most common entity mistake.
  • pkStrategyidentity gives an auto-incrementing integer id. snowflake gives a distributed 64-bit id. Use identity unless you have a reason not to.
  • icon / color / category — cosmetic, used by Studio's explorer tree.

Each field

  • fieldNamesnake_case, starts with a letter.

  • labelalso pre-stringified JSON. "\"Asset Tag\"" is the JSON string literal "Asset Tag". A bare "Asset Tag" fails the install.

  • dataType — accepted case-insensitively; every shipped file uses lowercase. The ones you'll actually use:

    dataTypePostgresnotes
    texttextany string, short or long. Not long — that's the 64-bit integer type.
    integerint32-bit
    longbigint64-bit; use for foreign-key id columns
    decimal / currencynumericmoney
    booleanboolean
    datedate
    datetimetimestamptz
    enumtext + checkneeds typeParams (below)
  • typeParamsalso a pre-stringified JSON string. For an enum: "{\"enumValues\":[\"AVAILABLE\",\"ASSIGNED\"]}".

  • nullable / required — a required: true field must also be nullable: false. A required: false field must be nullable: true. A mismatched pair produces a broken ALTER TABLE on a table that already has rows.

  • isUnique, isIndexed — add a unique constraint / index (scoped to the tenant).

  • isDisplayField: true — marks the human-readable "name" column. Pick exactly one.

  • defaultValue — always a string, even for numbers/booleans: "10", "true", "AVAILABLE".

Foreign keys and automatic labels

category_id is a long holding another table's id. Two ways to show a name instead of a raw number:

  1. Denormalize — also store category_name and write both when you create a row (what the tutorial does — simplest).
  2. reference flag — add "flags": "{\"reference\":{\"entityType\":\"Employee\"}}" to the id field. Every read then gains a sibling <field>_label key (employee_idemployee_id_label) resolved automatically, batched once per page load. entityType must be a registered provider — "Employee" is the only one shipped today. See resolved reference column.

The tutorial's oeq_checkout entity uses the reference flag on employee_id and it was verified live to return "employee_id_label": "Aria Chen".

Ground yourself first

bash
erp schema pull entity-definition        # the full contract

Or over MCP: erp_get_schema {"name":"entity-definition"}.

Two file shapes both validate

  • { "entity": { ... }, "fields": [ ... ] } — the packaged artifact shape, used in spk-assembly/metadata/entities/. Use this.
  • flat ({ "name": ..., "label": ..., "fields": [ ... ] }) — the live POST /api/v1/entities request body. Only matters if you're calling the API directly.

How to verify it worked

bash
erp schema validate spk-assembly/metadata/entities/oeq_equipment.json --schema entity-definition
OK — spk-assembly/metadata/entities/oeq_equipment.json matches schema "entity-definition"

After publishing, the table is live:

bash
erp api get "/api/v1/entities/oeq_equipment/records/query?size=5"
json
{ "rows": [], "total": 0 }

And a record you create comes back with audit columns the engine added for free:

json
{ "id": 1, "created_by": "you@example.com", "created_at": "2026-09-10T08:08:33.282+00:00",
  "asset_tag": "LAP-001", "name": "Dell Latitude 7440", "status": "AVAILABLE" }

Common mistakes

SymptomCauseFix
install fails invalid input syntax for type jsonlabel is a bare stringmake it "{\"en\":\"...\"}" (entity) or "\"...\"" (field)
a field silently missing after installrequired:false without nullable:true on a table with rowspair them correctly, bump version, republish
big text truncated / rejecteddataType: "long"that's the integer type; use "text"
enum column rejects every valuetypeParams is a nested object, not a stringpre-stringify it
grid shows a raw number for a personbound to employee_id not employee_id_labeladd the reference flag and bind the _label