# Entity declaration — verbatim rules

Transcribed from docs.modern-expo.com/backend/application-developing/migrations.html (captured 2026-08-04). If an option is not in these tables, it does not exist — stop and ask.

## A Minimal Entity

```ts
import { Column, Entity, FieldTypeEnum, SystemBaseEntity } from '@suppa/sdk';

@Entity({
	name: 'Workflows',
	title: { en: 'Workflows', uk: 'Робочі процеси' },
	importKeyFields: ['name'],
	representativeFieldName: 'name',
})
export class Workflows extends SystemBaseEntity {
	@Column({ name: 'name', type: FieldTypeEnum.Text, nullable: false })
	name: string;

	@Column({ name: 'description', type: FieldTypeEnum.Text })
	description: string;

	@Column({
		name: 'private',
		type: FieldTypeEnum.Boolean,
		default: 'false',
	})
	private: boolean;

	@Column({ name: 'externalId', type: FieldTypeEnum.UUID })
	externalId: string;
}
```

## SystemBaseEntity

Every entity must extend SystemBaseEntity. It contributes the standard columns, so you never declare them yourself:

| Field | Type | Description |
|---|---|---|
| id | serial | Auto-increment primary key |
| createdAt | timestamp | Creation timestamp |
| updatedAt | timestamp | Last update timestamp |
| deletedAt | timestamp | Soft-delete timestamp |
| createdBy | ManyToOne(Users) | Who created the record |
| removedBy | ManyToOne(Users) | Who soft-deleted it |

## @Entity() Options

| Option | Type | Description |
|---|---|---|
| name | string | Entity and table name. Defaults to the class name. |
| title | Record<string, string> | Localized display name. |
| comment | string | Description shown in metadata. |
| representativeFieldName | string | Field used to display a record. Defaults to id. |
| importKeyFields | string[] | Business key used by seeds, import and relation references. |
| key | string | Stable identifier that survives renaming the entity. |
| icon | Record<string, any> | Icon configuration. |
| type | string | Entity kind. Omit for a standalone entity, or set 'tabular-part' — see Tabular Parts. |
| relationEntityName | string | Name of the entity this one belongs to. Required for a tabular part. |
| options | Record<string, any> | Platform features to enable or disable for the entity (see below). |

See [references/tabular-parts.md](tabular-parts.md) for the full triad required to declare a tabular part (`type: 'tabular-part'` + `relationEntityName` + an `owner` relation).

`options` switches the platform's per-entity features. The migrations doc types it
`Record<string, any>` and shows nine keys underneath as an **example** — the example is not the
list. What the platform accepts is the builder's Entity Options table: sixteen keys, each with one
type. Anything else is dropped without complaint, so the gate refuses it, and refuses a value of the
wrong type too (**E503**):

| Option | Type | What it switches on |
|---|---|---|
| `comments` | boolean | comments |
| `reactions` | boolean | reactions |
| `hierarchy` | `"single" \| "multi"` | a tree over the entity's own records — `"single"` generates the self-relations `parent` and `children` (read off the live `Forms` entity); `"multi"` gives a record several parents, which the front end writes through `parents` |
| `folders` | `"single" \| "multi"` | folders to file records into |
| `trackChangeHistory` | boolean | change tracking |
| `browsingHistory` | boolean or number | browsing history |
| `globalSearch` | boolean | the entity in global search |
| `timeTracking` | boolean | time tracking — generates `spentTime` |
| `favorites` | boolean | favourites |
| `pinned` | boolean | pinned records |
| `approvals` | boolean | approvals |
| `checklists` | boolean | checklists |
| `recurrence` | boolean | recurrence |
| `reminders` | boolean | reminders |
| `notificationMutes` | boolean | notification mutes |
| `customFields` | boolean | per-context custom fields — see below |

`hierarchy` and `folders` are modes, not switches: `hierarchy: true` is wrong, and to leave either
feature off you omit the key. Every option is a schema change rather than a preference — "Enabling
an option creates the side table that backs it" (builder doc) — and the fields an option generates
(`parent`, `parents`, `children`, `multiChildren`, `spentTime`, `comments`, …) are names you may not
declare yourself (**E132**).

On an entity you do **not** own, extension mode ignores `options` wholesale (**W618**), so a feature
cannot be switched on from a file there. Do it once at runtime instead:
`suppa update-entity-options --entity-name Tasks --options-json '{"hierarchy":"single"}'`
(MCP: `suppa_update_entity_options`).

The migrations doc's own example, which switches off what an entity does not need — each enabled
feature adds side tables and processing:

```ts
@Entity({
	name: 'Workflows',
	importKeyFields: ['name'],
	options: {
		comments: false,
		favorites: false,
		approvals: false,
		timeTracking: false,
		browsingHistory: false,
		trackChangeHistory: false,
		globalSearch: false,
		notificationMutes: false,
		reminders: false,
	},
})
export class Workflows extends SystemBaseEntity {}
```

`customFields: true` belongs in this same object. It gives the entity per-context
custom fields: the platform creates a `{Entity}CustomFields` extension table, and a
tenant can then add columns to the entity from the running product, scoped to a
context such as one project. Enabling it from code works on any insert path —
`EntitySchemaSyncExtension.afterInsert` calls `enableOptions` for the builder, for
import and for the JSONL sync alike, and `afterUpdate` picks up a change to
`options` on an entity that already exists. On an existing entity that depends on
the sync actually rewriting `EntityMetadata.options`, so after a deploy check that
`{Entity}CustomFields` was created. The values themselves are never declared here —
they live under the reserved `customFields` key on the owner record.

## @Column()

```ts
import { Column, FieldTypeEnum } from '@suppa/sdk';

@Column({
	name: 'title',
	type: FieldTypeEnum.Text,
	nullable: false,
	length: 255,
})
title: string;

@Column({
	name: 'estimatedDuration',
	type: FieldTypeEnum.Numeric,
	decimalPlaces: 2,
})
estimatedDuration: number;

@Column({ name: 'isActive', type: FieldTypeEnum.Boolean, default: 'false' })
isActive: boolean;

@Column({ name: 'deadline', type: FieldTypeEnum.Timestamp })
deadline: string;
```

| Option | Type | Description |
|---|---|---|
| type | FieldTypeEnum | Column type. Take the value from FieldTypeEnum (see below). |
| subType | string \| null | Refines type — see subType. |
| name | string | Column name. Defaults to the property name. |
| nullable | boolean | Allow NULL. |
| primary | boolean | Mark as primary key. |
| unique | boolean | Unique constraint. |
| default | string | Default value as a SQL expression string. |
| length | number | Maximum length. |
| title | Record<string, string> | Localized field name. |
| comment | string | Description. |
| key | string | Stable identifier that survives renaming. |
| minLength | number | Validation: minimum length. |
| minValue | number | Validation: minimum value. |
| maxValue | number | Validation: maximum value. |
| decimalPlaces | number | Decimal precision. |
| isOnlyPositive | boolean | Positive numbers only. |
| mask | string | Input mask. |

> **WARNING**
> `default` is a string containing a SQL expression, not a JavaScript value. Write `default: 'false'`, not `default: false`, and `default: "'draft'"` for a string literal.

## FieldTypeEnum — Column Types

Column types come from the FieldTypeEnum enum exported by @suppa/sdk:

```ts
import { FieldTypeEnum } from '@suppa/sdk';
```

`type` is declared as string, so a raw literal such as `type: 'text'` still compiles — but a misspelled one (`'txet'`) compiles too and only fails when the migration runs. Referencing the enum turns that into an editor error, and gives you the full list of valid types on autocomplete.

| Member | Value | Use for |
|---|---|---|
| Text | text | Strings of any length. Add length to cap it. |
| Numeric | numeric | Decimal numbers. Pair with decimalPlaces. |
| Integer | integer | Whole numbers. |
| Serial | serial | Auto-increment integer. Already provided by SystemBaseEntity.id. |
| Boolean | boolean | true / false. |
| Timestamp | timestamp | Dates and date-times. |
| JSON | json | Arbitrary structured payloads. |
| UUID | uuid | External identifiers. |
| MultiLang | multi-language | Per-language string values stored on the record. |
| Bytea | bytea | Raw binary. Prefer @File for attachments. |
| TsVector | tsvector | Full-text search vectors. |
| MultiType | multi-type | Composite field built from sub-fields. |
| Icon | icon | Icon reference. |

> **TIP**
> FieldTypeEnum covers scalar columns only. Relations, enums and files are declared with their own decorators — @ManyToOne, @ManyToMany, @OneToMany, @Enum, @MultiEnum, @File, @MultiFile — and never with @Column({ type: ... }). The SDK also exports RelationTypeEnum with those relation kinds, but you do not pass it manually; each decorator sets it for you.

## `subType` — Refining a Column Type

`subType` is a second, narrower classification on top of `type`. It is a plain string, and the thing to understand before using it is that **it means three unrelated things depending on the base type**:

| Base type | `subType` | Effect |
|---|---|---|
| `Timestamp` | `'date'`, `'time'`, `'timestamp'` | Chooses the physical column type. Omitted means `timestamp`. |
| `Text` | `'encrypted'` | The value is encrypted at rest. |
| `Enum`, `MultiEnum` | The global enum name | Identifies which enum the field draws from. Set by the decorator — never by hand. |

There is no enum for these values in the SDK. Write the string.

### Dates and Times

`FieldTypeEnum` has no `Date` or `Time` member. A date-only or time-only column is `Timestamp` plus a `subType`:

```ts
@Column({ name: 'deadline', type: FieldTypeEnum.Timestamp })
deadline: string; // timestamp

@Column({ name: 'birthday', type: FieldTypeEnum.Timestamp, subType: 'date' })
birthday: string; // date

@Column({ name: 'opensAt', type: FieldTypeEnum.Timestamp, subType: 'time' })
opensAt: string; // time
```

The subtype becomes the column type verbatim, so `subType: 'date'` produces a real Postgres `date` column — not a `timestamp` that the UI renders without a time part.

### Encrypted Text

`subType: 'encrypted'` on a text field stores the value as an AES-GCM envelope (`v1:iv:tag:ct`) instead of plaintext:

```ts
@Column({
	name: 'payload',
	type: FieldTypeEnum.Text,
	subType: 'encrypted',
	nullable: false,
})
payload: string;
```

The platform rejects the combinations that cannot work:

| Rule | Error |
|---|---|
| Only on a `text` field. | `Encrypted subtype is only allowed on text fields` |
| Not inside a multi-type field, and not as one of its sub-fields. | `Encrypted subtype is not allowed inside a multi-type field` |

It also forces off everything that would need to read the plaintext, whatever you declared:

- `unique` becomes `false`;
- the `searchable`, `canFilter`, `canSort` and `canGroup` options become `false`;
- `maxLength`, `minLength` and `mask` are cleared — the envelope never matches a plaintext length or mask constraint, so those `CHECK`s are dropped.

Turning encryption on for an existing field encrypts the rows already in the table; turning it off decrypts them. A row that fails to decrypt aborts the backfill rather than overwriting ciphertext with a marker.

> **TIP**
> Plan for the lost capabilities before encrypting a field. An encrypted column cannot be searched, filtered, sorted, or grouped — if users need any of that, keep the searchable part in a separate plaintext field.

### Enums Set It For You

For an enum field, `subType` holds the enum's **global name** — the value that lets the platform resolve `{ value: 'active' }` to the right enum record on insert. `@Enum()` and `@MultiEnum()` fill it from their first argument:

```ts
@Enum('StageWorkflows.status', StageStatusEnum, StageStatus, { nullable: false })
status: StageStatusEnum; // subType = 'StageWorkflows.status'
```

Called without an explicit global name, the SDK derives `<ClassName>.<propertyName>` — so the field above, written `@Enum(StageStatusEnum, StageStatus, { nullable: false })` on class `StageWorkflows`, resolves to the same `subType`. That derived form is the one to write unless the enum is genuinely shared (see Enums below). Either way, do not pass `subType` yourself on an enum field.

### Changing a Subtype

Through the Builder API a subtype change is rejected outright:

```txt
Field subType cannot be changed.
```

The one permitted transition is the encryption toggle — setting `'encrypted'`, or clearing it. Treat the date/time choice as **fixed at creation**: if a field was created as `timestamp` and should have been `date`, add a new field rather than trying to convert it, since the physical column type would have to change under existing data. See Removing and Re-Adding a Field for what happens to the old one.

> **WARNING**
> To clear a subtype, write `subType: null` explicitly. Deleting the line does nothing — an omitted key is dropped from the sync payload, so the stored subtype stays as it was.

## Relations

Relation decorators take a thunk returning the target class — `() => Workflows` — which keeps circular imports working:

```ts
import {
	Column,
	Entity,
	FieldTypeEnum,
	ManyToOne,
	ManyToMany,
	OneToMany,
	SystemBaseEntity,
	IconsEntity,
} from '@suppa/sdk';
import { Workflows } from './workflows.entity';

@Entity({ name: 'StageWorkflows', importKeyFields: ['name'] })
export class StageWorkflows extends SystemBaseEntity {
	@Column({ name: 'name', type: FieldTypeEnum.Text, nullable: false })
	name: string;

	@ManyToOne(() => Workflows, { nullable: false })
	workflow: Workflows;

	@ManyToOne(() => IconsEntity)
	icon: any;
}
```

> **HOUSE RULE — the doc's `icon: any` is not the shape to copy (SKILL.md rule 14).**
> `any` names no class, so it tells callers nothing and the arity check cannot run (W615). `IconsEntity` is importable from the SDK, as the note below says — write `icon?: IconsEntity`. The `?` follows from the relation being nullable: this `@ManyToOne` sets no `nullable: false`, and the doc's `workflow: Workflows` above is right to omit it only because that one does.

| Decorator | Signature | Use |
|---|---|---|
| @ManyToOne | (target, options?) or (target, inverse, options?) | Foreign key on this entity. |
| @ManyToMany | (target, options?) or (target, inverse, options?) | Pivot table between both entities. |
| @ManyToManyBackRef | (target, inverse, options?) | Reverse side of a ManyToMany. |
| @OneToMany | (target, inverse, options?) | Reverse side of a ManyToOne. |

`inverse` is a property selector on the target entity, for example `(task) => task.project`.

Platform entities are importable from the SDK when you need to point at them — for example `IconsEntity`.

## Enums

@Enum and @MultiEnum accept a TypeScript enum with string values. The doc's own sentence here is *"Give the enum a global name (first argument) so it can be referenced from seeds and shared across entities"*, and every example in it passes one.

> **HOUSE RULE — overrides the sentence above (SKILL.md rule 17).**
> Pass the global name **only when the enum really is global**: shared by more than one entity, or referenced from a seed record (`{ name: 'StageWorkflows.status', value: 'active' }`). Otherwise omit the first argument and let the SDK derive `<ClassName>.<propertyName>`. A name that nothing references is a name that should not be there — W613, and it fails the close-out gate.
>
> Careful with `key`: a field conventionally carries `key: '<Entity>.<field>'`, spelled exactly like a global enum name. That is the **rename key**, an unrelated option — it is not a reference to the enum and does not make the enum global.

Local — the default, and what most enum fields should look like:

```ts
@Enum(StageStatusEnum, StageStatus, { nullable: false })
status: StageStatusEnum; // subType resolves to 'StageWorkflows.status'
```

Global — the same field, once a seed or a second entity refers to the value set by name:

```ts
export enum StageStatusEnum {
	Active = 'active',
	InProgress = 'inprogress',
	Deferred = 'deferred',
	Completed = 'completed',
	Canceled = 'cancelled',
}

export const StageStatus = {
	[StageStatusEnum.Active]: { title: { en: 'Active', uk: 'Активний' } },
	[StageStatusEnum.InProgress]: {
		title: { en: 'In Progress', uk: 'В процесі' },
	},
	[StageStatusEnum.Deferred]: { title: { en: 'Deferred', uk: 'Відкладений' } },
	[StageStatusEnum.Completed]: {
		title: { en: 'Completed', uk: 'Виконаний' },
	},
	[StageStatusEnum.Canceled]: { title: { en: 'Canceled', uk: 'Скасований' } },
};

@Enum('StageWorkflows.status', StageStatusEnum, StageStatus, {
	nullable: false,
})
status: StageStatusEnum;
```

The second map is optional and adds a localized title, icon and order per value.

Use @MultiEnum with the same signatures for multi-select.

## Files

```ts
@File({ title: { en: 'Cover', uk: 'Обкладинка' } })
cover: any;

@MultiFile({ title: { en: 'Attachments', uk: 'Вкладення' } })
attachments: any[];
```

## Indexes and Checks

@Index() and @Check() are class decorators:

```ts
@Entity({ name: 'Tasks', importKeyFields: ['externalId'] })
@Index('idx_tasks_project', ['project'])
@Index('idx_tasks_alive', ['deadline'], { where: '"deletedAt" IS NULL' })
@Check(
	'chk_tasks_positive_duration',
	['estimatedDuration'],
	'"estimatedDuration" > 0',
)
export class Tasks extends SystemBaseEntity {}
```

| @Index() option | Type | Description |
|---|---|---|
| unique | boolean | Unique index. |
| method | string | Index method, e.g. btree, gin. |
| where | string | Partial index predicate. |
| expression | string | Expression index. |
| comment | string | Description. |

## importKeyFields — the Business Key

importKeyFields names the fields that identify a record independently of its numeric id. It is the backbone of seeds, export/import and cross-schema references, because id values differ between tenants.

```ts
@Entity({ name: 'Workflows', importKeyFields: ['name'] })
```

Pick fields that are stable and unique in practice. Without importKeyFields, seeds have nothing to match on and cannot stay idempotent.

> **TIP**
> Use the key option on @Entity() and @Column() when you expect to rename things later. The key is the stable identity used to recognize an existing entity or field, so a rename becomes a rename instead of a drop-and-create. Without it the key defaults to the name — see Removing and Re-Adding a Field.

## How the platform writes its own entities

entity-builder — the platform's repository — declares its 114 system entities with the same `@suppa/sdk` decorators an application uses. Its habits are the reference for what reads as correct on this platform (surveyed 2026-09-18):

| Habit | In numbers | Why it matters |
| --- | --- | --- |
| Every `@Column` and every relation passes `name` explicitly | 951 columns, 194 of 195 `@ManyToOne` | The physical column is `<name>` or `<name>Id`; naming it is naming the column |
| `nullable: false` is written where it applies, `default` is a SQL expression STRING | `'false'` ×67, `'true'` ×17, `'0'` ×17, `'now'` ×9, `'{}'` ×6, `'$current-user'` ×5 | `default: false` (a JS boolean) is E103 |
| Indexes are PascalCase `<Entity><Columns>Index` / `Uindex`, unique ones over soft-deleted tables carry `where: '"deletedAt" IS NULL'` | 58 indexes, 22 `…Index`, 13 `…Uindex`; 20 of 29 unique ones carry the `where` | A unique index without the `where` blocks re-creating a record after a soft delete |
| No `@Index` over a single `@ManyToOne` or `unique` column | rule in their `database/AGENTS.md` §6.3 | metadata-sync indexes those itself; a duplicate aborts the tenant migration (W628) |
| `importKeyFields` on almost every entity, `representativeFieldName` on most | 94 and 76 of 114 | Seeds and import match on the key; the UI renders a record by the representative field |
| `options: { trackChangeHistory: true }` on most entities | 74 | History is opt-in per entity |
| Tabular parts: `type: 'tabular-part'`, `relationEntityName`, `importKeyFields: ['owner', …]` with `owner` first | ApplicationEnvs, ApplicationJobs, … | Exactly the shape rules 9–11 and `references/tabular-parts.md` describe |
| One `@Enum` per value set, member keys PascalCase | `UsersGenderEnum`, `UsersStatusEnum` | Rule 5 / W611 |

What NOT to copy from that repository — it is platform-internal and fails in an application: `initiator: InitiatorEnum.System` (E501 — an application's rows are `application`, set by the platform), `tableName`, `entityTranslations[...]` / `getFieldTitle(...)` (its translation tables; write `title: { en, uk }` inline), `extends BaseEntity` (an application extends `SystemBaseEntity`, E101) and `EntityNamesEnum` from `@suppa/db-toolkit`.

## About title

title is not stored as a column. During migration the platform creates a Localizations record holding the per-language strings and links the entity or field metadata to it. You write the object inline and the platform handles the rest:

```ts
@Column({
	type: FieldTypeEnum.Text,
	title: { en: 'Email', uk: 'Електронна пошта' },
})
email: string;
```

"The rest" is exact, and it is silent. What the platform does with the object (entity-builder, metadata-sync, read 2026-09-18):

| You write | The platform stores | Gate |
| --- | --- | --- |
| `title: 'Email'` | `{ en: 'Email' }` — a plain string is rewritten to `en` only; every other language has no label | W626 |
| `title: { ua: 'Пошта', en: 'Email' }` | `{ en: 'Email' }` — `ua` is not one of the 24 language columns, so it is dropped without a word; Ukrainian is `uk` | W626 |
| `title: { en: '', uk: 'Пошта' }` | an empty `en` — only a plain string is checked for emptiness, an object is not | W626 |
| `title: { id: 5 }` | a REFERENCE to Localizations row 5 — an object with `id` is never read as translations | W626 |
| `title: { uk: 'Пошта' }` | stored — but `en` is the platform's default wherever it needs one; add it | W626 |
| `{ en, uk }` here, `{ en }` there | both stored; the `uk` UI shows nothing for the second | W627 (advisory) |

The languages are the columns of `Localizations` and there are exactly 24: `af ar az bg cs da de el en es et fi fr gd hr hy it ka nl pl ro sq uk zh`. There is no `ru` and no `us`.

An `@Enum`/`@MultiEnum` titles map — `{ [Key]: { title: { en, uk } } }` — is stored differently: in `Enums.title`, an hstore on the record. There an unknown key is *kept*, as a language nothing ever asks for (`title.uk` reads `null`; only the any-language fallback finds it). The same rule applies, for the opposite reason.

A title the gate cannot read — an identifier, a spread, a computed key — is reported as W606 (check skipped), never as clean.
