# Entities & decorators


Entities are plain TypeScript classes decorated with `@Entity`, `@Column`,
`@Key`, `@Unique`, and the audit/delete/clone decorators. The class maps to a
table; each decorated property maps to a column. snake_case is used everywhere
— DB column, TS property, JSON response.

## The minimum viable entity

```typescript
import "reflect-metadata";
import { Entity, Column, Key, Unique, type UUID } from "@primebrick/dal-pg";
import { Decimal } from "@primebrick/dal-pg";

@Entity("products")
export class ProductEntity {
  @Key() id!: bigint;
  @Unique() uuid!: UUID;
  @Column({ nullable: false }) name!: string;
  @Column({ jsType: "decimal", precision: 18, scale: 2, nullable: false }) price!: Decimal;
}
```

That's enough to use `dal.findById`, `dal.findByUUID`, `dal.findAll`,
`dal.add`, `dal.update`, `dal.delete`, `dal.hardDelete`. Add the audit/delete
decorators to opt into soft delete, audit trail, and optimistic locking.

## Decorator reference

### `@Entity(tableName?, schema?)`

Maps a class to a database table. If `tableName` is omitted, the table name
defaults to the class name. If `schema` is set, it overrides the Dal gateway's
default `search_path` schema for this entity only.

```typescript
@Entity("customers")                  // table "customers" in the Dal's primary schema (schemas[0])
@Entity("customers", "billing")       // table "billing.customers" (schema override)
@Entity()                             // table name === class name
export class CustomerEntity { ... }
```

The schema pin (explicit or the `schemas[0]` default) must belong to the
`DalConfig.schemas` domain — an entity pinned elsewhere throws
`SchemaDomainViolationError` at `getDal()` (boot) and on every entity
operation, before SQL is generated.

The table name is also exposed via `Reflect.defineMetadata("primebrick:tableName", ...)`
so external tooling (e.g. the SDK's `CacheKeyBuilder`) can read it without
importing `@primebrick/dal-pg`.

### `@Key()`

Marks exactly one column as the primary key. Used by `update`/`delete`/
`restore`/`hardDelete` as the default `matchBy` column. Defaults to the
`identity` generation strategy (DB auto-generates).

```typescript
@Key() id!: bigint;
```

### `@Unique()`

Marks a column as having a unique index. Used by `clone()` to identify columns
that must be excluded from the copy (a new value is generated for them). The
canonical example is `uuid`:

```typescript
@Unique() uuid!: UUID;
```

### `@IsNotColumn()`

Excludes a property from persistence metadata and DAL queries. Use it for
computed/transient properties that live on the class but not in the table.

```typescript
@IsNotColumn() get displayLabel(): string { return `${this.name} <${this.email}>`; }
```

### `@Column(sqlName?: string | ColumnOptions)`

Overrides the column's SQL name, PG type, nullability, length, precision,
scale, default SQL expression, or join cast type. If you don't need any of
those overrides, you can omit `@Column()` entirely — every own enumerable
property of `new ctor()` is a column by convention (see
[Architecture: `syncImplicitEntityColumns`](./architecture#syncimplicitentitycolumns)).

`@Column()` accepts either a string (treated as `sqlName`) or an options
object. The options object must set at least one field — an empty `@Column({})`
throws `TypeError`.

```typescript
@Column("full_name")                                    // rename column only
@Column({ length: 255 })                                 // varchar(255) on a `string` prop
@Column({ jsType: "decimal", precision: 18, scale: 2 })  // numeric(18,2) on a `Decimal` prop
@Column({ nullable: true })                              // nullable column
@Column({ defaultSql: "gen_random_uuid()" })             // DB-side default
@Column({ castInJoin: "uuid" })                          // cast to uuid in JOIN ON
```

#### `ColumnOptions`

| Field | Type | Purpose |
|-------|------|---------|
| `sqlName` | `string` | Override the SQL column name (defaults to the property name) |
| `pgType` | `PgColumnType` | Explicit storage type — canonical names only, and only when it *differs* from what the annotation infers (whitelist-enforced) |
| `length` | `number` | `varchar(N)`/`char(N)` on `string`, `vector(N)` on `Vector` |
| `precision` | `number` | For `numeric`/`decimal` |
| `scale` | `number` | For `numeric`/`decimal` |
| `nullable` | `boolean` | `NOT NULL` by default — declare `nullable: true` for nullable columns |
| `defaultSql` | `string` | Raw SQL DEFAULT expression (e.g. `now()`, `gen_random_uuid()`) |
| `jsType` | `"instant" \| "decimal" \| "vector"` | Runtime hydration marker — required on `Decimal`/`Vector` props |
| `castInJoin` | `string` | Cast type to apply when this field is used in a JOIN ON clause |

#### Type resolution

When `pgType` is omitted, the PG type resolves **statically from the TS type
annotation** in the schema pipeline — never from the property name and never
from runtime reflection. The full matrix and the authoring rules
(guarded types, whitelist, warnings) live in
[Column types](./column-types). The short version:

| TS annotation | PG type |
|---|---|
| `bigint` | `int8` |
| `number` | `float8` |
| `boolean` | `boolean` |
| `string` | `text` |
| `Date` | `timestamptz` |
| `UUID` | `uuid` |
| `ISODate` / `ISOTime` | `date` / `time` |
| `Decimal` | `numeric` |
| `Vector` | `vector` (`+ length` → `vector(N)`) |
| object-shaped types | `jsonb` |

Nullability is NOT inferred: columns default to `NOT NULL`; a nullable column
must declare `@Column({ nullable: true })` (or a decorator that implies it —
`@DeletableField`, `@CloneField`).

### `@AuditableField(type)` — opt into audit columns

Marks a column as one of the five audit fields. The `type` argument can be the
`AuditableFieldType` enum or the equivalent string literal.

```typescript
import { AuditableField, AuditableFieldType } from "@primebrick/dal-pg";

@AuditableField(AuditableFieldType.CREATED_AT)  created_at!: Date;
@AuditableField(AuditableFieldType.CREATED_BY)  created_by!: string;
@AuditableField(AuditableFieldType.UPDATED_AT)  updated_at!: Date;
@AuditableField(AuditableFieldType.UPDATED_BY)  updated_by!: string;
@AuditableField(AuditableFieldType.VERSION)     version!: number;

// Equivalent object form (accepted by the decorator):
@AuditableField({ type: "createdAt" }) created_at!: Date;
```

The `VERSION` column is what powers [optimistic locking](./optimistic-lock) —
if the entity has a version column, the version guard is enforced on every
write. There is no separate `@OptimisticLock()` flag.

`AuditableFieldType` enum values:

| Member | String | Column |
|--------|--------|--------|
| `CREATED_AT` | `"CREATED_AT"` | `created_at` |
| `CREATED_BY` | `"CREATED_BY"` | `created_by` |
| `UPDATED_AT` | `"UPDATED_AT"` | `updated_at` |
| `UPDATED_BY` | `"UPDATED_BY"` | `updated_by` |
| `VERSION` | `"VERSION"` | `version` |

### `@DeletableField(type)` — opt into soft delete

Marks a column as one of the two soft-delete fields. Finders exclude
soft-deleted rows by default (`deletedRecords: "EXCLUDED"`); pass
`"INCLUDED"` or `"ONLY"` to override.

```typescript
import { DeletableField, DeletableFieldType } from "@primebrick/dal-pg";

@DeletableField(DeletableFieldType.DELETED_AT) deleted_at?: Date | null;
@DeletableField(DeletableFieldType.DELETED_BY) deleted_by?: string | null;

// Equivalent object form:
@DeletableField({ type: "deletedAt" }) deleted_at?: Date | null;
```

`DeletableFieldType` enum values: `DELETED_AT` (`"DELETED_AT"`), `DELETED_BY`
(`"DELETED_BY"`).

### `@SynchronizableField(type)` — external sync tracking

Marks a column as the `last_synced_at` field. Used by integrations that sync
records from an external system on a schedule.

```typescript
import { SynchronizableField, SynchronizableFieldType } from "@primebrick/dal-pg";

@SynchronizableField(SynchronizableFieldType.LAST_SYNCED_AT)
@Column({ nullable: true }) last_synced_at?: Date | null;
```

`SynchronizableFieldType` enum values: `LAST_SYNCED_AT` (`"LAST_SYNCED_AT"`).

### `@CloneField()` — clone tracking

Marks a column as the clone-tracking field. `Repository.clone()` stamps it with
the source record's UUID so the clone knows its origin. See [Clone](./clone).

```typescript
@CloneField() cloned_from?: UUID | null;
```

### `@AuditTrail()` — entity HAS an audit trail table

Marks an entity as having a companion audit table (e.g. `customers` →
`customers_audit`). When `@AuditTrail()` is present and an `AuditPort` is
injected, write operations automatically compute a field-level delta and call
`audit.writeAudit()` fire-and-forget. See [Audit trail](./audit-trail).

```typescript
@Entity("customers")
@AuditTrail()
export class CustomerEntity { ... }
```

`@AuditTrail()` is also what enables optimistic locking — the version column
guard is applied to every entity that has `@AuditTrail()` (because auditable
entities always have a `VERSION` column).

### `@AuditTrailEntity(options?)` — entity IS an audit trail table

Marks a class as **being** an audit trail table (a read-only view over an audit
table like `customers_audit`). Records the `changed_by` column name so that
`buildAuditTrailJoins()` can resolve it. See [Audit trail](./audit-trail).

```typescript
import { Entity, Column, Key, AuditTrailEntity } from "@primebrick/dal-pg";

@Entity("audit_log")
@AuditTrailEntity({ changedByColumn: "changed_by" })
export class AuditLogEntity {
  @Key() id!: bigint;
  @Column({ nullable: false }) entity_id!: bigint;
  @Column({ nullable: false }) entity_uuid!: UUID;
  @Column({ nullable: false }) action!: string;
  @Column({ nullable: false }) changed_at!: Date;
  @Column({ nullable: false }) changed_by!: string;
  @AuditableField(AuditableFieldType.VERSION) version!: number;
  @Column({ nullable: true }) delta?: Record<string, { old: unknown; new: unknown }>;
}
```

### `@Index(opts?)` — secondary index

Declares a secondary (non-unique) index. Bare `@Index()` → single-column
btree auto-named `<table>_<column>_idx`. Columns sharing the same `name`
form a composite index ordered by `order`. Unique indexes stay on
`@Unique()`.

```typescript
export type IndexOptions = {
  name?: string;       // shared name → composite index
  order?: number;      // position inside the composite (default 0)
  using?: IndexMethod; // access method — default btree
  opclass?: string;    // e.g. "vector_cosine_ops" (hnsw), "gin_trgm_ops" (gin)
  where?: string;      // partial-index predicate, e.g. "deleted_at IS NULL"
};
```

```typescript
@Column({ nullable: false })
@Index({ using: "hnsw", opclass: "vector_cosine_ops" })
embedding!: number[];

@Column()
@Index({ where: "deleted_at IS NULL" })
email!: string;
```

### `@ForeignKey(target, opts?)` — REFERENCES constraint

Declares a FOREIGN KEY referencing another entity's column. `target` is a
lazy thunk (`() => CustomerEntity`) so circular references resolve at
meta-read time, not class-eval time. The zero-option form references the
target's `@Key()` column with `ON DELETE RESTRICT` / `ON UPDATE NO
ACTION` — the safest default.

```typescript
export type ForeignKeyOptions = {
  column?: string;           // referenced column — default: target's @Key()
  onDelete?: ForeignKeyAction; // default "restrict"
  onUpdate?: ForeignKeyAction; // default "no_action"
  name?: string;             // constraint name — default <table>_<column>_fkey
};
```

```typescript
@Column({ nullable: false })
@ForeignKey(() => CustomerEntity, { onDelete: "cascade" })
customer_uuid!: UUID;
```

A `@PartitionField`-partitioned parent **cannot** be a plain FK target
(unique constraints must contain the partition key) — meta validation
throws and points to `@PartitionChildOf`.

### `@PartitionField(opts?)` — RANGE partition key

Marks a column as the RANGE partition key (pg_partman, daily/monthly/…).
Exactly ONE per entity; the column must be date/timestamptz-typed and is
forced NOT NULL (a NULL would fall into no partition). Every unique group
and the primary key emitted in DDL must include it — meta validation
throws otherwise, so a partitioned entity can never produce an invalid
table definition.

```typescript
export type PartitionFieldOptions = {
  interval?: string;   // pg_partman interval — default "1 month"
  retention?: string;  // e.g. "6 months" — undefined = no auto-retention
  keepTable?: boolean; // retention_keep_table — false = DROP (default)
  premake?: number;    // future partitions kept ahead
};
```

```typescript
@Column({ nullable: false })
@PartitionField({ interval: "1 month", retention: "6 months" })
last_seen_at!: Date;
```

### `@PartitionChildOf(opts)` — membership in a partition unit (class decorator)

Declares the entity a logical child of a `@PartitionField`-partitioned
parent: it joins the parent's atomic delete and retention purge unit.
Needed because a partitioned parent cannot host a plain FOREIGN KEY —
the relationship is application-level, enforced by the repository cascade
and the partition-maintenance purge. Entities that merely *reference* a
parent (telemetry, logs) don't declare this.

```typescript
@Entity("ai_messages")
@PartitionChildOf({ parent: AiConversationEntity, ref: "conversation_uuid" })
export class AiMessageEntity { ... }
```

## Entity interfaces

The library ships four marker interfaces that document an entity's capabilities.
They are **types only** — they have no runtime effect — but they make the
entity's contract explicit and let consumers narrow on them.

```typescript
import type {
  IExposableEntity,
  IDeletableEntity,
  IAuditableEntity,
  IClonableEntity,
} from "@primebrick/dal-pg";
```

| Interface | Fields | Meaning |
|-----------|-------|---------|
| `IExposableEntity` | `uuid: string` | Entity has a public UUID safe to expose outside the system |
| `IDeletableEntity` | `deleted_at?: Date; deleted_by?: string` | Entity supports soft delete |
| `IAuditableEntity` | `created_at`, `created_by`, `updated_at`, `updated_by`, `version` | Entity has audit stamps + optimistic-lock version |
| `IClonableEntity` | `cloned_from?: string` | Entity supports cloning |

`IAuditableEntity` and `IDeletableEntity` are **independent** capabilities —
an entity can be audited but hard-deleted (`api_keys`), deletable but not
audited, both, or neither. Compose them with `&` where a signature needs
both (the Dal write-path already does: `TEntity extends IAuditableEntity &
IDeletableEntity` for `delete`/`restore`).

## Capability mixins — declare shared columns once

`@AuditableField` and `@DeletableField` are the underlying machinery, but
standard entities **never apply them by hand** — the same five audit columns
and two delete markers would be copy-pasted into every entity. The library
exports mixin factories that own the field set in a single declaration:

```typescript
import {
  EntityBase, Auditable, Deletable,
} from "@primebrick/dal-pg";
```

| Composition | Columns added | Use when |
|-------------|---------------|----------|
| `Auditable(EntityBase)` | `created_at`, `created_by`, `updated_at`, `updated_by`, `version` | Every audited entity |
| `Deletable(EntityBase)` | `deleted_at`, `deleted_by` | Soft-delete entities |
| `Deletable(Auditable(EntityBase))` | all seven | The common case |
| `EntityBase` alone | none | Plain tables (logs, telemetry) |

Each mixin is a real class in the prototype chain — the persistence layer
merges decorated columns from every ancestor, so the subclass only declares
its domain columns. Extra type-level capabilities keep their `implements`
on the concrete class (`IExposableEntity`, `IClonableEntity`, …).

## A complete entity

Putting it all together — an entity with PK, UUID, business fields, audit
columns, soft delete, and clone tracking:

```typescript
import "reflect-metadata";
import {
  Entity, Column, Key, Unique,
  CloneField, AuditTrail,
  EntityBase, Auditable, Deletable,
} from "@primebrick/dal-pg";
import type { IClonableEntity } from "@primebrick/dal-pg";

@Entity("customers")
@AuditTrail()
export class CustomerEntity
  extends Deletable(Auditable(EntityBase))
  implements IClonableEntity {
  @Key() id!: bigint;
  @Unique() uuid!: UUID;

  @Column({ nullable: false }) name!: string;
  @Column({ nullable: false }) email!: string;
  @Column({ nullable: false, defaultSql: "false" }) is_active!: boolean;

  @CloneField() cloned_from?: UUID | null;
}
```

A hard-delete audited entity simply drops `Deletable`:

```typescript
@Entity("api_keys")
export class ApiKeyEntity extends Auditable(EntityBase) {
  @Key() id!: bigint;
  @Unique() uuid!: UUID;
  @Column({ nullable: false }) key_hash!: string;
}
```

## Metadata helpers (advanced)

The library exports a few metadata helpers for tooling that needs entity
metadata without instantiating the class:

```typescript
import {
  isEntityClass,
  getTableName,
  getQualifiedTableName,
  getEntityName,
  getColumnName,
  getPrimaryKeyColumn,
  getEntityPersistenceMeta,
  listEntityPersistencePropertyKeys,
  syncImplicitEntityColumns,
} from "@primebrick/dal-pg";

isEntityClass(CustomerEntity);                          // true (has @Entity)
getTableName(CustomerEntity);                           // "customers"
getEntityName(CustomerEntity);                          // "CustomerEntity"
getColumnName(CustomerEntity, "created_at");            // "created_at"
getPrimaryKeyColumn(CustomerEntity);                    // { sqlName, propertyKey } for @Key()
getEntityPersistenceMeta(CustomerEntity);               // full metadata object
listEntityPersistencePropertyKeys(CustomerEntity);      // ["id", "uuid", "name", ...]
syncImplicitEntityColumns(CustomerEntity);              // walk properties + infer nullability
```

`syncImplicitEntityColumns` is called automatically by the Repository on first
use of an entity; you only need to call it manually if you're reading metadata
before any Repository call.

## Next steps

- [Query DSL](./query-dsl) — build type-safe filters, sorts, joins, projections.
- [Repository](./repository) — finders, writes, bulk ops, streaming.
- [Audit trail](./audit-trail) — the `@AuditTrail()` + `AuditPort` system.
- [Optimistic locking](./optimistic-lock) — the version guard powered by `@AuditableField(VERSION)`.
- [Clone](./clone) — the `@CloneField()` + `Repository.clone()` flow.
