@nodefony/drizzle 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +544 -0
- package/README.md +162 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +105 -0
- package/dist/nodefony/command/migrateShared.js +247 -0
- package/dist/nodefony/command/orm-generate.js +356 -0
- package/dist/nodefony/command/orm-migrate-baseline.js +208 -0
- package/dist/nodefony/command/orm-migrate-repair.js +114 -0
- package/dist/nodefony/command/orm-migrate-status.js +67 -0
- package/dist/nodefony/command/orm-migrate.js +141 -0
- package/dist/nodefony/command/orm-reset.js +166 -0
- package/dist/nodefony/config/config.js +107 -0
- package/dist/nodefony/config/defineModuleConfig.js +63 -0
- package/dist/nodefony/entity/auditEventEntity.js +93 -0
- package/dist/nodefony/entity/colKit.js +260 -0
- package/dist/nodefony/entity/idempotencyEntity.js +74 -0
- package/dist/nodefony/entity/sessionEntity.js +75 -0
- package/dist/nodefony/entity/tokenEntity.js +198 -0
- package/dist/nodefony/entity/totpSecretEntity.js +98 -0
- package/dist/nodefony/entity/userTable.js +141 -0
- package/dist/nodefony/entity/webAuthnCredentialEntity.js +114 -0
- package/dist/nodefony/entity/webhookEndpointEntity.js +106 -0
- package/dist/nodefony/interfaces/IDrizzleConfig.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/migrations-schema/mysql.js +48 -0
- package/dist/nodefony/migrations-schema/postgres.js +48 -0
- package/dist/nodefony/migrations-schema/sqlite.js +48 -0
- package/dist/nodefony/registerStores.js +218 -0
- package/dist/nodefony/service/DrizzleService.js +282 -0
- package/dist/nodefony/src/DrizzleAuditStore.js +203 -0
- package/dist/nodefony/src/DrizzleIdempotencyStore.js +278 -0
- package/dist/nodefony/src/DrizzleTokenStore.js +244 -0
- package/dist/nodefony/src/DrizzleTotpSecretStore.js +151 -0
- package/dist/nodefony/src/DrizzleUserRepository.js +217 -0
- package/dist/nodefony/src/DrizzleWebAuthnCredentialStore.js +159 -0
- package/dist/nodefony/src/DrizzleWebhookStore.js +169 -0
- package/dist/nodefony/src/SessionStorage.js +259 -0
- package/dist/nodefony/src/connectorTarget.js +59 -0
- package/dist/nodefony/src/likeSql.js +50 -0
- package/dist/nodefony/src/migrator/DrizzleMigrator.js +775 -0
- package/dist/nodefony/src/migrator/adopt.js +553 -0
- package/dist/nodefony/src/migrator/appSchema.js +414 -0
- package/dist/nodefony/src/migrator/catalog.js +76 -0
- package/dist/nodefony/src/migrator/destructive.js +213 -0
- package/dist/nodefony/src/migrator/divergence.js +84 -0
- package/dist/nodefony/src/migrator/drivers/index.js +39 -0
- package/dist/nodefony/src/migrator/drivers/mysqlDriver.js +147 -0
- package/dist/nodefony/src/migrator/drivers/postgresDriver.js +151 -0
- package/dist/nodefony/src/migrator/drivers/sqliteDriver.js +121 -0
- package/dist/nodefony/src/migrator/explain.js +565 -0
- package/dist/nodefony/src/migrator/hash.js +47 -0
- package/dist/nodefony/src/migrator/history.js +219 -0
- package/dist/nodefony/src/migrator/index.js +16 -0
- package/dist/nodefony/src/migrator/kit.js +296 -0
- package/dist/nodefony/src/migrator/name.js +68 -0
- package/dist/nodefony/src/migrator/paths.js +88 -0
- package/dist/nodefony/src/migrator/refusals.js +143 -0
- package/dist/nodefony/src/migrator/resolve.js +281 -0
- package/dist/nodefony/src/migrator/schemaDiff.js +86 -0
- package/dist/nodefony/src/migrator/sources.js +419 -0
- package/dist/nodefony/src/migrator/status.js +231 -0
- package/dist/nodefony/src/migrator/types.js +91 -0
- package/dist/nodefony/src/orm-core/DrizzleOrm.js +1154 -0
- package/dist/nodefony/src/orm-core/DrizzleRepository.js +610 -0
- package/dist/nodefony/src/orm-core/DrizzleTransaction.js +106 -0
- package/dist/nodefony/src/orm-core/index.js +4 -0
- package/dist/nodefony/src/queryKit.js +318 -0
- package/dist/nodefony/src/safeTarget.js +55 -0
- package/dist/types/index.d.ts +76 -0
- package/dist/types/nodefony/command/migrateShared.d.ts +137 -0
- package/dist/types/nodefony/command/orm-generate.d.ts +53 -0
- package/dist/types/nodefony/command/orm-migrate-baseline.d.ts +87 -0
- package/dist/types/nodefony/command/orm-migrate-repair.d.ts +66 -0
- package/dist/types/nodefony/command/orm-migrate-status.d.ts +38 -0
- package/dist/types/nodefony/command/orm-migrate.d.ts +65 -0
- package/dist/types/nodefony/command/orm-reset.d.ts +55 -0
- package/dist/types/nodefony/config/config.d.ts +110 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +24 -0
- package/dist/types/nodefony/entity/auditEventEntity.d.ts +56 -0
- package/dist/types/nodefony/entity/colKit.d.ts +130 -0
- package/dist/types/nodefony/entity/idempotencyEntity.d.ts +52 -0
- package/dist/types/nodefony/entity/sessionEntity.d.ts +50 -0
- package/dist/types/nodefony/entity/tokenEntity.d.ts +53 -0
- package/dist/types/nodefony/entity/totpSecretEntity.d.ts +61 -0
- package/dist/types/nodefony/entity/userTable.d.ts +65 -0
- package/dist/types/nodefony/entity/webAuthnCredentialEntity.d.ts +57 -0
- package/dist/types/nodefony/entity/webhookEndpointEntity.d.ts +58 -0
- package/dist/types/nodefony/interfaces/IDrizzleConfig.d.ts +17 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/migrations-schema/mysql.d.ts +9 -0
- package/dist/types/nodefony/migrations-schema/postgres.d.ts +9 -0
- package/dist/types/nodefony/migrations-schema/sqlite.d.ts +9 -0
- package/dist/types/nodefony/registerStores.d.ts +52 -0
- package/dist/types/nodefony/service/DrizzleService.d.ts +27 -0
- package/dist/types/nodefony/src/DrizzleAuditStore.d.ts +65 -0
- package/dist/types/nodefony/src/DrizzleIdempotencyStore.d.ts +129 -0
- package/dist/types/nodefony/src/DrizzleTokenStore.d.ts +146 -0
- package/dist/types/nodefony/src/DrizzleTotpSecretStore.d.ts +60 -0
- package/dist/types/nodefony/src/DrizzleUserRepository.d.ts +67 -0
- package/dist/types/nodefony/src/DrizzleWebAuthnCredentialStore.d.ts +62 -0
- package/dist/types/nodefony/src/DrizzleWebhookStore.d.ts +79 -0
- package/dist/types/nodefony/src/SessionStorage.d.ts +72 -0
- package/dist/types/nodefony/src/connectorTarget.d.ts +48 -0
- package/dist/types/nodefony/src/likeSql.d.ts +29 -0
- package/dist/types/nodefony/src/migrator/DrizzleMigrator.d.ts +94 -0
- package/dist/types/nodefony/src/migrator/adopt.d.ts +280 -0
- package/dist/types/nodefony/src/migrator/appSchema.d.ts +223 -0
- package/dist/types/nodefony/src/migrator/catalog.d.ts +100 -0
- package/dist/types/nodefony/src/migrator/destructive.d.ts +123 -0
- package/dist/types/nodefony/src/migrator/divergence.d.ts +61 -0
- package/dist/types/nodefony/src/migrator/drivers/index.d.ts +26 -0
- package/dist/types/nodefony/src/migrator/drivers/mysqlDriver.d.ts +83 -0
- package/dist/types/nodefony/src/migrator/drivers/postgresDriver.d.ts +81 -0
- package/dist/types/nodefony/src/migrator/drivers/sqliteDriver.d.ts +53 -0
- package/dist/types/nodefony/src/migrator/explain.d.ts +424 -0
- package/dist/types/nodefony/src/migrator/hash.d.ts +39 -0
- package/dist/types/nodefony/src/migrator/history.d.ts +139 -0
- package/dist/types/nodefony/src/migrator/index.d.ts +20 -0
- package/dist/types/nodefony/src/migrator/kit.d.ts +141 -0
- package/dist/types/nodefony/src/migrator/name.d.ts +52 -0
- package/dist/types/nodefony/src/migrator/paths.d.ts +54 -0
- package/dist/types/nodefony/src/migrator/refusals.d.ts +170 -0
- package/dist/types/nodefony/src/migrator/resolve.d.ts +201 -0
- package/dist/types/nodefony/src/migrator/schemaDiff.d.ts +112 -0
- package/dist/types/nodefony/src/migrator/sources.d.ts +119 -0
- package/dist/types/nodefony/src/migrator/status.d.ts +132 -0
- package/dist/types/nodefony/src/migrator/types.d.ts +273 -0
- package/dist/types/nodefony/src/orm-core/DrizzleOrm.d.ts +241 -0
- package/dist/types/nodefony/src/orm-core/DrizzleRepository.d.ts +98 -0
- package/dist/types/nodefony/src/orm-core/DrizzleTransaction.d.ts +71 -0
- package/dist/types/nodefony/src/orm-core/index.d.ts +11 -0
- package/dist/types/nodefony/src/queryKit.d.ts +136 -0
- package/dist/types/nodefony/src/safeTarget.d.ts +42 -0
- package/docs/index.md +954 -0
- package/docs/migrations.md +691 -0
- package/migrations/mysql/0000_framework_init.sql +137 -0
- package/migrations/mysql/meta/_journal.json +13 -0
- package/migrations/postgres/0000_framework_init.sql +128 -0
- package/migrations/postgres/meta/_journal.json +13 -0
- package/migrations/sqlite/0000_framework_init.sql +127 -0
- package/migrations/sqlite/meta/_journal.json +13 -0
- package/package.json +126 -0
package/docs/index.md
ADDED
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "@nodefony/drizzle — l'ORM SQL par défaut"
|
|
3
|
+
navTitle: "@nodefony/drizzle"
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/drizzle"
|
|
6
|
+
topic: drizzle
|
|
7
|
+
section: "Persistance"
|
|
8
|
+
audience: [developer]
|
|
9
|
+
tags:
|
|
10
|
+
[
|
|
11
|
+
orm,
|
|
12
|
+
drizzle,
|
|
13
|
+
sql,
|
|
14
|
+
sqlite,
|
|
15
|
+
postgresql,
|
|
16
|
+
mysql,
|
|
17
|
+
mariadb,
|
|
18
|
+
repository,
|
|
19
|
+
entite,
|
|
20
|
+
transaction,
|
|
21
|
+
store,
|
|
22
|
+
migration,
|
|
23
|
+
]
|
|
24
|
+
version: "doc"
|
|
25
|
+
status: stable
|
|
26
|
+
updated: 2026-07-19
|
|
27
|
+
source: "src/packages/@nodefony/drizzle/docs/index.md"
|
|
28
|
+
coverageModule: drizzle
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
# @nodefony/drizzle — l'ORM SQL par défaut
|
|
32
|
+
|
|
33
|
+
> Le module que rencontre **toute application Nodefony** : il ouvre les connexions SQL, sert les
|
|
34
|
+
> repositories de tes entités, et fournit au framework les **huit briques durables** (session,
|
|
35
|
+
> utilisateurs, jetons, passkeys, 2FA, audit, webhooks, idempotence) sans que tu écrives une ligne de
|
|
36
|
+
> câblage. Un seul code applicatif, **trois dialectes** — SQLite en développement, PostgreSQL ou
|
|
37
|
+
> MySQL/MariaDB en production. C'est le **driver SQL** des contrats de
|
|
38
|
+
> [`@nodefony/orm-core`](../../orm-core/docs/index.md).
|
|
39
|
+
|
|
40
|
+
📍 [Documentation](../../../../../docs/index.md) › **Drizzle — ORM SQL**
|
|
41
|
+
|
|
42
|
+
## 🧠 Le modèle mental — trois étages, une seule vérité
|
|
43
|
+
|
|
44
|
+
Drizzle occupe l'étage du **driver**. Au-dessus, `@nodefony/orm-core` définit les contrats portables
|
|
45
|
+
(`IOrm`, `IRepository`, `ITransaction`) que ton code métier consomme ; en dessous, un driver natif par
|
|
46
|
+
dialecte. Changer de base = changer une URL, pas ton code.
|
|
47
|
+
|
|
48
|
+
```mermaid
|
|
49
|
+
flowchart TD
|
|
50
|
+
APP["Ton code<br/>controllers · services"] --> CORE["@nodefony/orm-core<br/>IRepository · Criteria · ITransaction"]
|
|
51
|
+
CORE --> DRZ["@nodefony/drizzle<br/>DrizzleOrm · DrizzleRepository"]
|
|
52
|
+
DRZ -->|dialect: sqlite| SQLITE["better-sqlite3<br/>fichier local"]
|
|
53
|
+
DRZ -->|dialect: postgres| PG["pg<br/>pool réseau"]
|
|
54
|
+
DRZ -->|dialect: mysql| MY["mysql2<br/>MySQL / MariaDB"]
|
|
55
|
+
DRZ -.->|"stores auto-enregistrés"| FW["http · security · framework<br/>session, users, jetons, audit…"]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Deux flux à distinguer, et c'est toute la lecture de cette page :
|
|
59
|
+
|
|
60
|
+
1. **Le flux applicatif** (trait plein) — tes entités, tes repositories, tes transactions.
|
|
61
|
+
2. **Le flux framework** (pointillés) — les briques durables que le module **déclare tout seul** au
|
|
62
|
+
démarrage, décrites dans [les huit stores](#les-huit-stores-du-framework--la-persistance-clé-en-main).
|
|
63
|
+
|
|
64
|
+
## 📖 Lexique
|
|
65
|
+
|
|
66
|
+
| Terme | Sens |
|
|
67
|
+
| --------------- | -------------------------------------------------------------------------------------------------- |
|
|
68
|
+
| ORM | _Object-Relational Mapping_ : traduit des lignes de table en objets, et l'inverse. |
|
|
69
|
+
| Dialecte | La variante de SQL d'un moteur (`sqlite`, `postgres`, `mysql`) — types et syntaxe diffèrent. |
|
|
70
|
+
| Connecteur | Une connexion nommée (`default`, `analytics`…). Clé de lookup dans le registre des ORM. |
|
|
71
|
+
| Repository | L'objet qui lit/écrit une entité (`find`, `create`, `updateOne`…), sans SQL visible. |
|
|
72
|
+
| Entité | Le nom logique d'une table + son schéma, déclaré à l'application (`defineEntity`). |
|
|
73
|
+
| Schema-as-code | Le schéma **est** du code TypeScript typé, pas un fichier de description séparé. |
|
|
74
|
+
| DDL | _Data Definition Language_ : le SQL qui crée/modifie les tables (`CREATE TABLE`, `ALTER`). |
|
|
75
|
+
| Store | **Où vivent des données** (session, jetons, audit…). À ne pas confondre avec `driver` = transport. |
|
|
76
|
+
| Dialecte porté | Un dialecte sur lequel une entité framework sait se construire — sinon le store est indisponible. |
|
|
77
|
+
| Criteria | Le filtre portable d'orm-core (`{ age: { $gte: 18 } }`), traduit en SQL par le driver. |
|
|
78
|
+
| Trappe SQL brut | L'accès direct au moteur pour ce que l'abstraction ne couvre pas (CTE, fenêtres, `JOIN` libres). |
|
|
79
|
+
| E2E | _End-to-end_ : un test qui parle à une **vraie** base, pas à un double. |
|
|
80
|
+
| Idempotence | Rejouer une mutation sans la ré-exécuter (double-clic, reconnexion) — pas de double effet. |
|
|
81
|
+
| PAT | _Personal Access Token_ : une clé d'API opaque, révocable côté serveur. |
|
|
82
|
+
| GC | _Garbage collection_ : la purge périodique des lignes expirées (pas de TTL natif en SQL). |
|
|
83
|
+
|
|
84
|
+
## Qu'est-ce que c'est, et pourquoi c'est le défaut
|
|
85
|
+
|
|
86
|
+
Une application a besoin d'une base **dès la première minute** : une session à retenir, un compte à
|
|
87
|
+
créer. Le réflexe habituel — « on branchera une vraie base plus tard » — coûte cher : le code s'écrit
|
|
88
|
+
contre un stockage en mémoire, puis tout est à reprendre quand la persistance arrive.
|
|
89
|
+
|
|
90
|
+
Nodefony prend le problème dans l'autre sens. **Charger `@nodefony/drizzle` suffit** : une base SQLite
|
|
91
|
+
locale apparaît sous `var/databases/`, et toutes les briques durables du framework s'y installent
|
|
92
|
+
d'elles-mêmes. Le jour où tu déclares `NF_DATABASE_URL=postgres://…`, **rien ne change dans ton code**
|
|
93
|
+
— le même module ouvre un pool PostgreSQL et recrée les mêmes tables dans les types du dialecte.
|
|
94
|
+
|
|
95
|
+
Le nom du paquet dit le moteur choisi : [Drizzle ORM](https://orm.drizzle.team). Trois raisons l'ont
|
|
96
|
+
fait retenir comme référence SQL du framework :
|
|
97
|
+
|
|
98
|
+
- **Type-safe-first** — un schéma est du TypeScript ; une colonne renommée casse à la compilation, pas
|
|
99
|
+
en production ;
|
|
100
|
+
- **léger** — pas de couche de métadonnées à l'exécution, le SQL émis reste lisible ;
|
|
101
|
+
- **jamais bloquant** — la trappe `sql` du moteur reste accessible pour tout ce que l'abstraction ne
|
|
102
|
+
couvre pas.
|
|
103
|
+
|
|
104
|
+
> [!NOTE]
|
|
105
|
+
> **Le défaut n'est pas une obligation.** `@nodefony/mongoose` sert le même contrat pour MongoDB, et
|
|
106
|
+
> ton code métier ne fait la différence nulle part : c'est précisément la valeur d'orm-core. Voir
|
|
107
|
+
> [le guide de persistance](../../../../../docs/guides/persistence.md) pour arbitrer.
|
|
108
|
+
|
|
109
|
+
### La vision Nodefony — ce que le module fait différemment
|
|
110
|
+
|
|
111
|
+
Un adapter ORM classique s'arrête à « je traduis un repository en SQL ». Celui-ci va deux pas plus
|
|
112
|
+
loin, et ces deux pas expliquent la taille de cette page.
|
|
113
|
+
|
|
114
|
+
**1. Il porte le framework, pas seulement ton métier.** Les huit briques durables (session, users,
|
|
115
|
+
jetons, passkeys, TOTP, audit, webhooks, idempotence) ont leurs tables **dans le module**, déclarées
|
|
116
|
+
au démarrage par `registerDrizzleFrameworkStores()` (`registerStores.ts:149`). Aucune application
|
|
117
|
+
n'écrit de `registerXStore(...)`.
|
|
118
|
+
|
|
119
|
+
**2. Il reconstruit la portabilité que Drizzle n'offre pas.** Drizzle est schema-as-code
|
|
120
|
+
**dialect-spécifique** : `sqliteTable` n'est pas `pgTable`, les types de colonnes diffèrent. On ne peut
|
|
121
|
+
donc pas « juste changer le dialecte ». Le module rétablit cette abstraction au niveau du framework —
|
|
122
|
+
une spécification logique par entité, traduite dans le bon dialecte par le `colKit`
|
|
123
|
+
(`buildFrameworkTable()`, `colKit.ts:543`). Le coût est assumé (une fabrique par entité framework),
|
|
124
|
+
la contrepartie est la type-safety intégrale.
|
|
125
|
+
|
|
126
|
+
> [!IMPORTANT]
|
|
127
|
+
> Le `colKit` est **interne** : il n'est pas exporté. Tes entités d'application écrivent du **Drizzle
|
|
128
|
+
> natif** du dialecte que tu vises — c'est ce que produit `nodefony create entity`. Exposer le kit
|
|
129
|
+
> serait promettre une API à maintenir pour un besoin que le scaffold couvre déjà.
|
|
130
|
+
|
|
131
|
+
## 🧭 Par où commencer
|
|
132
|
+
|
|
133
|
+
Le module n'a qu'une page — celle-ci — mais plusieurs façons de la lire. Choisis ton entrée ; chaque
|
|
134
|
+
parcours suit un ordre qui a une raison.
|
|
135
|
+
|
|
136
|
+
**Je démarre une application** — le chemin le plus court vers des données qui survivent au redémarrage.
|
|
137
|
+
|
|
138
|
+
1. [Démarrage rapide](#-démarrage-rapide) — la config, une entité, une requête. Copie-colle, ça marche.
|
|
139
|
+
2. [Configuration](#-configuration) — ce que tu peux régler, et ce qui se résout tout seul.
|
|
140
|
+
3. [Les huit stores](#les-huit-stores-du-framework--la-persistance-clé-en-main) — pourquoi tu n'as
|
|
141
|
+
rien câblé et que la session persiste quand même.
|
|
142
|
+
4. [Le tutoriel d'entité d'orm-core](../../orm-core/docs/tutorial-entity.md) — pour aller au-delà d'une table.
|
|
143
|
+
|
|
144
|
+
**Je passe en production** — l'ordre compte : le dialecte d'abord, le schéma ensuite.
|
|
145
|
+
|
|
146
|
+
1. [Dialectes](#dialectes--une-base-par-déploiement-un-seul-code) — ce qui change vraiment entre
|
|
147
|
+
SQLite, PostgreSQL et MySQL.
|
|
148
|
+
2. [Migrations et création des tables](#migrations--deux-chemins-un-seul-schéma) —
|
|
149
|
+
**à lire avant le premier déploiement**, le DDL dérivé ne fait pas d'`ALTER`.
|
|
150
|
+
3. [Pièges](#-pièges) — les symptômes qu'on rencontre en changeant de base.
|
|
151
|
+
4. [Tests](#-tests--couverture) — comment prouver ton dialecte, au lieu de le supposer.
|
|
152
|
+
|
|
153
|
+
**J'écris des requêtes** — de la plus portable à la plus spécifique.
|
|
154
|
+
|
|
155
|
+
1. [Repository](#-api-publique--du-repository-au-sql-brut) — le CRUD portable et les opérateurs riches.
|
|
156
|
+
2. [Transactions](#transactions--une-connexion-dédiée-jamais-le-pool) — et pourquoi elles s'écrivent
|
|
157
|
+
ainsi et pas autrement.
|
|
158
|
+
3. [Trappe SQL brut](#trappe-sql-brut--quand-labstraction-ne-suffit-plus) — CTE, fenêtres, jointures libres.
|
|
159
|
+
4. [Les contrats d'orm-core](../../orm-core/docs/index.md) — la référence de `Criteria` et des opérateurs.
|
|
160
|
+
|
|
161
|
+
**Je supervise ce qui tourne.**
|
|
162
|
+
|
|
163
|
+
1. [Observabilité Studio](#-observabilité--studio) — les écrans, le graphe d'entités, le data plane.
|
|
164
|
+
2. [Performance & mémoire](#-performance--mémoire) — le plafond connu de SQLite, et pourquoi.
|
|
165
|
+
|
|
166
|
+
## 🗂️ Ce que le module apporte
|
|
167
|
+
|
|
168
|
+
Le tableau pour situer en cinq secondes ; les cards en dessous pour savoir où lire.
|
|
169
|
+
|
|
170
|
+
| Brique | Ce qu'elle résout | Tu en as besoin quand… |
|
|
171
|
+
| ------------------------------------------------------------------------- | ------------------------------------------------- | -------------------------------------------- |
|
|
172
|
+
| [Connecteurs](#-configuration) | ouvrir une ou plusieurs bases, par dialecte | toujours — c'est le point d'entrée |
|
|
173
|
+
| [Dialectes](#dialectes--une-base-par-déploiement-un-seul-code) | SQLite, PostgreSQL, MySQL/MariaDB au même contrat | tu quittes le poste de développement |
|
|
174
|
+
| [Entités](#-démarrage-rapide) | déclarer une table et son nom logique | tu as des données à toi |
|
|
175
|
+
| [Repository](#-api-publique--du-repository-au-sql-brut) | CRUD portable, opérateurs riches, pagination | à chaque requête |
|
|
176
|
+
| [Transactions](#transactions--une-connexion-dédiée-jamais-le-pool) | tout-ou-rien sur plusieurs écritures | une opération ne doit jamais rester à moitié |
|
|
177
|
+
| [Trappe SQL](#trappe-sql-brut--quand-labstraction-ne-suffit-plus) | CTE, fenêtres, jointures arbitraires | l'abstraction ne couvre pas ton besoin |
|
|
178
|
+
| [Les 8 stores](#les-huit-stores-du-framework--la-persistance-clé-en-main) | session, users, jetons, audit… durables | jamais : c'est déjà branché |
|
|
179
|
+
| [Migrations](#migrations--deux-chemins-un-seul-schéma) | créer et faire évoluer les tables | avant le premier déploiement |
|
|
180
|
+
| [Studio](#-observabilité--studio) | voir les connexions, les entités, le graphe | tu veux comprendre ce qui tourne |
|
|
181
|
+
|
|
182
|
+
```nodefony-cards
|
|
183
|
+
[
|
|
184
|
+
{ "icon": "⚙️", "title": "connecteurs", "href": "#-configuration",
|
|
185
|
+
"desc": "Un connecteur = une base. `default` est celui que tout le framework utilise ; tu peux en déclarer d'autres — `analytics`, une fixture — sur leur propre fichier ou serveur. Le dialecte et la cible se déduisent de l'infra déclarée (`NF_DATABASE_URL`), sinon de ta config.",
|
|
186
|
+
"meta": "commence ici — tout le reste suppose un connecteur ouvert" },
|
|
187
|
+
{ "icon": "🧱", "title": "entités", "href": "#-démarrage-rapide",
|
|
188
|
+
"desc": "Une table Drizzle ordinaire plus un descripteur `defineEntity` qui lui donne un nom logique : aucune couche à contourner, tous les types du moteur restent accessibles. `nodefony create entity` écrit le reste — ligne typée, schémas de validation, service CRUD, controller REST/WebSocket et tests.",
|
|
189
|
+
"meta": "tu as des données à toi" },
|
|
190
|
+
{ "icon": "🧰", "title": "repository", "href": "#-api-publique--du-repository-au-sql-brut",
|
|
191
|
+
"desc": "`find`, `create`, `updateOne`, `upsert`, `increment`, `count`, la pagination — et des filtres portables (`views: { $gte: 10 }`) traduits en SQL par le driver. Le même code tourne sur les trois dialectes, prouvé par un banc de parité qui rejoue la même suite sur chacun.",
|
|
192
|
+
"meta": "à chaque requête" },
|
|
193
|
+
{ "icon": "🔒", "title": "transactions", "href": "#transactions--une-connexion-dédiée-jamais-le-pool",
|
|
194
|
+
"desc": "Ce qui réussit ensemble est commité ensemble, une exception annule tout. La section explique pourquoi une transaction emprunte une connexion dédiée au pool, et pourquoi seul `repo.withTransaction(tx)` y entre réellement — l'erreur classique coûte l'atomicité sans prévenir.",
|
|
195
|
+
"meta": "une opération ne doit jamais rester à moitié" },
|
|
196
|
+
{ "icon": "📦", "title": "stores", "href": "#les-huit-stores-du-framework--la-persistance-clé-en-main",
|
|
197
|
+
"desc": "Session, utilisateurs, jetons, passkeys, TOTP, audit, webhooks, idempotence : leurs tables vivent dans le module, leurs fabriques s'inscrivent seules dans les registres de `http`, `security` et `framework`. Rien à écrire.",
|
|
198
|
+
"meta": "à lire pour savoir où sont tes données, ou pour couper la déclaration" },
|
|
199
|
+
{ "icon": "🗃️", "title": "dialectes", "href": "#dialectes--une-base-par-déploiement-un-seul-code",
|
|
200
|
+
"desc": "Ce qui reste identique (les noms de colonnes, le contrat du repository) et ce qui diverge, avec la raison : types epoch/date/JSON, absence de `RETURNING` en MySQL, `OFFSET` sans `LIMIT`.",
|
|
201
|
+
"meta": "à lire avant de changer de base, pas après le premier incident" },
|
|
202
|
+
{ "icon": "🚧", "title": "migrations", "href": "#migrations--deux-chemins-un-seul-schéma",
|
|
203
|
+
"desc": "En développement, les tables sont créées au démarrage par un DDL dérivé de tes schémas. Ce DDL ne fait aucun `ALTER`, n'émet ni `DEFAULT` SQL ni index : la section dit ce que ça implique en production, et ce que le framework ne fournit pas encore.",
|
|
204
|
+
"meta": "le point à ne pas rater avant le premier déploiement" }
|
|
205
|
+
]
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## 🚀 Démarrage rapide
|
|
209
|
+
|
|
210
|
+
Vu depuis une application créée par `nodefony create app`. Trois fichiers, et des données qui
|
|
211
|
+
survivent au redémarrage.
|
|
212
|
+
|
|
213
|
+
### 1. Déclarer le module
|
|
214
|
+
|
|
215
|
+
Le manifeste `modules` de `nodefony.config.ts` est **ordonné** : Drizzle vient avant les modules qui
|
|
216
|
+
consomment ses stores, parce qu'il déclare leur schéma au moment où le kernel enregistre les modules.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
// nodefony.config.ts — l'orchestrateur de l'application
|
|
220
|
+
export default defineConfig(() => ({
|
|
221
|
+
modules: [
|
|
222
|
+
// Drizzle EN PREMIER : il déclare le schéma des briques durables (session,
|
|
223
|
+
// users, jetons…) AVANT que http/security/framework ne résolvent leurs stores.
|
|
224
|
+
use("@nodefony/drizzle", {
|
|
225
|
+
connectors: {
|
|
226
|
+
// `filename` est facultatif : omis, il est résolu au boot vers
|
|
227
|
+
// <app>/var/databases/nodefony-drizzle.db — un seul dossier à sauvegarder.
|
|
228
|
+
default: { dialect: "sqlite", filename: "var/databases/app.db" },
|
|
229
|
+
},
|
|
230
|
+
}),
|
|
231
|
+
"@nodefony/http",
|
|
232
|
+
"@nodefony/framework",
|
|
233
|
+
],
|
|
234
|
+
}));
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### 2. Déclarer une entité
|
|
238
|
+
|
|
239
|
+
La table est du **Drizzle natif** — pas une couche Nodefony. `defineEntity` ne fait que lui attacher un
|
|
240
|
+
nom logique ; c'est le décorateur `@entities([...])` posé sur ton module qui l'inscrit au démarrage,
|
|
241
|
+
avant toute connexion.
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
// nodefony/entity/Post.ts + index.ts du module, réunis ici pour l'exemple
|
|
245
|
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
246
|
+
import { defineEntity, entities } from "@nodefony/orm-core";
|
|
247
|
+
import { Kernel, Module } from "nodefony";
|
|
248
|
+
|
|
249
|
+
export const postTable = sqliteTable("Post", {
|
|
250
|
+
id: text("id")
|
|
251
|
+
.primaryKey()
|
|
252
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
253
|
+
title: text("title").notNull(),
|
|
254
|
+
// ⚠️ TOUJOURS `$defaultFn` (défaut posé côté JS), JAMAIS `.default()` (SQL) :
|
|
255
|
+
// le DDL dérivé n'émet pas de DEFAULT — une colonne NOT NULL casserait l'INSERT.
|
|
256
|
+
views: integer("views")
|
|
257
|
+
.notNull()
|
|
258
|
+
.$defaultFn(() => 0),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
/** Une ligne de `Post`, telle que la rend le repository. */
|
|
262
|
+
export interface PostRow {
|
|
263
|
+
id: string;
|
|
264
|
+
title: string;
|
|
265
|
+
views: number;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export const PostEntity = defineEntity({
|
|
269
|
+
name: "Post", // le nom logique passé à getRepository()
|
|
270
|
+
module: "blog",
|
|
271
|
+
schema: postTable,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Le connecteur n'est PAS figé dans l'entité : c'est une donnée de configuration,
|
|
275
|
+
// résolue au démarrage (défaut `default`).
|
|
276
|
+
@entities([PostEntity])
|
|
277
|
+
class Blog extends Module {
|
|
278
|
+
constructor(kernel: Kernel) {
|
|
279
|
+
super("blog", kernel, import.meta.url, {});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export default Blog;
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### 3. Lire et écrire
|
|
287
|
+
|
|
288
|
+
Le repository se récupère par le **nom logique** de l'entité, sur le connecteur voulu. Les filtres sont
|
|
289
|
+
portables : le même code tournera tel quel sur PostgreSQL.
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
// nodefony/service/PostService.ts (extrait)
|
|
293
|
+
import { ormRegistry } from "@nodefony/orm-core";
|
|
294
|
+
|
|
295
|
+
// Dans l'application : `import type { PostRow } from "../entity/Post";`
|
|
296
|
+
interface PostRow {
|
|
297
|
+
id: string;
|
|
298
|
+
title: string;
|
|
299
|
+
views: number;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function popularPosts(): Promise<PostRow[]> {
|
|
303
|
+
const posts = ormRegistry.get("default").getRepository<PostRow>("Post");
|
|
304
|
+
|
|
305
|
+
await posts.create({ title: "Bonjour" }); // id et views posés par $defaultFn
|
|
306
|
+
await posts.increment({ title: "Bonjour" }, { views: 1 });
|
|
307
|
+
|
|
308
|
+
return posts.find(
|
|
309
|
+
{ views: { $gte: 10 } }, // opérateur riche portable
|
|
310
|
+
{ order: [["views", "DESC"]], limit: 5 },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Ce qu'on observe
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
# Au démarrage : le connecteur s'ouvre et annonce sa cible
|
|
319
|
+
# INFO Drizzle ORM "default" connected (sqlite: var/databases/app.db)
|
|
320
|
+
|
|
321
|
+
# La base est là, la table aussi (créée au connect depuis ton schéma)
|
|
322
|
+
sqlite3 var/databases/app.db '.tables'
|
|
323
|
+
# Post audit_event idempotency_key session User access_token …
|
|
324
|
+
|
|
325
|
+
# Et le data plane d'administration voit le modèle
|
|
326
|
+
curl -s http://localhost:5151/nodefony/orm/api/entities | head -20
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
> [!TIP]
|
|
330
|
+
> Tu n'as déclaré **aucun** store. Les tables `session`, `User`, `access_token`… apparaissent quand
|
|
331
|
+
> même : c'est l'[auto-enregistrement](#les-huit-stores-du-framework--la-persistance-clé-en-main).
|
|
332
|
+
|
|
333
|
+
## ⚙️ Configuration
|
|
334
|
+
|
|
335
|
+
### La convention à deux fichiers — drizzle est la référence du dépôt
|
|
336
|
+
|
|
337
|
+
Tout module Nodefony qui expose une configuration suit **exactement** cette structure, et c'est celle
|
|
338
|
+
de drizzle qui sert de modèle. Deux fichiers, mêmes noms partout, aucune question à se poser :
|
|
339
|
+
|
|
340
|
+
| Fichier | Rôle | Contenu |
|
|
341
|
+
| --------------------------------------- | -------------- | ----------------------------------------------------------------------------- |
|
|
342
|
+
| `nodefony/config/config.ts` | **le QUOI** | schéma Zod commenté = source **unique** des défauts, matérialisés `parse({})` |
|
|
343
|
+
| `nodefony/config/defineModuleConfig.ts` | **le COMMENT** | builder **pur** : parse → surcharge d'environnement → gel |
|
|
344
|
+
|
|
345
|
+
Concrètement : `drizzleConfigSchema` (`config.ts:79`) porte chaque `.default()` et chaque
|
|
346
|
+
`.describe()` — changer un défaut du module, c'est éditer **là et nulle part ailleurs**. Le builder
|
|
347
|
+
`defineDrizzleConfig()` (`defineModuleConfig.ts:58`) ne retape jamais une valeur : il valide, applique
|
|
348
|
+
l'environnement, gèle. Et `drizzleConfigJsonSchema()` (`defineModuleConfig.ts:69`) expose le tout en
|
|
349
|
+
JSON Schema pour l'écran de configuration de Studio.
|
|
350
|
+
|
|
351
|
+
Le schéma reste **pur** : il ne lit ni `process.env` ni le kernel. C'est ce qui rend le module
|
|
352
|
+
importable et testable sans serveur.
|
|
353
|
+
|
|
354
|
+
### Les options
|
|
355
|
+
|
|
356
|
+
| Option | Type | Défaut | Effet |
|
|
357
|
+
| ------------------------- | ----------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
|
358
|
+
| `connectors` | `Record<string, Connector>` | `{ default: {…} }` | Les connexions, indexées par nom (= clé du registre des ORM). |
|
|
359
|
+
| `connectors.<n>.dialect` | `"sqlite" \| "postgres" \| "mysql"` | `"sqlite"` | Choisit le driver (`better-sqlite3` / `pg` / `mysql2`). |
|
|
360
|
+
| `connectors.<n>.filename` | `string` | _résolu au boot_ | Fichier SQLite. `:memory:` = base éphémère. Ignoré hors `sqlite`. |
|
|
361
|
+
| `connectors.<n>.url` | `string` | — | Chaîne de connexion `postgres://…` / `mysql://…`. **Porte un secret.** |
|
|
362
|
+
| `frameworkEntities` | `boolean` | `true` | Déclare (ou non) le schéma des huit briques durables sur `default`. |
|
|
363
|
+
|
|
364
|
+
Table dérivée de `drizzleConfigSchema` (`config.ts:79`) et de `SQL_DIALECTS` (`config.ts:37`).
|
|
365
|
+
|
|
366
|
+
**`filename` est volontairement sans défaut.** Le chemin dépend du kernel, qui n'existe pas quand le
|
|
367
|
+
schéma est évalué. Il est résolu **au démarrage** par `DrizzleService.#defaultFilename()`
|
|
368
|
+
(`DrizzleService.ts:95`) vers `<app>/var/databases/nodefony-<connecteur>.db` — sous `var/`, le dossier
|
|
369
|
+
commun des données runtime : « où sont mes données ? » a une réponse unique, un seul chemin à
|
|
370
|
+
sauvegarder et à ignorer dans git.
|
|
371
|
+
|
|
372
|
+
### Trois façons de désigner sa base
|
|
373
|
+
|
|
374
|
+
L'ordre de précédence est croissant : le défaut, puis ta config, puis l'environnement.
|
|
375
|
+
|
|
376
|
+
**Situation 1 — je développe.** Rien à écrire. Charger le module suffit : dialecte `sqlite`, fichier
|
|
377
|
+
sous `var/databases/`, tables créées au démarrage.
|
|
378
|
+
|
|
379
|
+
**Situation 2 — je déploie, l'infra est déclarée par l'orchestrateur.** C'est le chemin normal en
|
|
380
|
+
conteneur : une seule variable, et le module en déduit **tout**.
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
NF_DATABASE_URL=postgres://app:secret@db:5432/prod # dialecte + cible déduits
|
|
384
|
+
NF_DATABASE_URL=mysql://app:secret@db:3306/prod
|
|
385
|
+
NF_DATABASE_URL=sqlite:/var/lib/app/prod.db
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
`applyEnvOverrides()` (`defineModuleConfig.ts:20`) lit l'infra, en déduit le dialecte depuis le
|
|
389
|
+
_scheme_, et pose `filename` ou `url` sur le connecteur primaire. Une URL `mongodb://` est **ignorée
|
|
390
|
+
ici** : elle appartient alors à `@nodefony/mongoose`.
|
|
391
|
+
|
|
392
|
+
**Situation 3 — plusieurs bases.** Un connecteur par base. Seul `default` porte le schéma du framework ;
|
|
393
|
+
les autres sont à toi.
|
|
394
|
+
|
|
395
|
+
```ts ignore
|
|
396
|
+
use("@nodefony/drizzle", {
|
|
397
|
+
connectors: {
|
|
398
|
+
default: { dialect: "postgres", url: process.env.NF_DATABASE_URL },
|
|
399
|
+
analytics: { dialect: "sqlite", filename: "var/databases/analytics.db" },
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
> [!WARNING]
|
|
405
|
+
> Une URL de connexion **porte un mot de passe**. Le module ne la journalise jamais telle quelle :
|
|
406
|
+
> `redactUrl()` (`DrizzleService.ts:68`) remplace le mot de passe par `***` avant tout log de
|
|
407
|
+
> démarrage, et la sonde d'administration applique la même règle.
|
|
408
|
+
|
|
409
|
+
### Quand la connexion échoue, le démarrage échoue
|
|
410
|
+
|
|
411
|
+
Un connecteur **déclaré** qui ne se connecte pas lève une `BootConfigurationError`
|
|
412
|
+
(`DrizzleService.ts:244`) — en développement **comme** en production. Ce n'est pas une sévérité
|
|
413
|
+
gratuite : une infrastructure déclarée mais injoignable ne se répare pas en continuant. Un serveur qui
|
|
414
|
+
démarrerait « vivant » avec ses stores morts accepterait des requêtes pour échouer plus tard, la cause
|
|
415
|
+
noyée dans un avertissement. Le message d'erreur nomme le connecteur, le dialecte, la cible rédigée et
|
|
416
|
+
la piste à vérifier.
|
|
417
|
+
|
|
418
|
+
Pour les dialectes réseau, la connexion fait un **ping réel** au démarrage : les pools `pg` et `mysql2`
|
|
419
|
+
sont paresseux, sans ce `SELECT 1` une base morte « se connecterait » et n'échouerait qu'à la première
|
|
420
|
+
requête métier (`#connectPostgres()`, `DrizzleOrm.ts:597` · `#connectMysql()`, `DrizzleOrm.ts:1200`).
|
|
421
|
+
|
|
422
|
+
## Dialectes — une base par déploiement, un seul code
|
|
423
|
+
|
|
424
|
+
Trois dialectes au même contrat. Le tableau dit ce qu'il faut installer et ce qu'on obtient.
|
|
425
|
+
|
|
426
|
+
| Dialecte | Driver | Statut du paquet | Cible | Configuration |
|
|
427
|
+
| ---------- | ---------------- | -------------------------- | ------------------------------------------ | ------------- |
|
|
428
|
+
| `sqlite` | `better-sqlite3` | **dépendance** (fournie) | développement, tests, production mono-nœud | `filename` |
|
|
429
|
+
| `postgres` | `pg` | dépendance **optionnelle** | production multi-pod (recommandé) | `url` |
|
|
430
|
+
| `mysql` | `mysql2` | dépendance **optionnelle** | production — **MySQL 8.4 et MariaDB 11.4** | `url` |
|
|
431
|
+
|
|
432
|
+
Les drivers réseau sont chargés **paresseusement** au moment de la connexion : une application SQLite
|
|
433
|
+
ne paie ni l'installation ni le chargement de `pg`/`mysql2`.
|
|
434
|
+
|
|
435
|
+
### Ce qui ne change pas
|
|
436
|
+
|
|
437
|
+
- **Les noms de colonnes**, identiques sur les trois dialectes — c'est ce qui rend les stores et les
|
|
438
|
+
repositories agnostiques ;
|
|
439
|
+
- **le contrat `IRepository`** en entier, prouvé par un banc de parité qui rejoue la **même** suite sur
|
|
440
|
+
les trois moteurs — un écart de comportement y est un bug du framework, par construction ;
|
|
441
|
+
- **ton code applicatif**. C'est tout l'objet de l'exercice.
|
|
442
|
+
|
|
443
|
+
### Ce qui diverge, et pourquoi
|
|
444
|
+
|
|
445
|
+
Ces divergences sont **encapsulées** dans le module. Elles sont listées ici pour que tu saches quoi
|
|
446
|
+
regarder si un comportement te surprend.
|
|
447
|
+
|
|
448
|
+
<!-- prettier-ignore -->
|
|
449
|
+
| Sujet | SQLite | PostgreSQL | MySQL / MariaDB | Raison |
|
|
450
|
+
| --- | --- | --- | --- | --- |
|
|
451
|
+
| Horodatage epoch (ms) | `integer` | `bigint` | `bigint` | `integer` est 32 bits en PG/MySQL : un epoch ms déborde. |
|
|
452
|
+
| Date JS | `integer` (timestamp ms) | `timestamptz(3)` | `datetime(3)` | `timestamp` MySQL est borné à 2038 et dépend de la timezone. |
|
|
453
|
+
| JSON | `text` (mode json) | `jsonb` | type JSON compatible MariaDB | MariaDB rend une chaîne (LONGTEXT), MySQL un objet. |
|
|
454
|
+
| Booléen | `integer` | `boolean` | `boolean` (alias `tinyint(1)`) | SQLite n'a pas de type booléen. |
|
|
455
|
+
| Texte indexé / PK | `text` | `text` | `varchar(512)` | InnoDB n'indexe pas `TEXT` sans préfixe. |
|
|
456
|
+
| Retour d'une ligne écrite | `RETURNING` | `RETURNING` | **absent** → relecture par PK | MySQL n'a pas de `RETURNING`. |
|
|
457
|
+
| `OFFSET` sans `LIMIT` | fragment `-1` | rien (valide seul) | `LIMIT` sentinelle | Chaque moteur refuse une forme différente. |
|
|
458
|
+
|
|
459
|
+
Toute cette traduction vit à **un seul endroit**, le `colKit` (`buildFrameworkTable()`,
|
|
460
|
+
`colKit.ts:543` ; variante MySQL : `mysqlColumn()`, `colKit.ts:444`). Ajouter un dialecte, c'est
|
|
461
|
+
étendre le kit — jamais retoucher les entités.
|
|
462
|
+
|
|
463
|
+
En MySQL, les verbes « qui rendent la ligne écrite » (`create`, `updateOne`, `upsert`,
|
|
464
|
+
`findOneAndDelete`) se décomposent en sélection de la cible → mutation bornée par la clé primaire **avec
|
|
465
|
+
le critère revérifié dans le `WHERE`** → relecture. Deux à trois allers-retours au lieu d'un : c'est le
|
|
466
|
+
prix du dialecte, payé **uniquement** en MySQL. Une course perdue rend `null`, jamais une mutation hors
|
|
467
|
+
critère (`#mysqlInsertReturning()`, `DrizzleRepository.ts:1138`).
|
|
468
|
+
|
|
469
|
+
Le SQL brut nécessaire aux entités du framework est lui aussi routé par dialecte, dans un seul fichier
|
|
470
|
+
(`queryKit.ts`) : recherche dans une colonne JSON (`findUserIdBySocialProvider()`, `queryKit.ts:76`),
|
|
471
|
+
réservation atomique d'idempotence en MySQL (`reserveIdempotencyKeyMysql()`, `queryKit.ts:152`),
|
|
472
|
+
pagination des utilisateurs (`listUserIdsPage()`, `queryKit.ts:352`). Toutes ces requêtes sont
|
|
473
|
+
**paramétrées** — jamais de concaténation.
|
|
474
|
+
|
|
475
|
+
## 🏗️ Architecture interne
|
|
476
|
+
|
|
477
|
+
### Le trajet du démarrage
|
|
478
|
+
|
|
479
|
+
```mermaid
|
|
480
|
+
sequenceDiagram
|
|
481
|
+
participant K as Kernel
|
|
482
|
+
participant M as Module Drizzle
|
|
483
|
+
participant S as DrizzleService
|
|
484
|
+
participant O as DrizzleOrm
|
|
485
|
+
K->>M: onKernelRegister
|
|
486
|
+
M->>M: defineDrizzleConfig() — valide, applique l'env, gèle
|
|
487
|
+
M->>M: registerDrizzleFrameworkStores(dialecte)
|
|
488
|
+
Note over M: entités des 8 briques + fabriques<br/>dans les registres http/security/framework
|
|
489
|
+
K->>S: onBoot
|
|
490
|
+
S->>O: new DrizzleOrm(nom, {dialect, filename, url})
|
|
491
|
+
O->>O: connexion + CREATE TABLE IF NOT EXISTS (dérivé)
|
|
492
|
+
Note over O: échec ⇒ BootConfigurationError (boot fatal)
|
|
493
|
+
K->>M: onKernelBoot
|
|
494
|
+
M->>M: montage du data plane /nodefony/orm/api/*
|
|
495
|
+
K->>S: onTerminate
|
|
496
|
+
S->>O: disconnect (toutes les connexions)
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
L'ordre est ce qui rend l'auto-enregistrement possible : **les entités sont déclarées avant la
|
|
500
|
+
connexion**, donc leurs tables sont créées au moment où l'ORM s'ouvre.
|
|
501
|
+
|
|
502
|
+
### Les pièces
|
|
503
|
+
|
|
504
|
+
| Pièce | Rôle | Ancre |
|
|
505
|
+
| ---------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
506
|
+
| `Drizzle` (le module) | valide la config, déclare le schéma framework, monte le data plane | `index.ts` du module |
|
|
507
|
+
| `DrizzleService` | ouvre un ORM par connecteur au boot, ferme tout à l'arrêt | `connectAll()`, `DrizzleService.ts:148` |
|
|
508
|
+
| `DrizzleOrm` | la connexion : DDL dérivé, repositories, transactions, sonde | `DrizzleOrm.ts:214` |
|
|
509
|
+
| `DrizzleRepository<T>` | le CRUD portable, les opérateurs riches, l'eager-load | `DrizzleRepository.ts:146` |
|
|
510
|
+
| `DrizzleTransaction` | `BEGIN`/`COMMIT`/`ROLLBACK` pilotés à la main, sur les trois dialectes | `DrizzleTransaction.ts:70` |
|
|
511
|
+
| `buildFrameworkTable` | une spécification logique → la table du dialecte demandé | `colKit.ts:543` |
|
|
512
|
+
| `queryKit` (interne) | le SQL brut des entités framework, émis **et exécuté** par dialecte | `findUserIdBySocialProvider()` (`queryKit.ts:76`) |
|
|
513
|
+
| `registerStores` | l'auto-enregistrement des huit briques | `registerDrizzleFrameworkStores()` (`registerStores.ts:149`) |
|
|
514
|
+
|
|
515
|
+
### Le DDL dérivé — comment les tables apparaissent
|
|
516
|
+
|
|
517
|
+
Drizzle ne « synchronise » pas un schéma. L'adapter dérive lui-même un `CREATE TABLE IF NOT EXISTS`
|
|
518
|
+
depuis chaque table déclarée (`#buildCreateTable()`, `DrizzleOrm.ts:400`) et l'exécute à la connexion.
|
|
519
|
+
Trois conséquences à connaître **avant** de dépendre de ce mécanisme :
|
|
520
|
+
|
|
521
|
+
1. il **crée**, il ne **modifie** pas — aucun `ALTER` n'est émis ;
|
|
522
|
+
2. il n'émet **ni `DEFAULT` SQL ni index** — d'où la règle des défauts en `$defaultFn` ;
|
|
523
|
+
3. il ne connaît que les colonnes, les clés primaires, `NOT NULL` et `UNIQUE`.
|
|
524
|
+
|
|
525
|
+
C'est un confort de développement, pas un outil de migration. La suite est dans
|
|
526
|
+
[Migrations](#migrations--deux-chemins-un-seul-schéma).
|
|
527
|
+
|
|
528
|
+
## 🧰 API publique — du repository au SQL brut
|
|
529
|
+
|
|
530
|
+
Les signatures exactes vivent dans le graphe généré (`jq '.symbols.DrizzleRepository' .ai/symbols.json`)
|
|
531
|
+
— jamais recopiées ici, elles divergeraient. Ce qui suit montre **l'usage**.
|
|
532
|
+
|
|
533
|
+
### Le repository
|
|
534
|
+
|
|
535
|
+
```ts ignore
|
|
536
|
+
const posts = ormRegistry.get("default").getRepository<PostRow>("Post");
|
|
537
|
+
|
|
538
|
+
// Lecture — égalité, opérateurs riches, tri, pagination, eager-load
|
|
539
|
+
await posts.find({ title: "Bonjour" });
|
|
540
|
+
await posts.find({ views: { $gte: 10, $lt: 1000 } }); // plusieurs opérateurs = AND
|
|
541
|
+
await posts.find({ id: { $in: ids } });
|
|
542
|
+
await posts.find({ title: { $like: "Bon%" } }); // sémantique SQL (`%`, `_`)
|
|
543
|
+
await posts.find({ title: { $like: escapeLikeTerm("100%") } }); // `%` littéral
|
|
544
|
+
await posts.find({}, { order: [["views", "DESC"]], limit: 20, offset: 40 });
|
|
545
|
+
await posts.find({}, { relations: ["comments"] }); // associations déclarées
|
|
546
|
+
|
|
547
|
+
// Écriture
|
|
548
|
+
await posts.create({ title: "Neuf" });
|
|
549
|
+
await posts.createMany([{ title: "a" }, { title: "b" }]);
|
|
550
|
+
await posts.updateOne({ id }, { title: "Corrigé" }); // rend la ligne, ou null
|
|
551
|
+
await posts.upsert({ id }, { title: "Neuf" });
|
|
552
|
+
await posts.increment({ id }, { views: 1 }); // compteur atomique
|
|
553
|
+
await posts.deleteOne({ id });
|
|
554
|
+
await posts.count({ views: { $gte: 10 } });
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Les opérateurs (`$eq $ne $gt $gte $lt $lte $in $nin $like`) sont **ceux d'orm-core**, identiques sur
|
|
558
|
+
tous les drivers ; la traduction en `eq()`/`inArray()` se fait dans `#where()`
|
|
559
|
+
(`DrizzleRepository.ts:420`). Leur référence complète est dans
|
|
560
|
+
[la page d'orm-core](../../orm-core/docs/index.md).
|
|
561
|
+
|
|
562
|
+
`$like` est émis avec sa clause `ESCAPE '\'` (`likeSql.ts`), ce qui rend un `%` ou un `_` **littéral**
|
|
563
|
+
exprimable : passez le fragment par `escapeLikeTerm` (orm-core) plutôt que de composer le motif à la
|
|
564
|
+
main. Sans cette clause — c'était le cas — un antislash valait échappement en PostgreSQL et MySQL, et
|
|
565
|
+
lui-même en SQLite : le même critère ne rendait pas les mêmes lignes selon la base.
|
|
566
|
+
|
|
567
|
+
Deux points de comportement qui évitent des surprises :
|
|
568
|
+
|
|
569
|
+
- **« au plus une ligne »** est garanti par construction pour `updateOne`/`deleteOne`/`increment` :
|
|
570
|
+
la mutation est bornée par la clé primaire découverte de la table, jamais par un `LIMIT` sur un
|
|
571
|
+
`UPDATE` (`#pickOne()`, `DrizzleRepository.ts:259`). C'est ce qui rend ces verbes portables — MySQL
|
|
572
|
+
interdit la forme naïve.
|
|
573
|
+
- **l'eager-load est manuel** : une requête `IN (…)` par relation déclarée, puis regroupement en
|
|
574
|
+
mémoire (`#populate()`, `DrizzleRepository.ts:683`). Choix assumé — pas de couche de relations à
|
|
575
|
+
déclarer une seconde fois, et le comportement est le même sur les trois dialectes.
|
|
576
|
+
|
|
577
|
+
### Transactions — une connexion dédiée, jamais le pool
|
|
578
|
+
|
|
579
|
+
```ts ignore
|
|
580
|
+
await orm.transaction(async (tx) => {
|
|
581
|
+
// withTransaction(tx) est le SEUL moyen d'entrer dans la transaction
|
|
582
|
+
const author = await users.withTransaction(tx).create({ email: "x@y.z" });
|
|
583
|
+
await posts.withTransaction(tx).create({ title: "…", authorId: author.id });
|
|
584
|
+
// une exception ⇒ ROLLBACK de tout ; sinon COMMIT automatique
|
|
585
|
+
});
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Le module pilote `BEGIN`/`COMMIT`/`ROLLBACK` **à la main** plutôt que d'utiliser l'aide du moteur, pour
|
|
589
|
+
une raison précise : `better-sqlite3` est **synchrone**, son helper commite au `return` — donc _avant_
|
|
590
|
+
les `await` d'un contrat asynchrone. Le pilotage manuel rétablit la sémantique attendue
|
|
591
|
+
(`DrizzleTransaction`, `DrizzleTransaction.ts:70`).
|
|
592
|
+
|
|
593
|
+
> [!WARNING]
|
|
594
|
+
> **`getRepository()` écrit HORS transaction.** Un repository obtenu normalement passe par le pool ;
|
|
595
|
+
> seul `repo.withTransaction(tx)` emprunte la connexion de la transaction. C'est l'erreur la plus
|
|
596
|
+
> coûteuse du domaine : sur PostgreSQL/MySQL, le `BEGIN` et les écritures partiraient sur des
|
|
597
|
+
> connexions différentes — **aucune atomicité**, et un `BEGIN` orphelin recyclé dans le pool.
|
|
598
|
+
|
|
599
|
+
Le mécanisme diffère par dialecte, sans que ton code le voie : en PostgreSQL/MySQL la transaction
|
|
600
|
+
emprunte une connexion **dédiée** au pool, rendue au commit — et **détruite** si celui-ci échoue,
|
|
601
|
+
jamais recyclée dans un état inconnu. En SQLite la connexion est unique, donc c'est un pool de taille 1 :
|
|
602
|
+
les transactions concurrentes sont **sérialisées par une file d'attente** (`#sqliteTxGate`,
|
|
603
|
+
`DrizzleOrm.ts:267`), sinon deux requêtes HTTP simultanées émettraient deux `BEGIN` sur la même
|
|
604
|
+
connexion et la seconde échouerait.
|
|
605
|
+
|
|
606
|
+
Les points de sauvegarde sont disponibles (`savepoint()`, `DrizzleTransaction.ts:123`) ; le nom est
|
|
607
|
+
validé et cité selon le dialecte — en MySQL/MariaDB, `"x"` est une **chaîne**, pas un identifiant.
|
|
608
|
+
|
|
609
|
+
### Trappe SQL brut — quand l'abstraction ne suffit plus
|
|
610
|
+
|
|
611
|
+
Toute requête que le repository ne couvre pas s'écrit directement, avec le moteur :
|
|
612
|
+
|
|
613
|
+
```ts ignore
|
|
614
|
+
import { sql } from "drizzle-orm";
|
|
615
|
+
|
|
616
|
+
const db = orm.getNativeConnection<DrizzleDb>();
|
|
617
|
+
const rows = await db.all(sql`
|
|
618
|
+
WITH ranked AS (
|
|
619
|
+
SELECT id, title, views,
|
|
620
|
+
ROW_NUMBER() OVER (PARTITION BY authorId ORDER BY views DESC) AS rk
|
|
621
|
+
FROM Post
|
|
622
|
+
)
|
|
623
|
+
SELECT * FROM ranked WHERE rk = 1
|
|
624
|
+
`);
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
C'est l'**anti-blocage** du modèle Repository (`getNativeConnection()`, `DrizzleOrm.ts:1405`) : CTE,
|
|
628
|
+
fonctions de fenêtre, sous-requêtes corrélées, jointures arbitraires. Deux contreparties assumées :
|
|
629
|
+
ce SQL n'est plus portable entre dialectes, et il **ne passe pas** par la sonde de profilage des
|
|
630
|
+
requêtes.
|
|
631
|
+
|
|
632
|
+
## Les huit stores du framework — la persistance clé en main
|
|
633
|
+
|
|
634
|
+
C'est la partie qui distingue ce module d'un simple adapter : **charger `@nodefony/drizzle` rend huit
|
|
635
|
+
briques durables disponibles**, sans aucun câblage.
|
|
636
|
+
|
|
637
|
+
<!-- prettier-ignore -->
|
|
638
|
+
| Brique | Table(s) | Contrat servi | Ce que ça rend durable |
|
|
639
|
+
| --- | --- | --- | --- |
|
|
640
|
+
| Session | `session` | `ISessionStorage` (http) | les sessions survivent au redémarrage |
|
|
641
|
+
| Utilisateurs | `User` | `IUserRepository` (user) | l'annuaire des comptes |
|
|
642
|
+
| Jetons | `access_token`, `denied_jti`, `subject_revocation` | `ITokenStore` (security) | PAT, denylist JWT, révocation par sujet |
|
|
643
|
+
| Passkeys | `webauthn_credential` | `IWebAuthnCredentialStore` | les clés WebAuthn enrôlées |
|
|
644
|
+
| 2FA (TOTP) | `totp_secret` | `ITotpSecretStore` | les secrets 2FA (chiffrés en amont) |
|
|
645
|
+
| Audit | `audit_event` | `IAuditStore` | le journal de sécurité, append-only |
|
|
646
|
+
| Webhooks | `webhook_endpoint` | `IWebhookStore` | le registre des destinataires |
|
|
647
|
+
| Idempotence | `idempotency_key` | `IIdempotencyStore` (core) | la dédup des mutations, **partagée** |
|
|
648
|
+
|
|
649
|
+
Les huit sont portées sur les **trois** dialectes. Chaque table est déclarée par une spécification
|
|
650
|
+
logique traduite par le colKit — par exemple la session (`SESSION_TABLE_SPEC`, `sessionEntity.ts:25`)
|
|
651
|
+
ou l'idempotence (`createIdempotencyTable`, `idempotencyEntity.ts:85`).
|
|
652
|
+
|
|
653
|
+
### Comment ça s'active — la réponse est : tout seul
|
|
654
|
+
|
|
655
|
+
Chaque brique choisit son backend par une option `store`, dont le **défaut est `"auto"`**. La
|
|
656
|
+
résolution automatique (`resolveAutoStore()`, `infra.ts:241`) applique cette préférence :
|
|
657
|
+
|
|
658
|
+
1. une infra `database` déclarée (`NF_DATABASE_URL`) → **`drizzle`** (ou `mongoose` si l'URL est Mongo) ;
|
|
659
|
+
2. sinon, un backend local persistant réellement chargé → **`drizzle`**, c'est-à-dire SQLite ;
|
|
660
|
+
3. sinon seulement, repli en mémoire (volatil), **annoncé** dans les journaux.
|
|
661
|
+
|
|
662
|
+
Autrement dit : le simple fait de charger ce module fait basculer session, jetons, audit, passkeys,
|
|
663
|
+
2FA, webhooks et idempotence sur une base **persistante**. Le journal de démarrage dit toujours quel
|
|
664
|
+
backend a été retenu **et pourquoi** — `audit.store "auto" → "drizzle" (aucune infra déclarée — backend
|
|
665
|
+
local persistant "drizzle" (mono-nœud))`.
|
|
666
|
+
|
|
667
|
+
Tu peux évidemment forcer :
|
|
668
|
+
|
|
669
|
+
```ts ignore
|
|
670
|
+
use("@nodefony/http", { session: { store: "drizzle" } });
|
|
671
|
+
use("@nodefony/security", {
|
|
672
|
+
audit: { store: "drizzle" },
|
|
673
|
+
tokenStore: { store: "drizzle" },
|
|
674
|
+
});
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
> [!IMPORTANT]
|
|
678
|
+
> **Les TSDoc de deux fichiers du module décrivent une « approche B » où l'application câblerait
|
|
679
|
+
> elle-même la fabrique et l'entité d'idempotence** (`DrizzleIdempotencyStore.ts:97` ·
|
|
680
|
+
> `idempotencyEntity.ts:32`). Ce n'est plus le comportement : le module inscrit lui-même la fabrique
|
|
681
|
+
> via `registerIdempotencyStore()` (`registerStores.ts:316`). **Le code exécuté fait autorité** —
|
|
682
|
+
> ces commentaires sont périmés.
|
|
683
|
+
|
|
684
|
+
### Le mécanisme, et comment garder la main
|
|
685
|
+
|
|
686
|
+
`registerDrizzleFrameworkStores()` (`registerStores.ts:149`) est appelé à l'enregistrement du module,
|
|
687
|
+
avec le dialecte du connecteur `default`. Pour chaque brique, il déclare l'entité puis inscrit la
|
|
688
|
+
fabrique du store dans le registre de son propriétaire (`http`, `security` ou `framework`). Deux
|
|
689
|
+
garde-fous préservent ta liberté :
|
|
690
|
+
|
|
691
|
+
- **entité déjà enregistrée par l'application** → elle est respectée, jamais écrasée ;
|
|
692
|
+
- **fabrique déjà posée** → elle garde la main (premier arrivé, premier servi).
|
|
693
|
+
|
|
694
|
+
Et deux garde-fous protègent de l'incohérence :
|
|
695
|
+
|
|
696
|
+
- une brique **non portée** sur le dialecte configuré n'est ni déclarée ni fabricable — la
|
|
697
|
+
sélectionner échoue franchement au démarrage plutôt que de produire une table fantôme ;
|
|
698
|
+
- la fabrique **capture le dialecte** de son enregistrement : elle refuse un ORM d'un autre dialecte
|
|
699
|
+
(`resolveConnectedOrm()`, `registerStores.ts:112`).
|
|
700
|
+
|
|
701
|
+
Pour tout couper — module « données seulement », aucune entité ni fabrique framework :
|
|
702
|
+
|
|
703
|
+
```ts ignore
|
|
704
|
+
use("@nodefony/drizzle", { frameworkEntities: false });
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
Le bilan de l'opération (déclarées / laissées à l'application / non portées) est **journalisé**, jamais
|
|
708
|
+
silencieux.
|
|
709
|
+
|
|
710
|
+
### Deux comportements à connaître
|
|
711
|
+
|
|
712
|
+
**La résolution du handle est paresseuse.** Les stores ne capturent pas la connexion à leur
|
|
713
|
+
construction : ils la résolvent à chaque appel. C'est nécessaire parce que l'ordre n'est pas garanti —
|
|
714
|
+
le framework résout ses stores avant que l'ORM ne soit connecté — et parce que l'ORM se **déconnecte à
|
|
715
|
+
l'arrêt** avant que les serveurs HTTP n'aient fini de vider leurs requêtes en vol. Handle absent =
|
|
716
|
+
dégradation annoncée, pas un plantage : le `SessionStorage` rend une session vide et ignore les
|
|
717
|
+
écritures (`#repo()`, `SessionStorage.ts:65`), le store d'idempotence laisse passer la mutation sans
|
|
718
|
+
dédup (`begin()`, `DrizzleIdempotencyStore.ts:200`).
|
|
719
|
+
|
|
720
|
+
**SQL n'a pas de TTL.** Contrairement à Redis, rien n'expire tout seul : chaque store expose un `gc()`
|
|
721
|
+
applicatif qui supprime les lignes échues, déclenché par un minuteur hors du chemin chaud —
|
|
722
|
+
sessions (`SessionStorage.ts:166`), jetons (`DrizzleTokenStore.ts:297`), idempotence
|
|
723
|
+
(`DrizzleIdempotencyStore.ts:318`), audit selon la rétention configurée (`DrizzleAuditStore.ts:231`).
|
|
724
|
+
|
|
725
|
+
### Deux détails de conception qui valent la lecture
|
|
726
|
+
|
|
727
|
+
**L'idempotence** est une **réservation atomique** : un `INSERT … ON CONFLICT(key) DO UPDATE … WHERE
|
|
728
|
+
expiré` dont le `RETURNING` ne rend une ligne que si l'insertion (clé neuve) ou le vol d'une entrée
|
|
729
|
+
morte a réellement eu lieu. C'est l'équivalent SQL du `SET NX PX` de Redis, en une instruction — et
|
|
730
|
+
c'est ce qui interdit de conclure « nouvelle mutation » hors d'une réservation gagnée. En MySQL, où ni
|
|
731
|
+
`RETURNING` ni `WHERE` ne sont disponibles sur un `ON DUPLICATE KEY UPDATE`, la même garantie s'obtient
|
|
732
|
+
en deux instructions au verdict non ambigu (`reserveIdempotencyKeyMysql()`, `queryKit.ts:152`).
|
|
733
|
+
|
|
734
|
+
**Le journal d'audit** pagine par un curseur **composite auto-portant** `<horodatage>:<id>` sur un ordre
|
|
735
|
+
total, plutôt que par un identifiant seul (`listPage()`, `DrizzleAuditStore.ts:154`). Deux gains : plus
|
|
736
|
+
d'aller-retour pour résoudre le curseur, et une pagination qui ne rembobine pas à la première page si
|
|
737
|
+
l'événement de référence a été purgé entre deux appels.
|
|
738
|
+
|
|
739
|
+
Le détail fonctionnel de ces briques vit chez leur propriétaire :
|
|
740
|
+
[audit](../../security/docs/audit.md) · [jetons](../../security/docs/tokens.md) ·
|
|
741
|
+
[passkeys](../../security/docs/webauthn.md) · [TOTP](../../security/docs/totp.md) ·
|
|
742
|
+
[webhooks](../../security/docs/webhooks.md) · [idempotence](../../framework/docs/idempotence.md) ·
|
|
743
|
+
[stockage de session](../../../../../docs/guides/session-storage.md).
|
|
744
|
+
|
|
745
|
+
## Migrations — deux chemins, un seul schéma
|
|
746
|
+
|
|
747
|
+
Deux mécanismes créent les tables, et il faut savoir lequel travaille pour toi.
|
|
748
|
+
|
|
749
|
+
**En développement et en test**, chaque table déclarée est créée à la connexion par un
|
|
750
|
+
`CREATE TABLE IF NOT EXISTS` dérivé de ton schéma — index compris. Tu n'as rien à lancer : la base
|
|
751
|
+
part de zéro et fonctionne.
|
|
752
|
+
|
|
753
|
+
**Sa limite tient en un mot : il n'ajoute jamais ce qu'il faudrait inventer.**
|
|
754
|
+
|
|
755
|
+
| Attente | Réalité |
|
|
756
|
+
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
757
|
+
| « j'ajoute un champ nullable, ça suit » | **Oui** — la colonne est posée au démarrage et journalisée. Voir [Migrations de schéma](migrations.md#en-développement--le-schéma-se-répare-tout-seul). |
|
|
758
|
+
| « j'ajoute un champ obligatoire » | **Non** — il faudrait inventer une valeur pour les lignes déjà là. L'écart est publié avec son geste : `nodefony orm:reset`, ou une migration. |
|
|
759
|
+
| « mes `.default()` SQL s'appliquent » | **Non** — le DDL dérivé ne les émet pas. Utilise `$defaultFn` (côté JS). |
|
|
760
|
+
| « mes index sont créés » | **Oui** — des deux côtés. Un banc de parité le vérifie table par table. |
|
|
761
|
+
|
|
762
|
+
> [!CAUTION]
|
|
763
|
+
> Ne déploie jamais sur le DDL dérivé. Il est conçu pour qu'une base **neuve** fonctionne, et pour
|
|
764
|
+
> qu'une base de développement suive un ajout de champ — pas pour faire évoluer une base de
|
|
765
|
+
> production. Il ne pose aucune trace dans l'historique : les tables qu'il crée n'ont, pour qui
|
|
766
|
+
> arrive après, aucune origine connue.
|
|
767
|
+
|
|
768
|
+
**En production**, le schéma vient de fichiers de migration versionnés. Ceux du framework sont
|
|
769
|
+
**livrés dans le paquet** : `migrations/{sqlite,postgres,mysql}/`, un fichier par changement, plus le
|
|
770
|
+
journal qui dit lesquels ont été appliqués. Ils sont produits par `drizzle-kit` à partir des mêmes
|
|
771
|
+
entités que le DDL dérivé, et un banc vérifie sur les trois dialectes qu'une base **migrée** est
|
|
772
|
+
identique à une base **dérivée** — colonne par colonne, index compris. Sans cette preuve, les deux
|
|
773
|
+
chemins divergeraient en silence.
|
|
774
|
+
|
|
775
|
+
Les appliquer, les adopter, les réparer et surveiller leur état est le travail de cinq commandes
|
|
776
|
+
`nodefony orm:migrate*` — avec leur verrou, leur historique et leur branchement sur la sonde de
|
|
777
|
+
disponibilité. Tout est dans **[Migrations de schéma](migrations.md)**, qui porte aussi le patron de
|
|
778
|
+
déploiement sans interruption et les droits du compte qui migre.
|
|
779
|
+
|
|
780
|
+
### Regénérer les migrations du framework
|
|
781
|
+
|
|
782
|
+
Concerne les contributeurs du framework, pas les applications.
|
|
783
|
+
|
|
784
|
+
```bash
|
|
785
|
+
# Les TROIS dialectes ensemble, sous le même nom — jamais un seul.
|
|
786
|
+
npm run generate:migrations -w @nodefony/drizzle -- --name ajout_du_champ_x
|
|
787
|
+
|
|
788
|
+
# Le schéma des entités a-t-il bougé sans regénération ?
|
|
789
|
+
npm run check:migrations -w @nodefony/drizzle
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
Les trois dialectes se génèrent **ensemble** parce qu'un identifiant de migration publié sur npm est
|
|
793
|
+
immuable à vie : trois journaux désalignés ne se renumérotent pas. Le contrôle de dérive tourne aussi
|
|
794
|
+
en intégration continue — une entité modifiée sans regénération y devient rouge.
|
|
795
|
+
|
|
796
|
+
### Les trois refus, et ce qu'ils protègent
|
|
797
|
+
|
|
798
|
+
Un générateur de migrations compare deux schémas. Il ne voit pas une **intention** — et c'est
|
|
799
|
+
précisément là que les données se perdent.
|
|
800
|
+
|
|
801
|
+
| Ce qui arrive | Ce que tu obtiens |
|
|
802
|
+
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
803
|
+
| Tu **renommes** une colonne | La génération s'arrête et demande un terminal interactif. Une colonne qui disparaît et une autre qui apparaît, c'est un renommage (les données suivent) ou une suppression puis un ajout (elles sont perdues) : seul toi peux trancher. |
|
|
804
|
+
| La migration **détruit** des données | Refus. `DROP TABLE`, `DROP COLUMN`, changement de type, `TRUNCATE` — les fichiers sont écrits pour que tu les relises, et c'est leur MISE EN SERVICE qui est retenue : `orm:migrate` la refuse hors développement sans `--allow-destructive`. Les annuler se fait avec l'outil de gestion de versions, pas en regénérant. |
|
|
805
|
+
| La migration **verrouille** en production | Avertissement, sans blocage — un `CREATE INDEX` PostgreSQL non concurrent bloque les écritures de la table, un `SET NOT NULL` la scanne entière sous verrou exclusif. La manœuvre sûre est indiquée. |
|
|
806
|
+
|
|
807
|
+
> [!WARNING]
|
|
808
|
+
> Après avoir répondu « renamed », **relis le fichier produit**. Quand une colonne est renommée _et_
|
|
809
|
+
> que son type change, l'outil n'écrit que le renommage et oublie le changement de type
|
|
810
|
+
> ([drizzle-orm#3826](https://github.com/drizzle-team/drizzle-orm/issues/3826)). Le contrôle de
|
|
811
|
+
> dérive le rattrape — c'est aussi pour cela qu'il existe.
|
|
812
|
+
|
|
813
|
+
Chaque fichier généré porte en tête `-- nodefony:migration format=1`. Ce n'est pas décoratif : un
|
|
814
|
+
applicateur ne doit pas deviner le format de ce qu'il exécute, et c'est la porte de sortie prévue
|
|
815
|
+
pour changer d'outil sans réécrire les bases existantes.
|
|
816
|
+
|
|
817
|
+
## 📡 Observabilité — Studio
|
|
818
|
+
|
|
819
|
+
Le module monte le **data plane d'administration ORM** au démarrage — un branchement global, idempotent,
|
|
820
|
+
que chaque driver déclenche à l'identique (orm-core étant une bibliothèque pure, il ne peut pas le
|
|
821
|
+
faire lui-même).
|
|
822
|
+
|
|
823
|
+
| Route | Contenu |
|
|
824
|
+
| --------------------------------------- | ---------------------------------------- |
|
|
825
|
+
| `GET /nodefony/orm/api/orms` | les ORM et connecteurs, avec leur santé |
|
|
826
|
+
| `GET /nodefony/orm/api/entities` | les entités (`?connector=` pour filtrer) |
|
|
827
|
+
| `GET /nodefony/orm/api/entity/{name}` | une entité et ses colonnes normalisées |
|
|
828
|
+
| `GET /nodefony/orm/api/graph` | le graphe complet du modèle de données |
|
|
829
|
+
| `GET /nodefony/orm/api/export/{format}` | export du schéma (`dbml`) |
|
|
830
|
+
|
|
831
|
+
Côté écrans : **Database**, **ORM (vue d'ensemble et par entité)** et **Stores** — ce dernier répond à
|
|
832
|
+
la question « où sont écrites mes données ? » pour chaque brique.
|
|
833
|
+
|
|
834
|
+
La sonde d'un connecteur s'adapte au dialecte (`probe()`, `DrizzleOrm.ts:1495`) :
|
|
835
|
+
|
|
836
|
+
- **SQLite** → `storage` : taille du fichier, mode de journal, pages libres (lus par `PRAGMA`) ;
|
|
837
|
+
- **PostgreSQL / MySQL** → `pool` : taille, connexions libres, empruntées, en attente — **compteurs en
|
|
838
|
+
mémoire, aucune requête émise**.
|
|
839
|
+
|
|
840
|
+
La taille d'une base **serveur** n'est délibérément **pas** sondée : la mesurer coûterait une requête à
|
|
841
|
+
chaque appel, pour une donnée que l'administration du SGBD expose déjà. Ne rien promettre vaut mieux
|
|
842
|
+
que promettre en silence — c'est le principe « superviser sans peser sur la production ».
|
|
843
|
+
|
|
844
|
+
Chaque store expose aussi son **emplacement physique** pour l'écran Stores : le chemin du fichier
|
|
845
|
+
SQLite, relativisé (anti-fuite d'information), et `undefined` pour un backend réseau — dont
|
|
846
|
+
l'emplacement **est** l'infra déclarée, déjà affichée ailleurs (`location`, `DrizzleOrm.ts:372`).
|
|
847
|
+
|
|
848
|
+
## ⚡ Performance & mémoire
|
|
849
|
+
|
|
850
|
+
**La sonde de requêtes ne coûte rien quand elle est éteinte.** Chaque exécution passe par un point de
|
|
851
|
+
mesure unique (`#prof()`, `DrizzleRepository.ts:330`) qui alimente deux consommateurs — le profileur
|
|
852
|
+
par requête (barre de debug) et l'agrégat de flux. Les deux sont gardés par un drapeau : si aucun n'est
|
|
853
|
+
actif, la fonction rend le constructeur de requête tel quel, sans allocation. Le flux est **désactivé
|
|
854
|
+
en production** par défaut.
|
|
855
|
+
|
|
856
|
+
La sérialisation du SQL n'est faite que sur le **chemin lent** (au-delà du seuil de requête lente),
|
|
857
|
+
jamais au cas nominal — et le SQL journalisé est la forme **paramétrée** (`?`), jamais les valeurs.
|
|
858
|
+
|
|
859
|
+
**Le plafond de SQLite est structurel, pas un défaut.** `better-sqlite3` est synchrone et
|
|
860
|
+
mono-connexion : les écritures sont sérialisées. Sur un banc de session en HTTP/2, cela donne un
|
|
861
|
+
plafond stable autour de **400 requêtes/s** avec 100 % de réponses correctes et zéro session perdue ;
|
|
862
|
+
augmenter la concurrence augmente la latence, pas le débit. PostgreSQL ou MySQL paralléliseraient. Si
|
|
863
|
+
tu vises plus haut, ce n'est pas le module qu'il faut changer, c'est le dialecte.
|
|
864
|
+
|
|
865
|
+
Les bancs de charge du module (`npm run test:load`) mesurent l'insertion, le balayage, les gros `$in`,
|
|
866
|
+
et vérifient l'absence de fuite mémoire sur des dizaines de milliers de cycles et des centaines de
|
|
867
|
+
connexions. Les chiffres vivent dans la sortie du banc, pas ici — ils dépendent de la machine.
|
|
868
|
+
|
|
869
|
+
## ⚠️ Pièges
|
|
870
|
+
|
|
871
|
+
| Symptôme | Cause | Correction |
|
|
872
|
+
| -------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
873
|
+
| Une écriture dans `transaction()` n'est pas annulée | repository obtenu par `getRepository()` → passe par le **pool**, hors tx | `repo.withTransaction(tx)` — le seul moyen d'entrer dans la transaction |
|
|
874
|
+
| `NOT NULL constraint failed` sur une colonne pourtant « par défaut » | `.default()` SQL : le DDL dérivé ne l'émet pas | poser le défaut côté JS : `$defaultFn(() => …)` |
|
|
875
|
+
| Colonne ajoutée, table inchangée | le DDL dérivé **ne fait aucun `ALTER`** | supprimer la base de dev, ou passer par `drizzle-kit` |
|
|
876
|
+
| Index déclaré, absent en base | les index ne sortent que via `drizzle-kit` | migration `drizzle-kit` en production |
|
|
877
|
+
| Le démarrage échoue sur le connecteur | infra déclarée injoignable → `BootConfigurationError` (voulu) | corriger l'URL / démarrer la base / retirer le connecteur |
|
|
878
|
+
| `cannot start a transaction within a transaction` (SQLite) | deux `BEGIN` concurrents sur la connexion unique | rien à faire : la file d'attente interne les sérialise |
|
|
879
|
+
| Une entité fonctionne en SQLite, échoue en PostgreSQL | table Drizzle **figée** sur un dialecte, posée sur le connecteur `default` | fixer l'entité sur son propre connecteur SQLite, ou la porter au dialecte |
|
|
880
|
+
| `store inconnu` au démarrage | brique non portée sur le dialecte, ou `frameworkEntities: false` | vérifier le dialecte configuré et le bilan journalisé de l'auto-register |
|
|
881
|
+
| La session repart vide à chaque redémarrage | store résolu en `memory` (aucun backend persistant chargé) | charger `@nodefony/drizzle` ; le journal dit toujours le store retenu |
|
|
882
|
+
| Débit bloqué autour de 400 req/s en écriture | SQLite est synchrone et mono-connexion — pas un bug | passer en `postgres`/`mysql` via `NF_DATABASE_URL` |
|
|
883
|
+
| Le SQL brut n'apparaît pas dans le profileur | les requêtes de la trappe native ne passent pas par le point de mesure | attendu ; utiliser le repository si la mesure compte |
|
|
884
|
+
|
|
885
|
+
## 🧪 Tests & couverture
|
|
886
|
+
|
|
887
|
+
Le module est couvert par plusieurs familles complémentaires — les compteurs exacts sont **régénérés**
|
|
888
|
+
depuis vitest, jamais figés dans ce texte :
|
|
889
|
+
|
|
890
|
+
- **intégration** — la config Zod, le banc orm-core, la jointure très complexe (CTE, fenêtres,
|
|
891
|
+
sous-requêtes corrélées via la trappe native), le stockage de session, l'entité `User`, et chacun
|
|
892
|
+
des huit stores ;
|
|
893
|
+
- **bancs de contrat partagés** — les invariants que **tous** les backends doivent tenir, importés de
|
|
894
|
+
leur module propriétaire (session et pagination depuis `http`, pagination d'audit depuis `security`)
|
|
895
|
+
et rejoués ici ;
|
|
896
|
+
- **banc de parité `IRepository`** — la **même** suite exécutée sur les trois dialectes ; c'est lui qui
|
|
897
|
+
a attrapé un `LIMIT` négatif silencieusement ignoré par le moteur ;
|
|
898
|
+
- **E2E sur base réelle** — une suite par brique pour PostgreSQL et pour MySQL/MariaDB, plus la preuve
|
|
899
|
+
d'atomicité **inter-pods** de l'idempotence (deux pools concurrents, un seul gagnant par tour) ;
|
|
900
|
+
- **charge et mémoire** — `npm run test:load` : débit, grands `$in`, absence de fuite.
|
|
901
|
+
|
|
902
|
+
### ⚠️ Un `npm test` vert ne prouve que SQLite
|
|
903
|
+
|
|
904
|
+
C'est le piège le plus important de cette page, et il a été vécu sur **ce module** : les suites E2E se
|
|
905
|
+
**skippent** silencieusement sans leur base, et un test skippé compte comme vert. Une exécution sans
|
|
906
|
+
variables laisse **des centaines de tests non exécutés — soit les deux dialectes de production** — et
|
|
907
|
+
annonce quand même un succès.
|
|
908
|
+
|
|
909
|
+
Le module rend donc ce silence audible : ses gates d'infrastructure sont déclarées dans
|
|
910
|
+
`vitest.gates.ts` à la racine (source **unique** du dépôt), et un rapporteur nomme en fin d'exécution
|
|
911
|
+
les cibles **non exercées**, avec la commande exacte pour les activer.
|
|
912
|
+
|
|
913
|
+
```bash
|
|
914
|
+
# Ne prouve QUE sqlite — lire le bloc de fin de run avant de conclure
|
|
915
|
+
npm test
|
|
916
|
+
|
|
917
|
+
# PostgreSQL réellement exercé
|
|
918
|
+
docker compose -f docker/docker-compose.yml --profile postgres up -d postgres
|
|
919
|
+
NF_PG_URL=postgres://… npm test
|
|
920
|
+
|
|
921
|
+
# MySQL / MariaDB réellement exercés
|
|
922
|
+
docker compose -f docker/docker-compose.yml --profile mariadb up -d mariadb
|
|
923
|
+
NF_MYSQL_URL=mysql://… npm test
|
|
924
|
+
|
|
925
|
+
npm run coverage # couverture (vitest)
|
|
926
|
+
npm run test:load # charge, limites, mémoire
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
> [!CAUTION]
|
|
930
|
+
> N'affirme jamais qu'un dialecte est « prouvé » sur la foi d'un compteur vert. **PostgreSQL exige
|
|
931
|
+
> `NF_PG_URL`, MySQL/MariaDB exigent `NF_MYSQL_URL`** — sans elles, ces dialectes ne sont pas testés,
|
|
932
|
+
> ils sont sautés. Les commandes exactes sont affichées par le rapporteur, jamais recopiées à la main.
|
|
933
|
+
|
|
934
|
+
## 🔗 Pour aller plus loin
|
|
935
|
+
|
|
936
|
+
- ⬆️ **Retour** : [Toute la documentation](../../../../../docs/index.md) ·
|
|
937
|
+
[Démarrer avec Nodefony](../../../../../docs/demarrer.md)
|
|
938
|
+
- 🧭 **L'abstraction au-dessus** : [`@nodefony/orm-core`](../../orm-core/docs/index.md) — contrats
|
|
939
|
+
`IOrm`/`IRepository`/`ITransaction`, `Criteria` et opérateurs riches, registres. À lire pour tout ce
|
|
940
|
+
qui est **portable** ; cette page-ci ne documente que le driver SQL.
|
|
941
|
+
- 🗄️ **Déployer un schéma** : [Migrations de schéma](migrations.md) — les cinq commandes, le
|
|
942
|
+
rattrapage de développement, le travail d'orchestrateur et l'expansion/contraction.
|
|
943
|
+
- 📗 **Tutoriel** : [créer une entité pas à pas](../../orm-core/docs/tutorial-entity.md)
|
|
944
|
+
- 🧩 **L'autre driver** : [`@nodefony/mongoose`](../../mongoose/docs/index.md) — même contrat, MongoDB.
|
|
945
|
+
- 🗄️ **Guides transverses** : [choisir sa persistance](../../../../../docs/guides/persistence.md) ·
|
|
946
|
+
[stockage de session](../../../../../docs/guides/session-storage.md) ·
|
|
947
|
+
[configuration d'une application](../../../../../docs/guides/configuration.md)
|
|
948
|
+
- 🔐 **Les briques servies** : [`@nodefony/security`](../../security/docs/index.md) (jetons, audit,
|
|
949
|
+
passkeys, TOTP, webhooks) · [`@nodefony/user`](../../user/docs/index.md) (l'annuaire) ·
|
|
950
|
+
[`@nodefony/http`](../../http/docs/index.md) (sessions) ·
|
|
951
|
+
[`@nodefony/framework`](../../framework/docs/index.md) (idempotence)
|
|
952
|
+
- 🏛️ **Décision d'architecture** :
|
|
953
|
+
[ADR-0003 — abstraction Repository multi-ORM](../../../../../docs/adr/0003-orm-core-abstraction-repository-multi-orm.md)
|
|
954
|
+
- 📖 [Lexique général](../../../../../docs/lexique.md) du framework.
|