# Tabular parts — verbatim rules

Transcribed from docs.modern-expo.com/backend/application-developing/migrations.html (captured 2026-08-03). If an option is not in these tables, it does not exist — stop and ask.

## Tabular Parts

A tabular part is a child entity whose rows only exist inside one parent record — the table the user edits on the parent's form rather than a separate section with its own list. Checklist items of a task, lines of an order, attendees of an event.

The platform uses the same mechanism for its own entities: ApplicationJobs and ApplicationEnvs are tabular parts of Applications, CalendarEventAttendees is a tabular part of CalendarEvents.

A tabular part is still an ordinary entity — its own table, its own columns, readable through Query Manager. What `type: 'tabular-part'` changes is ownership: the platform records that these rows belong to a parent record, and the UI renders them inside it.

## Declaring One

Three things make an entity a tabular part. All three are required, and the platform does not infer any of them:

- `type: 'tabular-part'` on `@Entity()`.
- `relationEntityName` — the name of the parent entity.
- A `@ManyToOne` field named `owner` pointing at the parent class.

```ts
import {
	Column,
	Entity,
	FieldTypeEnum,
	Index,
	ManyToOne,
	SystemBaseEntity,
} from '@suppa/sdk';
import { Tasks } from './tasks.entity';

@Index('TaskChecklistItemsOwnerTitleUindex', ['owner', 'title'], {
	unique: true,
	where: '"deletedAt" IS NULL',
})
@Entity({
	name: 'TaskChecklistItems',
	title: { en: 'Checklist items', uk: 'Пункти чеклиста' },
	type: 'tabular-part',
	relationEntityName: 'Tasks',
	importKeyFields: ['owner', 'title'],
	representativeFieldName: 'title',
})
export class TaskChecklistItems extends SystemBaseEntity {
	@ManyToOne(() => Tasks, {
		name: 'owner',
		title: { en: 'Task', uk: 'Задача' },
		nullable: false,
	})
	owner: Tasks;

	@Column({
		name: 'title',
		title: { en: 'Title', uk: 'Назва' },
		type: FieldTypeEnum.Text,
		nullable: false,
	})
	title: string;

	@Column({
		name: 'done',
		title: { en: 'Done', uk: 'Виконано' },
		type: FieldTypeEnum.Boolean,
		nullable: false,
		default: 'false',
	})
	done: boolean;

	@Column({
		name: 'order',
		title: { en: 'Order', uk: 'Порядок' },
		type: FieldTypeEnum.Integer,
	})
	order: number;
}
```

> **HOUSE RULE — `owner` is NOT NULL (SKILL.md rules 14 and 18).** The platform's own
> doc leaves `nullable` unset on this relation, which makes the parent link optional and
> types it `owner?: Tasks`. The example above departs from it deliberately. A tabular-part
> row only exists inside a parent, and `owner` is the FIRST `importKeyFields` entry — a
> null there breaks the business key it is supposed to scope. `templates/tabular-part.template.ts`
> writes it the same way. W610 checks that the decorator and the property type agree,
> whichever you choose; only `nullable: false` is correct here.

## What Gets Created

Registering the class in EntityModule.forFeature() and starting the application produces:

| Artifact | Result |
|---|---|
| Table | A normal table TaskChecklistItems in the tenant schema. Nothing about the storage differs from a standalone entity. |
| ownerId column | An integer foreign key to the parent table, materialized from the owner relation. |
| Entity metadata | An EntityMetadata row with type = 'tabular-part' and relationEntity pointing at Tasks. |
| Schema API | The entity is returned by GET /core/schema/Tasks/tabular-parts and is excluded from GET /core/schema/Tasks/related. |
| UI | The client reads that list and renders the rows as a table on the parent record instead of giving the entity its own navigation entry. |
| Index and constraints | Whatever you declared with @Index() / @Check(), unchanged. |

## Scoping the Business Key

Put owner first in importKeyFields. A checklist item is unique inside its task, not across the workspace, and seeds, import and export all resolve the row through that pair:

```ts
importKeyFields: ['owner', 'title'],
```

The unique index mirrors the same pair, so the database enforces what the metadata declares:

```ts
@Index('TaskChecklistItemsOwnerTitleUindex', ['owner', 'title'], {
	unique: true,
	where: '"deletedAt" IS NULL',
})
```

`where: '"deletedAt" IS NULL'` keeps soft-deleted rows from blocking a re-created one.

## The Back Reference on the Parent

Declare the reverse side on the parent to read the rows as a nested array and to give the tab a localized name:

```ts
@OneToMany(() => TaskChecklistItems, (item) => item.owner, {
	title: { en: 'Checklist', uk: 'Чекліст' },
})
checklist: TaskChecklistItems[];
```

This is the same shape the platform uses — Applications.jobs is an @OneToMany to ApplicationJobs inverted on owner.

> **WARNING**
> A back reference is readable, not writable. Never include the field in a seed — not even as `[]` — the import throws `Back reference can't insert`. Seed the tabular part with its own seed and reference the parent through owner.

```ts
import { createSeed } from '@suppa/sdk';

export const taskChecklistItemsSeed = createSeed('TaskChecklistItems', [
	{ owner: { externalId: 'onboarding-task' }, title: 'Sign the NDA', order: 1000 },
	{ owner: { externalId: 'onboarding-task' }, title: 'Set up the laptop', order: 2000 },
]);
```

owner is referenced through the parent's importKeyFields, exactly like any other @ManyToOne.

## Registration

A tabular part is registered like any other entity — both classes go into forFeature(), and the parent does not need to come first:

```ts
EntityModule.forFeature([Tasks, TaskChecklistItems], {
	seeds: [tasksSeed, taskChecklistItemsSeed],
});
```

Seed order still matters: the parent's seed must run before the tabular part's seed, or owner resolves to nothing.
