# @mostajs/orm — fiche LLM
> ORM multi-dialecte inspiré d'Hibernate — une seule API, 13 bases de données, zéro lock-in.

- Version: 2.2.8 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI <drmdh@msn.com>
- Chemin: mostajs/mosta-orm · Statut audit: complet (dist/) · Anomalies #1-#13 + #16 corrigées (cf. docs/ANOMALIES-LOT3-2026-05-25.md) + #12 Mongo index doublon (2.2.8)

## RÔLE
Couche d'accès aux données unifiée pour Node.js/TypeScript. Le développeur décrit
ses entités sous forme d'objets `EntitySchema` (fields, relations, indexes) et obtient
une API CRUD/query identique quel que soit le SGBD cible : MongoDB, SQLite, PostgreSQL,
MySQL, MariaDB, Oracle, MSSQL, CockroachDB, DB2, SAP HANA, HSQLDB, Spanner, Sybase.
Concepts empruntés à Hibernate/JPA (CascadeType, FetchType, schemaStrategy = hbm2ddl.auto,
ConnectionConfig = persistence.xml). C'est le module fondation de l'écosystème mostajs.

## INSTALLATION
npm i @mostajs/orm
(driver natif requis selon dialecte : better-sqlite3, pg, mysql2, mariadb, oracledb,
mssql, ibm_db, @sap/hana-client, @google-cloud/spanner, mongoose — peer/optional deps)

## EXPORTS
- Registre schémas : registerSchema, registerSchemas, getSchema, getSchemaByCollection,
  getAllSchemas, getEntityNames, hasSchema, validateSchemas, clearRegistry
- Connexion/factory : getDialect, getConfigFromEnv, getCurrentDialectType, disconnectDialect,
  testConnection, createConnection, createIsolatedDialect, createDatabase, dropDatabase,
  registerNamedConnection, getNamedConnection, removeNamedConnection, listNamedConnections,
  clearNamedConnections
- Dialectes (métadonnées) : DIALECT_CONFIGS, getSupportedDialects, getDialectConfig
- Repository : BaseRepository, EntityService
- Introspection : OrmIntrospectionError, resolveLookup, findMatchingUniqueIndex, extractRelId
- Normalisation : normalizeDoc, normalizeDocs
- Migration : diffSchemas, generateMigrationSQL
- Erreurs : MostaORMError, EntityNotFoundError, ConnectionError, ValidationError, DialectNotFoundError
- Config (ré-export @mostajs/config) : getEnv, getEnvBool, getEnvNumber, getCurrentProfile
- Types : FieldType ('string'|'text'|'number'|'boolean'|'date'|'json'|'array'),
  FieldDef, EmbeddedSchemaDef, RelationType ('one-to-one'|'many-to-one'|'one-to-many'|'many-to-many'),
  RelationDef, CascadeType ('persist'|'merge'|'remove'|'all'),
  FetchType ('lazy'|'eager'), OnDeleteAction ('cascade'|'set-null'|'restrict'|'no-action'),
  IndexType, IndexDef, EntitySchema, FilterOperator, FilterValue, FilterQuery,
  SortDirection, QueryOptions, PaginatedResult, AggregateStage (et variantes),
  DialectType, SchemaStrategy ('validate'|'update'|'create'|'create-drop'|'none'),
  ConnectionConfig, IDialect, IRepository, IPlugin, HookContext, NormalizedDoc,
  TxHandle ({ id: string; startedAt: number; depth: 1=outermost / 2+=nested SAVEPOINT } — expérimentale),
  DialectConfig, ResolvedLookup, UniqueIndexMatch, DiffOperation (14 variantes — cf. TYPES CLÉS),
  OrmRequest, OrmResponse, OrmOperation

## EXPORTS PAR SOUS-CHEMIN
- `@mostajs/orm/register` : `register(registry: { register(r: ModuleRegistration): void }): void`
  — déclare l'ORM dans le graphe de dépendances @mostajs/socle (`ModuleRegistration` provient de
  `@mostajs/socle`). Le module fondation n'expose ni schémas ni repos propres ; cette fonction
  rend simplement l'ORM visible aux modules en aval qui veulent dépendre de lui par déclaration.
- `@mostajs/orm/bridge` : pont JDBC vers SGBD non-natifs Node via process Java.
  JdbcNormalizer, parseUri, JDBC_REGISTRY, hasJdbcDriver, getJdbcDriverInfo, BridgeManager,
  saveJarFile, deleteJarFile, listJarFiles, detectDialectFromJar, getJdbcDialectStatus ;
  types BridgeInstance, JarUploadResult, JdbcDriverInfo, JdbcBridgeConfig.
- `@mostajs/orm/validator` : linter conceptuel de schémas (24 règles : R001-R018
  + R003B + R004B + R013B + R019/R020/R021 ajoutées en 2.1.0 — voir bloc PIÈGES).
  validateSchemas, DEFAULT_RULES, applyFixes, rollbackFixes, formatText/formatJson/formatMarkdown,
  DEFAULT_SOFT_DELETE_PATTERNS, DEFAULT_AUDIT_BY_FIELDS, DEFAULT_THRESHOLDS ; types Severity,
  Finding, Report, ValidateOptions, Rule, RuleContext, FixOptions, FixResult, RollbackResult.
  Bin : `mostajs-orm-validator`.
  - `applyFixes(report: Report, opts: FixOptions): Promise<FixResult[]>`
    `FixOptions = { sourceRoot: string; dryRun?: boolean = true; rules?: string[]; backup?: boolean = true }`
    `FixResult = { ruleId, schema, field?, file, applied, reason?, diff?, description }`
  - `rollbackFixes(sourceRoot: string): RollbackResult[]` — restaure tous les `<file>.bak`
    présents sous `sourceRoot`, supprime les `.bak` après restauration ; idempotent.
  Les règles R019-R021 nécessitent `sourceRoot` dans les options pour faire de la
  détection AST cross-file via ts-morph sur le code consumer. Auto-fix R019/R021
  livré 2.1.0 (insert import extractRelId + wrap, idempotent, .bak backup).
  Règles ré-exportées individuellement (pour composition opt-in via Rule[] custom) :
  R001_EMPTY_RELATIONS, R002_FK_NAMING, R003_SOFT_DELETE,
  R003B_UNIQUE_WITH_SOFTDELETE_CONFLICT, R004_DUPLICATE_ENTITY,
  R004B_LEGACY_ENTITY, R005_ANY_TYPED_REPO, R006_JSON_AS_RELATION,
  R007_REDUNDANT_DERIVED_FIELD, R008_BEST_EFFORT_RESOLVER, R009_MISSING_LOOKUP_INDEX,
  R010_MISSING_AUDIT_TABLE, R011_LEGACY_DEAD_CODE, R012_DUPLICATE_IMPLEMENTATION,
  R013_MISSING_CASCADE, R013B_EAGER_WITHOUT_CASCADE,
  R014_REPO_FACTORY_BOILERPLATE, R015_FLAT_LIB_STRUCTURE,
  R016_AUDIT_EMAIL_AS_STRING, R017_UNBOUNDED_BLOB, R018_EXTERNAL_SCHEMA_OVERSCOPED,
  R019_FINDBYID_OBJECT_INPUT, R020_NATURAL_KEY_LOOKUP_OPPORTUNITY,
  R021_DIRECT_RELATION_COMPARISON.

## API — SIGNATURES
- getDialect(config?): Promise<IDialect> — singleton, reconnexion idempotente
- getConfigFromEnv(): ConnectionConfig — lit DB_DIALECT + SGBD_URI (cascade MOSTA_ENV)
- createConnection(config, schemas?): Promise<IDialect> — connecte + enregistre schémas
- createIsolatedDialect(config, schemas?): Promise<IDialect> — connexion neuve hors singleton
- testConnection(config): Promise<{ ok: boolean; error?: string }>
- createDatabase / dropDatabase(dialect, uri, dbName): Promise<{ ok; detail?; error? }>
- registerSchema(schema) / registerSchemas(schemas) : void
- getSchema(name): EntitySchema (throw si absent) ; getAllSchemas(): EntitySchema[]
- validateSchemas(): { valid: boolean; errors: string[] } — vérifie cibles de relations
- diffSchemas(oldSchemas, newSchemas): DiffOperation[]
- generateMigrationSQL(ops): string[]
- class BaseRepository<T extends { id: string }> implements IRepository<T>
  ctor(schema: EntitySchema, dialect: IDialect)
  findAll(filter?, options?) · findOne(filter, options?) · findById(idOrEntity, options?)
  findByIdWithRelations(id, relations?, options?) · create(data) · update(id, data)
  updateMany(filter, data) · delete(id) · deleteMany(filter)
  count(filter?, options?) · search(query, options?)
  distinct(field, filter?, options?) · aggregate(stages, options?) · upsert(filter, data)
  increment(id, field, amount) · addToSet(id, field, value) · pull(id, field, value)
  findWithRelations(filter, relations, options?)
  Toutes les méthodes read acceptent `options?: QueryOptions` ; `includeDeleted: true`
  bypasse le filtre soft-delete (2.2.0).
- class EntityService extends EventEmitter
  ctor(dialect) ; getRepo(name): BaseRepository ; getEntityNames() ; getEntitySchema(name)
  hasEntity(name) ; findAll/findOne/findById/create/update/delete/count(...) ;
  execute(req: OrmRequest): Promise<OrmResponse>
- IDialect : connect/disconnect/testConnection/initSchema + CRUD + aggregate + findWithRelations
  + upsert/increment/addToSet/pull/search ; signatures notables (2.2.0) :
  - `count(schema, filter, options?: QueryOptions)`
  - `distinct(schema, field, filter, options?: QueryOptions)`
  - `aggregate(schema, stages, options?: QueryOptions)`
  Optionnels :
  - executeQuery / executeRun (SQL brut, opt-in dialect)
  - $transaction<T>(cb, opts?: { isolation?: 'READ UNCOMMITTED'|'READ COMMITTED'|'REPEATABLE READ'|'SERIALIZABLE' }): Promise<T>
  - beginTx(opts?: { isolation? }): Promise<TxHandle> · commitTx(tx): Promise<void> · rollbackTx(tx): Promise<void>
    (API tx manuelle, expérimentale ; `poolSize: 1` requis sur dialectes SQL poolés ; MongoDB : utiliser $transaction(cb))
  - dropTable / dropAllTables / dropSchema / truncateTable / truncateAll.

  Mapping isolation par dialect (2.2.0) :
  - SQLite : ANSI → DEFERRED/IMMEDIATE/EXCLUSIVE
  - MySQL/MariaDB : `SET SESSION TRANSACTION ISOLATION LEVEL X; START TRANSACTION`
  - Oracle : 2 niveaux uniquement (READ COMMITTED, SERIALIZABLE) — mapping auto pour les 4 ANSI
  - HANA : READ UNCOMMITTED non supporté → mappé à READ COMMITTED
  - DB2 : ANSI → UR/CS/RS/RR (vocabulaire DB2 natif)
  - Postgres/CockroachDB/MSSQL : ANSI nativement supportés

## TYPES CLÉS
- EntitySchema : { name, collection, fields: Record<string,FieldDef>,
  relations: Record<string,RelationDef>, indexes: IndexDef[], timestamps: boolean,
  discriminator?, discriminatorValue?, softDelete? }
- FieldDef : { type: FieldType, required?, unique?, sparse?, default?, enum?, lowercase?,
  trim?, arrayOf? }  · FieldType = 'string'|'text'|'number'|'boolean'|'date'|'json'|'array'
- RelationDef : { target, type: RelationType, required?, select?, nullable?, through?,
  cascade?: CascadeType[], orphanRemoval?, fetch?: FetchType, mappedBy?, joinColumn?,
  inverseJoinColumn?, onDelete? }
- RelationType = 'one-to-one'|'many-to-one'|'one-to-many'|'many-to-many'
- ConnectionConfig : { dialect, uri, showSql?, formatSql?, highlightSql?, schemaStrategy?,
  poolSize?, cacheEnabled?, cacheTtlSeconds?, batchSize?, options? }
- SchemaStrategy = 'validate'|'update'|'create'|'create-drop'|'none' (= hbm2ddl.auto)
- FilterQuery : objet MongoDB-like ($eq/$ne/$gt/$gte/$lt/$lte/$in/$nin/$regex/$exists, $or/$and)
- QueryOptions : { sort?, skip?, limit?, select?, exclude?, includeDeleted? }
  - `includeDeleted?: boolean` (2.2.0) — bypasse le filtre soft-delete automatique
    sur read methods. Sans effet si `softDelete: true` n'est pas activé sur le schéma.
- IPlugin : hooks onSchemaInit/preSave/postSave/preUpdate/postUpdate/preDelete/onQuery/onResult
- TxHandle (lecture seule) : `{ id: string; startedAt: number; depth: 1|2|... }`
  — `depth === 1` = transaction réelle (BEGIN/COMMIT), `depth >= 2` = nested SAVEPOINT.
- DiffOperation (union discriminée — 14 variantes, type = discriminant) :
  - `{ type: 'addEntity'; schema: EntitySchema }`
  - `{ type: 'removeEntity'; entity: string; collection: string }`
  - `{ type: 'addField'; entity; field; def: FieldDef }`
  - `{ type: 'removeField'; entity; field }`
  - `{ type: 'alterField'; entity; field; from: FieldDef; to: FieldDef }`
  - `{ type: 'addIndex'; entity; index: IndexDef }` · `{ type: 'removeIndex'; entity; index }`
  - `{ type: 'renameCollection'; entity; from; to }`
  - `{ type: 'addTimestamps'; entity }` · `{ type: 'removeTimestamps'; entity }`
  - `{ type: 'addSoftDelete'; entity }` · `{ type: 'removeSoftDelete'; entity }`
  - `{ type: 'addDiscriminator'; entity; field; value }` · `{ type: 'removeDiscriminator'; entity; field }`

## NORMALISATION — `normalizeDoc` / `normalizeDocs`

```ts
import { normalizeDoc, normalizeDocs, type NormalizedDoc } from '@mostajs/orm'
```

### Signature

- `normalizeDoc<T>(doc: any): T` — normalise un document brut renvoyé par le dialect.
- `normalizeDocs<T>(docs: any[]): T[]` — applique `normalizeDoc` sur un tableau.

### Transformations effectuées (3 seulement)

| Source | Cible | Quand |
|---|---|---|
| `_id` (Mongo ObjectId) | `id: string` | Toujours. `_id.toString()` via optional chaining → string. Si `_id` absent, `id = doc.id`. |
| `__v` (mongoose version) | (supprimé) | Toujours. Le champ `__v` (version automatique de mongoose) est retiré du résultat. |
| Sous-documents populés (récursif) | `_id → id` interne | Récursion sur sous-objets et items d'arrays qui contiennent `_id !== undefined`. |

### Transformations qui ne sont **pas** effectuées par `normalizeDoc`

- **Dates** : pas de conversion `string ISO → Date` (déléguée au dialect — voir bloc « Conversions types SQL »).
- **JSON / array** : géré côté SQL par `deserializeField` (parse JSON.parse safe) — `normalizeDoc` ne touche pas.
- **Boolean** : géré côté SQL par `deserializeBoolean` (1/0 → true/false) — `normalizeDoc` ne touche pas.
- **Soft-delete filter** : géré côté dialect (cf. `applySoftDeleteFilter`).
- **Validation de schéma** : `normalizeDoc` ne valide rien — fait confiance au dialect.

### Conversions types par dialect

Appliquées par chaque dialect **avant** `normalizeDoc` quand on passe par
`BaseRepository`. Comportement par `field.type` et par dialect :

| `field.type` | SQLite raw | Postgres / MySQL raw | MongoDB raw | Retour consumer |
|---|---|---|---|---|
| `'string'` / `'text'` | TEXT | TEXT / VARCHAR | String | `string` |
| `'number'` | INT / REAL | INT / FLOAT | Number | `number` |
| `'boolean'` | INT 0/1 → `true/false` (`deserializeBoolean`) | BOOLEAN driver natif | Boolean | `boolean` |
| `'date'` | TEXT ISO → ⚠️ **string** (non converti) | TIMESTAMP → **Date** (driver pg/mysql2 natif) | BSON Date → **Date** (mongoose `schemaDef.type = Date` cf. `mongo.dialect.ts:60`) | **dialect-dependent** |
| `'json'` | TEXT → `JSON.parse` safe (`deserializeField`) | JSONB driver natif → object | Object BSON | `object` |
| `'array'` | TEXT → `JSON.parse` safe (`deserializeField`, fallback `[]`) | JSONB / ARRAY natif | Array BSON | `array` |

**Piège `date` sur SQLite** : `deserializeField('date', val)` retourne `val`
tel quel (`abstract-sql.dialect.ts:556`). Sur SQLite, la valeur sortante
est une `string` ISO 8601 (`"2026-04-01T08:00:00.000Z"`), **pas un `Date`
object**. Le consumer qui s'attend à `instance? true` doit faire
`new Date(val)` manuellement OU adopter un dialect qui hydrate nativement
(Postgres/MySQL/Mongo).

**Piège `date` sur sérialisation HTTP** : si vous passez par `@mostajs/net`
(mode NET via `@mostajs/data-plug`), `JSON.stringify(date)` produit une
string ISO et `JSON.parse` côté client retourne la string (comportement
standard JSON, pas une régression). C'est documenté côté
`@mostajs/net` — pour préserver l'instance `Date` côté client NET,
re-hydrater avec `new Date(val)` après réception HTTP.

### Quand appeler `normalizeDoc` manuellement

`BaseRepository` applique **automatiquement** `normalizeDoc` après chaque
read (`findAll`, `findOne`, `findById`, `findByIdWithRelations`,
`findWithRelations`, `search`, `create`, `update`, `upsert`, `increment`,
`addToSet`, `pull`).

Cas d'usage manuel :
- Résultat de `dialect.executeQuery(rawSQL, params)` (SQL brut, hors repo)
- Documents Mongo récupérés via mongoose direct (hors `BaseRepository`)
- Migration de code legacy qui manipule des `_id` Mongo

### Pièges

- `normalizeDoc(null)` / `normalizeDoc(undefined)` → retourne l'input tel quel (no-op).
- `id` peut être `undefined` si ni `_id` ni `doc.id` ne sont définis (cas d'un objet pré-insert).
- Sur Mongo, `_id.toString()` produit `"507f1f77bcf86cd799439011"` (24 hex chars). Sur SQL, `id` est déjà une string (UUID ou autoincrement-string).
- La récursion ne descend que dans les sous-objets contenant `_id`. Un sous-objet métier sans `_id` (par ex. `{ address: { street, city } }`) **n'est pas touché**.

## ERREURS TYPÉES — QUAND LEVÉES
- `MostaORMError(message)` — racine de toutes les erreurs ORM (catch générique).
- `EntityNotFoundError(entityName, id?)` — levée par `update(id, …)` / `delete(id)` quand `id`
  n'existe pas. Pas levée par `findById` / `findOne` (qui retournent `null`).
- `ConnectionError(dialect, cause?)` — levée par `createConnection` / `testConnection`
  quand l'URI est invalide, le driver natif manque, ou le SGBD refuse la session.
- `ValidationError(entityName, details)` — levée à `create` / `update` quand un field `required`
  est absent, qu'un `enum` est violé, ou qu'un `unique` est dupliqué (selon dialect).
- `DialectNotFoundError(dialect)` — levée par `getDialect({ dialect: 'inconnu' })` si la valeur
  ne correspond à aucun `DialectType` supporté (cf. `getSupportedDialects()`).
- `OrmIntrospectionError` (introspection, pas une `MostaORMError`) — levée par `findById({…})`
  quand l'objet ne matche ni `id`, ni un index unique ; expose `schemaName`, `input`,
  `availableFields`, candidates de matching pour message actionnable.

## PATTERN
```ts
import { createConnection, BaseRepository, type EntitySchema } from '@mostajs/orm';

const UserSchema: EntitySchema = {
  name: 'User', collection: 'users',
  fields: { email: { type: 'string', required: true, unique: true },
            name:  { type: 'string' } },
  relations: {}, indexes: [], timestamps: true,
};

const dialect = await createConnection(
  { dialect: 'sqlite', uri: './app.db', schemaStrategy: 'update' },
  [UserSchema],
);
const users = new BaseRepository<{ id: string; email: string; name?: string }>(UserSchema, dialect);
await users.create({ email: 'a@b.c', name: 'Alice' });
const found = await users.findOne({ email: 'a@b.c' });
```

## DÉPEND DE
- Runtime : @mostajs/config (cascade env). peerDeps : @mostajs/socle (graphe modules).
- Drivers SGBD en peer/optional deps (installer seulement ceux utilisés).
- Pas d'autre dépendance @mostajs/* obligatoire — c'est le module fondation.

## PIÈGES
- BREAKING 2.0 : toutes les relations sont `lazy` par défaut (avant : many-to-one et
  one-to-one étaient eager). `findById()` retourne désormais la string id pour ces relations,
  pas l'objet populé. Utiliser `findByIdWithRelations` / `findWithRelations` pour populer,
  ou `extractRelId(value)` pour des comparaisons sûres en lazy comme en eager.
- `getDialect()` est un singleton ; pour plusieurs connexions simultanées utiliser
  `createIsolatedDialect()` (hors singleton, schémas non enregistrés globalement).
- `schemaStrategy: 'create'` drop puis recrée ; `'create-drop'` drop à l'arrêt. En prod : `'validate'`.
- `softDelete: true` sur un `EntitySchema` :
  - **injecte automatiquement** le champ `deletedAt: Date | null`. Pas besoin de le déclarer.
  - **filtre automatiquement** `findAll` / `findOne` / `findById` / `count` / `search` /
    `aggregate` sur `deletedAt IS NULL` (SQL) ou `deletedAt: null` (Mongo).
  - `delete(id)` devient un soft-delete (UPDATE `deletedAt = now`), pas un DELETE physique.
  - **Pas d'option `includeDeleted`** côté repo en 2.1.0. Pour récupérer une ligne soft-deletée,
    filtrer explicitement : `findOne({ deletedAt: { $ne: null } })` ou `findAll({ deletedAt: { $exists: true } })`.
  - Compatible `sparse: true` sur unique indexes → réinsertion possible après soft-delete (R003B).
- API tx manuelle (beginTx/commitTx/rollbackTx) : expérimentale, type `TxHandle`. Sur dialectes
  SQL poolés, forcer `poolSize: 1` pour la cohérence stricte ; MongoDB : utiliser `$transaction(cb)`.
- Ne jamais mettre `cascade: ['remove'|'all']` sur une relation many-to-many (supprimerait l'entité cible).
- `dropDatabase` est IRRÉVERSIBLE. `findById(objet)` lève `OrmIntrospectionError` si l'objet
  ne correspond ni à `id` ni à un index unique.
- Multi-dialecte FK : sur SQL, les contraintes `FOREIGN KEY ... ON DELETE ...` sont générées
  depuis `relations[name].onDelete` à la création de schema ; sur MongoDB, il n'y a pas de
  FK natives — `findWithRelations` utilise `.populate()` (mongoose ref). L'API JS reste
  identique ; le dialect adapte. Conséquence : `onDelete: 'cascade'` ne s'applique côté
  serveur que sur SQL — sur MongoDB il faut implémenter manuellement la cascade (cf. R013).
- Validator 2.1.0 — nouvelles règles :
  Cross-file consumer-code (nécessitent `sourceRoot`) :
  - **R019-FINDBYID-OBJECT-INPUT** *(warning, fixable)* — flag `repo.findById(entity.relation)`
    où relation est déclarée comme relation. Auto-fix : insert import extractRelId + wrap.
  - **R020-NATURAL-KEY-LOOKUP-OPPORTUNITY** *(info, non-fixable par design)* — flag
    `repo.findOne({field:x})` quand `field` (ou composite) est un unique index ;
    `findOne` reste valide.
  - **R021-DIRECT-RELATION-COMPARISON** *(warning, fixable)* — flag
    `entity.relation === value` : sous `fetch:'eager'`, toujours faux. Auto-fix wrap.
  Schema-only (pas besoin de `sourceRoot`) :
  - **R003B-UNIQUE-WITH-SOFTDELETE-CONFLICT** *(warning, fixable)* — flag les index
    unique non-sparse sur schémas avec soft-delete (natif ou pattern manuel) — bloque
    la réinsertion après soft-delete. Auto-fix : ajoute `sparse: true`.
  - **R013B-EAGER-WITHOUT-CASCADE** *(warning, fixable)* — flag les relations
    `fetch: 'eager'` sans `onDelete` — orphelins populés silencieusement. Auto-fix
    insère un `onDelete` cohérent ('cascade' si required ou one-to-many, sinon 'set-null').

## PATTERNS RÉVÉLÉS PAR LOT 3 (samples 10-15)

### Relations + FK cascade (sample 10)

```ts
const ProfileSchema: EntitySchema = {
  fields: { bio: { type: 'text' }, userId: { type: 'string', required: true, unique: true } },
  relations: {
    user: { target: 'User', type: 'one-to-one', joinColumn: 'userId', onDelete: 'cascade', required: true },
  },
}
// Convention : le champ FK (userId) peut coexister avec relation.joinColumn — fix #2 (2.2.0).
// onDelete:'cascade' génère bien `FOREIGN KEY … ON DELETE CASCADE` y compris sur SQLite (fix #6 2.2.1).
```

### Lazy vs Eager fetch (sample 11)

```ts
// Lazy default (2.0+) : findById retourne string id
const r = await regs.findById(reg.id)
typeof r.projectId  // 'string'

// Populate explicite
const populated = await regs.findByIdWithRelations(reg.id, ['project'])
typeof populated.project  // 'object' — fix #8 (2.2.3) : utilise joinColumn pour lire FK

// Eager opt-in (auto-populate sur findById)
const eagerSchema = { …, relations: { project: { …, fetch: 'eager' } } }

// Safe comparisons (R019/R021) :
import { extractRelId } from '@mostajs/orm'
if (extractRelId(reg.project) === proj.id) { /* match en lazy ET eager */ }
```

### Migration schema → schema (sample 12)

```ts
import { diffSchemas, generateMigrationSQL } from '@mostajs/orm'
const ops = diffSchemas([UserV1], [UserV2])     // DiffOperation[] (14 variantes)
const sql = generateMigrationSQL(ops)            // ALTER TABLE… / CREATE TABLE…

// Runtime : strategy:'update' migre auto au boot.
// Fix #9 (2.2.4) : ajoute désormais createdAt/updatedAt/deletedAt/discriminator
// quand timestamps/softDelete/discriminator sont activés rétroactivement.
const dialect = await createConnection({ …, schemaStrategy: 'update' }, [UserV2])
```

### Soft-delete + sparse partial unique (sample 13)

```ts
const UserSchema: EntitySchema = {
  fields: { email: { type: 'string', required: true, unique: true } },
  indexes: [{ fields: { email: 'asc' }, unique: true, sparse: true }],
  softDelete: true,
}
// Fix #10 (2.2.5) : génère CREATE UNIQUE INDEX … WHERE deletedAt IS NULL
// (partial unique). Réinsertion possible après soft-delete.
// Bypass filtre auto via :
const all = await users.findAll({}, { includeDeleted: true })  // fix #1 (2.2.0)
```

### Pattern audit-by (sample 14)

```ts
import { DEFAULT_AUDIT_BY_FIELDS } from '@mostajs/orm/validator'
// 8 champs canoniques :
// createdBy, updatedBy, deletedBy, archivedBy, validatedBy, scannedBy, reviewedBy, approvedBy

// Pattern wrapper minimal — pas de hook ORM requis :
function audited(repo) {
  return {
    create:     (data, actor) => repo.create({ ...data, createdBy: actor, updatedBy: actor }),
    update:     (id, data, actor) => repo.update(id, { ...data, updatedBy: actor }),
    approve:    (id, actor) => repo.update(id, { approvedBy: actor, updatedBy: actor }),
    softDelete: async (id, actor) => {
      await repo.update(id, { deletedBy: actor, updatedBy: actor })  // AVANT soft-delete
      await repo.delete(id)
    },
  }
}
```

### Transactions avec savepoints (sample 15)

```ts
// Wrapper $transaction (recommandé) :
await dialect.$transaction!(async () => {
  await users.create({ email: 'tx@e.c' })
  await profile.create({ userId: '…' })
}, { isolation: 'SERIALIZABLE' })   // mappé par dialect (SQLite/MySQL/Oracle/DB2/HANA) — fix #5 (2.2.0)

// API manuelle avec savepoints :
const tx = await dialect.beginTx!()
try {
  await users.update(id, { active: true })
  const inner = await dialect.beginTx!()         // SAVEPOINT nested (depth: 2)
  try { await users.delete(otherId); await dialect.commitTx!(inner); }
  catch { await dialect.rollbackTx!(inner); }
  await dialect.commitTx!(tx)
} catch { await dialect.rollbackTx!(tx); }
```

### Constantes exportées du validator

- `DEFAULT_SOFT_DELETE_PATTERNS` — patterns détectés par R003 (`deleted: boolean` + `deletedAt: Date`, etc.)
- `DEFAULT_AUDIT_BY_FIELDS` — 8 champs canoniques (cf. sample 14)
- `DEFAULT_THRESHOLDS` — seuils par défaut R010 (audit-table absence), R017 (BLOB), etc.

## RÉFÉRENCES
- README.md · CHANGELOG.md
- docs/ORMConceptValidator.md, docs/TECHNIQUE-INTROSPECTION-FINDONEBYID.md,
  docs/MONITORING-orm.md, docs/IDEE-INTRASPECTION-SCHEMAT.md
