# FieldSchema and Decorators

> **Prerequisite**: the `*.field-schema.ts` file you need to read or reason about already exists on
> disk — the CLI generated it from the module's YAML. If the file does not exist or you need a field
> that is not in it, return to Step 0 of `SKILL.md` and use the YAML + `catalyst load` path.
> Hand-editing a field-schema bypasses the generator and breaks on the next regeneration.

## When it applies

You are wiring `@Sanitize`, `@Format`, or `@ApplySchema` on a handler's `main()`; or you are reading
a `*.field-schema.ts` file and need to understand what a given field type means at runtime; or you
are computing a delta between an incoming payload and the persisted record via `Obj.diff`.

For querying (QueryStatement, Operator, include), use [`query-statement.md`](query-statement.md)
instead.

## The `FieldSchema` interface

Exported from `@aurorajs.dev/core-back`:

```typescript
export interface FieldSchema {
  name: string;
  type: string;
  nullable?: boolean;
  maxLength?: number;
  enumOptions?: string[];
  decimals?: [number, number];
  rules?: Record<string, unknown>;
}
```

Each `*.field-schema.ts` file exports a `FieldSchema[]` derived from the module's YAML
`aggregateProperties`. The shape is CURATED, not 1:1: `rowId` (sequential bigint) is omitted; YAML
`relationship` metadata collapses to a bare `type: 'id'` entry without relation info (for
relationship semantics see the root-scope `catalyst-schema` skill).

Minimal example from `backend/src/@app/iam/tag/domain/iam-tag.field-schema.ts`:

```typescript
import { FieldSchema } from '@aurorajs.dev/core-back';

export const IamTagFieldSchema: FieldSchema[] = [
  { name: 'id', type: 'id', nullable: false, maxLength: 36 },
  { name: 'name', type: 'varchar', nullable: false, maxLength: 64, rules: { minLength: 1 } },
  { name: 'createdAt', type: 'timestamp', nullable: true },
  { name: 'updatedAt', type: 'timestamp', nullable: true },
  { name: 'deletedAt', type: 'timestamp', nullable: true },
];
```

Mixed-types example from `backend/src/@app/iam/account/domain/iam-account.field-schema.ts`:

```typescript
export const IamAccountFieldSchema: FieldSchema[] = [
  { name: 'id', type: 'id', nullable: false, maxLength: 36 },
  {
    name: 'type',
    type: 'enum',
    nullable: false,
    enumOptions: ['USER', 'SERVICE'],
  },
  { name: 'email', type: 'varchar', nullable: true, maxLength: 128 },
  { name: 'username', type: 'varchar', nullable: false, maxLength: 128, rules: { minLength: 1 } },
  { name: 'isActive', type: 'boolean', nullable: false },
  { name: 'tags', type: 'array', nullable: true },
  { name: 'dPermissions', type: 'jsonb', nullable: false },
  { name: 'meta', type: 'jsonb', nullable: true },
  { name: 'createdAt', type: 'timestamp', nullable: true },
  // … plus surname, deletedAt, etc.
];
```

Password example from `backend/src/@app/iam/user/domain/iam-user.field-schema.ts`:

```typescript
export const IamUserFieldSchema: FieldSchema[] = [
  // … id, accountId, name, surname, avatar, mobile, langId, …
  { name: 'password', type: 'password', nullable: false, maxLength: 255 },
  { name: 'isTwoFactorAuthenticationEnabled', type: 'boolean', nullable: false },
  // …
];
```

## Type handlers catalog

Behind each `type` string there is a **type handler** at
`@aurorajs.dev/core-back/dist/modules/type-handlers/handlers/`. Each handler implements:

```typescript
interface TypeHandler {
  validate?(value: unknown, options?: TypeHandlerOptions): void;
  sanitize?(value: unknown, options?: TypeHandlerOptions): unknown;
  format?(value: unknown, options?: TypeHandlerOptions): unknown;
}
```

- **`validate`** throws if the value violates the schema (type mismatch, maxLength overflow, enum
  membership failure, `rules.minLength` violation). Runs before persist.
- **`sanitize`** transforms the value going in (e.g., trim strings, remove timezone from timestamps,
  bcrypt-hash passwords).
- **`format`** transforms the value coming out (e.g., add timezone to timestamps, mask sensitive
  data).

The 16 handlers in catalyst today:

| Type        | Behavior highlights                                                                  |
| ----------- | ------------------------------------------------------------------------------------ |
| `id`        | UUID v4; `maxLength: 36`.                                                            |
| `varchar`   | String; validates `maxLength` and `rules.minLength`.                                 |
| `text`      | Unbounded string; validates `rules.minLength`.                                       |
| `char`      | Fixed-length string; validates exact length from `maxLength`.                        |
| `password`  | See spotlight below.                                                                 |
| `enum`      | Validates membership in `enumOptions`. Never null unless `nullable: true`.           |
| `boolean`   | Validates it is `true` / `false`.                                                    |
| `timestamp` | ISO 8601; `sanitize` strips timezone, `format` applies `handlerMeta.timezone`.       |
| `date`      | Calendar date without time; format respects `handlerMeta.timezone`.                  |
| `array`     | JS array; no per-element validation unless `rules` declares one.                     |
| `jsonb`     | Arbitrary JSON, stored as `jsonb` in Postgres. No schema validation inside.          |
| `json`      | Arbitrary JSON stored as `json`. Same semantics as `jsonb` from the handler.         |
| `bigint`    | Arbitrary-precision integer as string. Range-validated.                              |
| `smallint`  | 16-bit signed integer (-32768..32767). Validates range.                              |
| `int`       | 32-bit signed integer. Validates range.                                              |
| `decimal`   | Fixed-precision decimal; `decimals: [total, scale]` (e.g., `[10, 2]` = 99999999.99). |

When a type requires additional constraints, `rules` carries them. Observed rules today:

- `minLength` — minimum string length (applied by `varchar`, `text`, `password`).

The rules bag is extensible — upstream handlers may consult additional keys as catalyst grows.

### Password spotlight (anchored in `iam/user`)

The password handler is the canonical example of a type that couples validation, encryption, and
output masking. Its real source, from
`@aurorajs.dev/core-back/dist/modules/type-handlers/handlers/password.type-handler.js`:

```typescript
export const PasswordTypeHandler = {
  validate(value, options) {
    if (options?.nullable && (value === null || value === undefined)) return;
    if (typeof value !== 'string') {
      throw new Error(`${options?.fieldName ?? 'Field'}: expected string, got ${typeof value}`);
    }
    const minLength = options?.rules?.minLength ?? 8;
    if (value.length < minLength) {
      throw new Error(
        `${options?.fieldName ?? 'Field'}: password must be at least ${minLength} characters`,
      );
    }
  },
  sanitize(value) {
    if (typeof value !== 'string' || value.length === 0) return value;
    return bcrypt.hashSync(value, SALT_ROUNDS); // SALT_ROUNDS = 10
  },
  format() {
    // never return password values
    return undefined;
  },
};
```

Consequences for any handler that touches an `iam/user` record:

- **On write** (create/update with `@ApplySchema(IamUserFieldSchema)`): the plaintext password from
  the payload is replaced by its bcrypt hash before the service runs. You never store plaintext.
- **On read** (`@Format(IamUserFieldSchema)`): the `password` key in the returned object becomes
  `undefined`. JSON serialization drops it, so the client never sees the hash.

This is what makes FieldSchema the **validation + modification layer that replaces Value Objects**:
domain-specific behavior (bcrypt, masking) is declarative via `type: 'password'`, not imperative via
a `Password` VO you construct in the handler.

Note — this protection extends to relations loaded via `include` since
`@aurorajs.dev/core-back@1.1.0`: the pipeline recurses through `relation` entries in the
generated schema and applies `PasswordTypeHandler.format()` to every nested password field. See
the "Relation formatting" section in [`query-statement.md`](query-statement.md) for the contract
and requirements.

## The three decorators

All three live in `@aurorajs.dev/core-back` and apply a `FieldSchema[]` to a method's payload and/or
return value. Always prefer the decorator over calling `sanitizeRecord()` or `formatRecord()`
directly — the decorator wires `handlerMeta.timezone` and other options automatically.

### `@ApplySchema` — sanitize + format (write handlers that return the entity)

```typescript
import { ApplySchema } from '@aurorajs.dev/core-back';

@EmitEvent('iam.tag.created')
@ApplySchema(IamTagFieldSchema)
async main(payload: IamCreateTagInput, handlerMeta?: HandlerMeta): Promise<IamTag> {
  await this.createService.main(payload, handlerMeta);
  return await this.findByIdService.main(payload.id, {}, handlerMeta);
}
```

- Runs `sanitize` on `payload` BEFORE `main()` executes.
- Runs `format` on the return value AFTER `main()` resolves.
- The default assumes `payload` is the first argument. For a different position, pass the object
  form:

  ```typescript
  @ApplySchema({ schema: IamTagFieldSchema, payloadIndex: 1 })
  async main(otherArg, payload, handlerMeta?) { … }
  ```

### `@Format` — output only (read handlers)

```typescript
import { Format } from '@aurorajs.dev/core-back';

@Format(IamTagFieldSchema)
async main(id: string, constraint?: QueryStatement, handlerMeta?: HandlerMeta): Promise<IamTag> {
  const tag = await this.findByIdService.main(id, constraint, handlerMeta);
  if (!tag) throw new NotFoundException(`IamTag with id: ${id}, not found`);
  return tag;
}
```

- Detects the return shape automatically: single object → formats the object; array → formats each
  element; `Pagination` → formats each element in `.rows`.
- Does not touch the payload. Reads are idempotent; the arguments go to the service unchanged.

### `@Sanitize` — input only (write handlers that do not return the entity)

```typescript
import { Sanitize } from '@aurorajs.dev/core-back';

@Sanitize(IamUserFieldSchema)
async main(payload: IamBulkIngestUsersInput, handlerMeta?: HandlerMeta): Promise<void> {
  await this.bulkIngestService.main(payload, handlerMeta);
}
```

- Same sanitize step as `@ApplySchema`, but no format. Use when the handler returns `void`,
  `boolean`, or a summary type that is NOT the entity.
- Same `payloadIndex` option as `@ApplySchema`.
- **No current real usage in the backend.** Documented for completeness and for the SRP case:
  write-only flows where imposing a format pass on an unrelated return type would be noise.

### Decision tree

```
Does the handler RECEIVE a payload?
  │
  ├── No  → @Format(schema)  if it returns entity data, or nothing at all
  │
  └── Yes
        │
        └── Does the handler RETURN the entity (or entities)?
              │
              ├── Yes → @ApplySchema(schema)
              │
              └── No  → @Sanitize(schema)
```

Additional dimension: if the payload is NOT the first argument, use the object form
`{ schema, payloadIndex: N }` on `@ApplySchema` or `@Sanitize` (no `payloadIndex` on `@Format` — it
operates on the return value).

## `Obj.diff` helper

Imported from `@aurorajs.dev/core-common`:

```typescript
class Obj {
  static diff<T = any>(newObj: T, origObj: T): T;
}
```

Used in update handlers to compute the subset of fields that actually changed between the incoming
payload and the persisted record. Keeps the UPDATE statement minimal and plays well with auditing.

Real usage in `backend/src/@app/iam/tag/application/update/iam-update-tag-by-id.handler.ts`:

```typescript
const tag = await this.findByIdService.main(payload.id, constraint, handlerMeta);
if (!tag) throw new NotFoundException(`IamTag with id: ${payload.id}, not found`);

const dataToUpdate = Obj.diff(payload, tag);

await this.updateByIdService.main(
  { ...dataToUpdate, id: payload.id }, // id re-added explicitly
  constraint,
  handlerMeta,
);
```

The `id` is re-added after the diff because `Obj.diff` omits keys whose values match between sides.
The update service needs the `id` as the target key, so it is appended explicitly.

See
[`catalyst-handler-composer/references/update-handler.md`](../../catalyst-handler-composer/references/update-handler.md)
for the full update-handler flow.

## Archetype-specific anti-patterns

- **Hand-editing `*.field-schema.ts`.** The file is CLI-generated. Edit the YAML + run
  `catalyst load back module --name=<bc>/<module> --force`. The lockfile + integrity-hash mechanism
  will flag a hand-edit on the next regeneration and emit a `.origin` conflict.
- **Duplicating validation inside `main()`.** `@ApplySchema` already enforces `maxLength`,
  `nullable`, `enumOptions`, and declared `rules`. Adding `if (payload.name.length > 128) throw …`
  inside `main()` is dead code; remove it and let the decorator run. Keep only business validation
  that the schema cannot express.
- **Constructing Value Objects in the handler to validate.** Catalyst has no VO layer; the
  validation + modification concern is fully delegated to `FieldSchema` + type handlers via
  `@ApplySchema`.
- **Calling `formatRecord` or `sanitizeRecord` directly.** Always use `@ApplySchema`, `@Format`, or
  `@Sanitize`. The decorators extract `handlerMeta.timezone` and other options for you; a direct
  call loses that context and produces inconsistent output.
- **Conflating `@Sanitize` alone with `@ApplySchema`.** They differ: `@Sanitize` runs only on input
  and does NOT format the return. If the handler returns the entity, use `@ApplySchema`; otherwise
  `@Sanitize` is fine on its own.
