# Registration — 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.

## EntityModule.forFeature

Everything goes through EntityModule from @suppa/sdk. Import it with forFeature() in the module that owns the entities — usually AppModule.

```ts
import { Module } from '@nestjs/common';
import { EntityModule } from '@suppa/sdk';
import { Workflows, StageWorkflows } from '../common/database/entities';
import { workflowsSeed, stageWorkflowsSeed } from '../common/database/seeds';

@Module({
	imports: [
		EntityModule.forFeature([Workflows, StageWorkflows], {
			seeds: [workflowsSeed, stageWorkflowsSeed],
		}),
	],
})
export class AppModule {}
```

EntityModule.forFeature() is what makes the platform aware of your entities and seeds. An entity class that is never passed to forFeature() is invisible — no table is created for it.

## Call Shapes

There are two supported shapes:

```ts
// 1. Explicit classes, with optional options
EntityModule.forFeature([Workflows, StageWorkflows], {
	seeds: [workflowsSeed],
});

// 2. Options only — entities and seeds are autoloaded from paths
EntityModule.forFeature({
	entitiesPath: 'dist/common/database/entities',
	seedsPath: 'dist/common/database/seeds',
});
```

### Options

| Option | Type | Description |
|---|---|---|
| seeds | EntitySeedDefinition[] | Seeds created with createSeed(). |
| entitiesPath | string \| string[] | Directory or file to autoload entity classes from. |
| seedsPath | string \| string[] | Directory or file to autoload seeds from. |

Resolution order:

1. If the entities array is passed and not empty, it is used and entitiesPath is ignored.
2. Otherwise entities are loaded from entitiesPath.
3. If seeds is passed and not empty, it is used and seedsPath is ignored.
4. Otherwise seeds are loaded from seedsPath.

## Recommended Project Structure

Keep entities and seeds side by side, separate from business logic:

```txt
src/
  index.ts
  app/
    app.module.ts
  common/
    database/
      index.ts
      entities/
        index.ts
        workflows.entity.ts
        stage-workflows.entity.ts
      seeds/
        index.ts
        workflows.seed.ts
        stage-workflows.seed.ts
```

## Full Registration Example

```ts
import { Module } from '@nestjs/common';
import { EntityModule } from '@suppa/sdk';
import {
	StageWorkflows,
	Tasks,
	Workflows,
} from '../common/database/entities';
import {
	stageWorkflowsSeed,
	workflowsSeed,
} from '../common/database/seeds';

@Module({
	imports: [
		EntityModule.forFeature([Workflows, StageWorkflows, Tasks], {
			seeds: [workflowsSeed, stageWorkflowsSeed],
		}),
	],
})
export class AppModule {}
```

Two rules to remember:

- Every entity class must appear in the entities array.
- Every seed must appear in the seeds array, ordered so that dependencies come first.

## How Migrations Are Applied

You never run a migration command. The platform applies your schema as part of the application lifecycle:

1. The runner starts your application — on creation of the Applications record, on reload, or when a tenant is provisioned.
2. The SDK reads the decorator metadata of everything registered in EntityModule.forFeature() and generates two migration files: one for entity metadata, one for seeds.
3. The files are applied to the tenant's schema: tables, columns, relations, enums, indexes and checks are created or updated, then seeds are inserted or updated.
4. The result is recorded. If nothing in your entities or seeds changed since the last start, the step is skipped.

Two details worth knowing:

Entities and seeds behave differently on conflict. Entity metadata is overridden so the schema always converges to your class definitions. Seed rows are only inserted when missing, so data edited by users in the workspace is not overwritten on the next start.

It runs per tenant. Each tenant schema is migrated separately, and a newly provisioned tenant gets your entities and seeds when the application first boots for it.

## Removing and Re-Adding a Field

Deleting a property from an entity class and later adding it back with the same name is the one change whose outcome surprises people. Nothing is destroyed, and nothing starts empty: the field comes back with its old data.

The reason is that the sync matches your declarations against existing metadata by key, and the key of a field defaults to `<EntityName>.<fieldName>`. Same entity plus same field name means the same key, whether or not you ever set key yourself.

### Removing a Field

Delete the property, push, reload — on the next start the sync sees a key that exists in metadata but is no longer declared, and tears the field down:

| Step | Result |
|---|---|
| Field metadata | Soft-deleted: deletedAt is set and removedBy points at the system user. The row itself stays. |
| Column | Renamed, not dropped: title becomes title_1730457600000, where the suffix is deletedAt in epoch milliseconds. |
| Data | Untouched. Every value is still in the renamed column. |
| Relation fields | The relation is torn down the same way, through a soft removal. |
| Indexes and checks on the field | Their metadata is removed together with the field. |
| API and UI | The field is gone from metadata, from selects, and from forms. |

```sql
-- what the platform actually runs
ALTER TABLE "Tasks" RENAME COLUMN "title" TO "title_1730457600000";
```

> **WARNING**
> The renamed column is never cleaned up. It stays in the table with all its rows, invisible to the platform, until someone drops it manually. Removing a field is therefore not a way to delete data, and repeated remove/re-add cycles leave one leftover column each.

### Adding the Same Field Back

Re-declare the property with the same name on the same entity, and the sync resolves the key to the soft-deleted field instead of creating a new one:

| Step | Result |
|---|---|
| Field metadata | The same row is restored — deletedAt is cleared. No duplicate field, and the field id does not change. |
| Column | title_1730457600000 is renamed back to title. |
| Data | The old values are back, including everything written before the removal. |

```sql
ALTER TABLE "Tasks" RENAME COLUMN "title_1730457600000" TO "title";
```

> **WARNING**
> Re-adding a field is a restore, not a fresh start. If you removed the field in order to clear it, the data returns the moment you declare it again. There is no flag that turns this off — to get a genuinely empty field, the new field must have a different key.

## The Development Loop

```txt
edit entity / seed  →  push  →  reload the application  →  verify
```

Reload with the application record id:

```http
POST /core/applications/:appId/reload
```

(From Run application:) appId is the id of the record in the Applications table. The runner rebuilds the app from that record and restarts the running instance.

See Run application for the full application lifecycle.

> **TIP**
> For a faster loop, suppa-dev start --watch runs your application locally through a tunnel so hooks and controllers reload on save.

## Common Mistakes

| Symptom | Cause |
|---|---|
| No table is created for an entity | The class was not passed to EntityModule.forFeature(). |
| Autoloading finds nothing | entitiesPath / seedsPath points at src/ instead of compiled dist/. |
| Duplicate seed rows after a restart | Record is missing one or more importKeyFields, so nothing matched. |
| Seed relation is empty or fails to resolve | Relation referenced by numeric id, or the target seed runs later. |
| Back reference can't insert | A reverse @OneToMany field is present in a seed record, possibly as []. |
| A renamed entity or field appears as a new one | No key option was set, so the rename was not recognized as a rename. |
| default value is rejected | A JavaScript value was passed instead of a SQL expression string. |
| A re-added field already contains old data | Same name means the same key, so the soft-deleted field was restored with its column. |
| A removed field is still present after a reload | The removal pass was skipped because the system user could not be resolved. |
| The table has columns such as title_1730457600000 | Leftovers from removed fields. Renaming is how the platform removes a column; nothing cleans them up. |
| A tabular part has no ownerId column and its tab stays empty | relationEntityName was set, but the owner @ManyToOne was not declared. |
| A tabular part is missing from the parent's tabs | type: 'tabular-part' or relationEntityName is missing, so it is listed under /related instead. |
| Duplicate tabular-part rows across different parents | owner is missing from importKeyFields, so the key is matched globally. |

## Requirements

Use the latest version of @suppa/sdk:

```bash
pnpm add @suppa/sdk@latest
```

Entities rely on decorator metadata, so the application tsconfig.json must enable both decorator flags:

```json
{
	"compilerOptions": {
		"experimentalDecorators": true,
		"emitDecoratorMetadata": true
	}
}
```
