@nodefony/drizzle 10.0.0-beta.1 → 10.0.0-beta.2

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/README.md CHANGED
@@ -4,14 +4,15 @@ Adapter [Drizzle ORM](https://orm.drizzle.team/) pour Nodefony, branché sur
4
4
  [`@nodefony/orm-core`](https://www.npmjs.com/package/@nodefony/orm-core). driver concret du socle multi-ORM (avec
5
5
  `@nodefony/mongoose`), **type-safe-first**.
6
6
 
7
- > Driver de référence : `better-sqlite3` (tests, embarqué). Pour Postgres/MySQL,
7
+ > Driver SQLite : `better-sqlite3` (dépendance de pair optionnelle, à installer avec le dialecte
8
+ > `sqlite`). Pour Postgres/MySQL,
8
9
  > changer le client et le constructeur de table (`pgTable`/`mysqlTable`) — le
9
10
  > contrat `IRepository` reste identique.
10
11
 
11
12
  ## Installation
12
13
 
13
14
  ```bash
14
- npm install @nodefony/drizzle@alpha drizzle-orm better-sqlite3
15
+ npm install @nodefony/drizzle@beta drizzle-orm better-sqlite3
15
16
  ```
16
17
 
17
18
  ## Utilisation comme module Nodefony (bootable)
@@ -43,8 +44,8 @@ l'override générique `NF__DRIZZLE__<CHEMIN>`. Sans `filename`, un connecteur S
43
44
  Au runtime, l'ORM est récupérable via le registre :
44
45
 
45
46
  ```typescript
46
- import { OrmRegistry } from "@nodefony/orm-core";
47
- const orm = OrmRegistry.get("default");
47
+ import { ormRegistry } from "@nodefony/orm-core";
48
+ const orm = ormRegistry.get("default");
48
49
  const users = orm.getRepository("User");
49
50
  ```
50
51
 
@@ -83,7 +84,7 @@ const users = orm.getRepository<{ id: string; email: string; age: number }>(
83
84
  const u = await users.create({ email: "a@b.c", age: 30 });
84
85
  await users.findOne({ id: u.id });
85
86
  await users.find(); // tous
86
- await users.update({ id: u.id }, { age: 31 });
87
+ await users.updateOne({ id: u.id }, { age: 31 });
87
88
  await users.delete({ id: u.id });
88
89
  await users.count();
89
90
  ```
@@ -101,7 +102,8 @@ await users.find({ age: { $ne: 30 } });
101
102
  await users.find({ email: { $like: "u2%" } }); // sémantique SQL (`%`, `_`)
102
103
  ```
103
104
 
104
- Opérateurs supportés : `$eq $ne $gt $gte $lt $lte $in $nin $like`. Une valeur nue
105
+ Opérateurs supportés : `$eq $ne $gt $gte $lt $lte $in $nin $like $null`, et `$or` pour une
106
+ disjonction. Une valeur nue
105
107
  (`{ email: "a@b.c" }`) = égalité.
106
108
 
107
109
  ## Relations & eager-load
@@ -153,7 +155,7 @@ const rows = db.all(sql`SELECT ... JOIN ...`);
153
155
  ## Notes
154
156
 
155
157
  - **Dev/test** : le schéma SQLite est créé automatiquement (DDL dérivé des tables).
156
- **Production** : utiliser `drizzle-kit` pour les migrations.
158
+ **Production** : `nodefony orm:generate` puis `nodefony orm:migrate` (voir `docs/migrations.md`).
157
159
  - `better-sqlite3` est **synchrone** : les transactions sont pilotées manuellement
158
160
  (`BEGIN`/`COMMIT`/`ROLLBACK`) pour rester compatibles avec le contrat async.
159
161
 
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorateMetadata.js
2
2
  function __decorateMetadata(k, v) {
3
3
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
4
  }
package/dist/index.js CHANGED
@@ -38,8 +38,8 @@ import OrmMigrateStatus from "./nodefony/command/orm-migrate-status.js";
38
38
  import OrmMigrateBaseline from "./nodefony/command/orm-migrate-baseline.js";
39
39
  import OrmMigrateRepair from "./nodefony/command/orm-migrate-repair.js";
40
40
  import OrmReset from "./nodefony/command/orm-reset.js";
41
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
42
- import __decorate from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
41
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
42
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
43
43
  import SessionStorage from "./nodefony/src/SessionStorage.js";
44
44
  import "./nodefony/src/migrator/index.js";
45
45
  import { DrizzleUserRepository } from "./nodefony/src/DrizzleUserRepository.js";
@@ -1,6 +1,7 @@
1
1
  import { HISTORY_TABLE } from "../types.js";
2
2
  import { schemaReader } from "../catalog.js";
3
3
  import "../../memoryDatabase.js";
4
+ import { enableWriteAheadLog } from "../../sqliteJournal.js";
4
5
  //#region nodefony/src/migrator/drivers/sqliteDriver.ts
5
6
  /**
6
7
  * Pilote SQLite de l'applicateur — connexion `better-sqlite3` dédiée.
@@ -53,9 +54,11 @@ var SqliteMigrationDriver = class {
53
54
  throw new Error(`Le dialecte sqlite exige le pilote optionnel \`better-sqlite3\` (\`npm i better-sqlite3\`). ${e.message}`, { cause: e });
54
55
  }
55
56
  const db = new Sqlite(this.#filename);
56
- if (this.#filename !== ":memory:") {
57
- db.pragma("journal_mode = WAL");
58
- db.pragma("synchronous = NORMAL");
57
+ if (this.#filename !== ":memory:") try {
58
+ await enableWriteAheadLog(db);
59
+ } catch (e) {
60
+ db.close();
61
+ throw e;
59
62
  }
60
63
  this.#database = db;
61
64
  }
@@ -1,5 +1,6 @@
1
1
  import { schemaReader, toDollarParams } from "../migrator/catalog.js";
2
2
  import "../memoryDatabase.js";
3
+ import { enableWriteAheadLog } from "../sqliteJournal.js";
3
4
  import { notConfigured } from "../migrator/refusals.js";
4
5
  import { additiveSql, compareSchema, hasGap } from "../migrator/schemaDiff.js";
5
6
  import { describeTargetSafely } from "../safeTarget.js";
@@ -475,9 +476,11 @@ var DrizzleOrm = class DrizzleOrm extends Orm {
475
476
  throw new Error(`DrizzleOrm "${this.name}": the sqlite dialect needs the optional driver \`better-sqlite3\` (run \`npm i better-sqlite3\`). ` + e.message, { cause: e });
476
477
  }
477
478
  const client = new Sqlite(this.#filename);
478
- if (this.#filename !== ":memory:") {
479
- client.pragma("journal_mode = WAL");
480
- client.pragma("synchronous = NORMAL");
479
+ if (this.#filename !== ":memory:") try {
480
+ await enableWriteAheadLog(client);
481
+ } catch (e) {
482
+ client.close();
483
+ throw e;
481
484
  }
482
485
  client.pragma("foreign_keys = ON");
483
486
  this.#client = client;
@@ -24,6 +24,25 @@ const PREPARED_CACHE_MAX = 128;
24
24
  /** Séquence des noms de prepared statements (PG les exige uniques par process). */
25
25
  let preparedNameSeq = 0;
26
26
  /**
27
+ * Les comparaisons scalaires, dans l'ordre où elles entrent dans le `WHERE`.
28
+ *
29
+ * UNE table pour le chemin direct (`#pushOperators`) ET le chemin préparé
30
+ * (`#buildPreparedSelect`) : deux listes recopiées finiraient par traduire le
31
+ * même critère en deux SQL différents, selon que la forme est en cache ou non.
32
+ * Leur texte SQL ne dépend que de l'opérateur, jamais de la valeur — c'est ce
33
+ * qui les rend préparables (≠ `$in`/`$nin`, dont le SQL suit la cardinalité).
34
+ */
35
+ const COMPARISONS = [
36
+ ["$eq", eq],
37
+ ["$ne", ne],
38
+ ["$gt", gt],
39
+ ["$gte", gte],
40
+ ["$lt", lt],
41
+ ["$lte", lte]
42
+ ];
43
+ /** Opérateurs qu'une forme préparée sait rendre : les comparaisons et `$null`. */
44
+ const PREPARABLE_OPERATORS = /* @__PURE__ */ new Set([...COMPARISONS.map(([op]) => op), "$null"]);
45
+ /**
27
46
  * Repository portable (contrat {@link IRepository}) au-dessus d'une table Drizzle
28
47
  * + driver `better-sqlite3`.
29
48
  *
@@ -83,9 +102,18 @@ var DrizzleRepository = class DrizzleRepository {
83
102
  this.#dialect = dialect;
84
103
  this.#transactional = transactional;
85
104
  }
86
- /** Colonne Drizzle d'une table par nom logique ; `undefined` si elle n'existe pas. */
105
+ /**
106
+ * Colonne Drizzle d'une table par nom logique ; `undefined` si elle n'existe pas.
107
+ *
108
+ * 🔴 `Object.hasOwn` sur l'objet des COLONNES, jamais `table[name]` : le nom
109
+ * vient souvent de la requête (`?sort=`), et une clé héritée du prototype
110
+ * (`constructor`, `__proto__`, `toString`) ne vaut pas `undefined` — elle
111
+ * franchissait la liste blanche et cassait plus loin dans le pilote.
112
+ * `getTableColumns` rend l'objet tel quel, sans copie.
113
+ */
87
114
  #col(table, name) {
88
- return table[name];
115
+ const columns = getTableColumns(table);
116
+ return Object.hasOwn(columns, name) ? columns[name] : void 0;
89
117
  }
90
118
  /**
91
119
  * Colonne EXIGÉE : un nom inconnu lève au lieu de partir dans le SQL.
@@ -138,9 +166,21 @@ var DrizzleRepository = class DrizzleRepository {
138
166
  *
139
167
  * Fallback sans PK déclarée : `rowid` (SQLite-only — toutes les entités
140
168
  * framework ont une PK ; une table d'app sans PK est un cas sqlite assumé).
169
+ *
170
+ * Court-circuit : un critère qui fixe TOUTES les colonnes de la PK par une
171
+ * égalité désigne déjà au plus une ligne — le `WHERE` nu suffit, et la
172
+ * sous-requête n'est plus payée. Elle coûtait ~60 µs par UPDATE en SQLite
173
+ * (recompilation d'un SQL plus long + table dérivée matérialisée, mesuré par
174
+ * `scripts/micro/micro-update-pick.mjs` du skill `nodefony-load-test`), soit
175
+ * l'essentiel de l'écart du banc ORM face à NestJS (#510).
176
+ *
177
+ * @param criteria - critère portable de la mutation.
178
+ * @returns le prédicat qui borne la mutation à au plus une ligne.
141
179
  */
142
- #pickOne(where) {
180
+ #pickOne(criteria) {
181
+ const where = this.#where(criteria);
143
182
  const pk = this.#pkColumns();
183
+ if (where && pk && this.#pinsPrimaryKey(pk, criteria)) return where;
144
184
  if (!pk) return where ? sql`rowid in (select rowid from ${this.#table} where ${where} limit 1)` : sql`rowid in (select rowid from ${this.#table} limit 1)`;
145
185
  const qualified = sql.join(pk, sql.raw(", "));
146
186
  const inner = where ? sql`select ${qualified} from ${this.#table} where ${where} limit 1` : sql`select ${qualified} from ${this.#table} limit 1`;
@@ -150,6 +190,25 @@ var DrizzleRepository = class DrizzleRepository {
150
190
  return sql`${target} in (select ${output} from (${inner}) as picked)`;
151
191
  }
152
192
  /**
193
+ * Vrai si le critère fixe chaque colonne de la PK par une égalité à une
194
+ * valeur (ni `null`, ni opérateur, ni branche de `$or`) — l'unicité de la
195
+ * PK garantit alors « au plus une ligne » sans sous-requête.
196
+ *
197
+ * @param pk - colonnes de la PK (au plus 31 : masque de bits).
198
+ * @param criteria - critère portable de la mutation.
199
+ */
200
+ #pinsPrimaryKey(pk, criteria) {
201
+ if (criteria === void 0 || pk.length > 31) return false;
202
+ let pinned = 0;
203
+ for (const [field, value] of Object.entries(criteria)) {
204
+ if (field === "$or" || value === null || value === void 0 || isFieldOperators(value)) continue;
205
+ const col = this.#col(this.#table, field);
206
+ const i = col === void 0 ? -1 : pk.indexOf(col);
207
+ if (i !== -1) pinned |= 1 << i;
208
+ }
209
+ return pinned === (1 << pk.length) - 1;
210
+ }
211
+ /**
153
212
  * Lignes affectées, normalisé par driver : better-sqlite3 `{changes}` /
154
213
  * pg `{rowCount}` / mysql2 tuple `[ResultSetHeader{affectedRows}, fields]`.
155
214
  */
@@ -228,12 +287,10 @@ var DrizzleRepository = class DrizzleRepository {
228
287
  }
229
288
  /** Empile les conditions d'un objet d'opérateurs riches sur une colonne. */
230
289
  #pushOperators(conds, col, ops) {
231
- if (ops.$eq !== void 0) conds.push(eq(col, ops.$eq));
232
- if (ops.$ne !== void 0) conds.push(ne(col, ops.$ne));
233
- if (ops.$gt !== void 0) conds.push(gt(col, ops.$gt));
234
- if (ops.$gte !== void 0) conds.push(gte(col, ops.$gte));
235
- if (ops.$lt !== void 0) conds.push(lt(col, ops.$lt));
236
- if (ops.$lte !== void 0) conds.push(lte(col, ops.$lte));
290
+ for (const [op, compare] of COMPARISONS) {
291
+ const value = ops[op];
292
+ if (value !== void 0) conds.push(compare(col, value));
293
+ }
237
294
  if (ops.$in !== void 0) conds.push(inArray(col, [...ops.$in]));
238
295
  if (ops.$nin !== void 0) conds.push(notInArray(col, [...ops.$nin]));
239
296
  if (ops.$like !== void 0) conds.push(likeCond(this.#dialect, sql`${col}`, ops.$like));
@@ -246,7 +303,13 @@ var DrizzleRepository = class DrizzleRepository {
246
303
  for (const [field, value] of Object.entries(criteria)) {
247
304
  if (field === "$or") {
248
305
  if (!Array.isArray(value)) throw new Error(`DrizzleRepository(${getTableName(this.#table)}): $or attend un tableau de critères.`);
249
- const [first, ...rest] = value.map((branch) => this.#where(branch)).filter((branch) => branch !== void 0);
306
+ if (value.length === 0) {
307
+ conds.push(sql`1 = 0`);
308
+ continue;
309
+ }
310
+ const branches = value.map((branch) => this.#where(branch));
311
+ if (branches.some((branch) => branch === void 0)) continue;
312
+ const [first, ...rest] = branches;
250
313
  if (first !== void 0) conds.push(rest.length === 0 ? first : or(first, ...rest));
251
314
  continue;
252
315
  }
@@ -267,8 +330,14 @@ var DrizzleRepository = class DrizzleRepository {
267
330
  * présence de limit/offset. Les VALEURS n'en font jamais partie : elles
268
331
  * deviennent des placeholders (`p<i>`, `lim`, `off`) bindés à l'exécution.
269
332
  *
270
- * Repli (`null`) par construction : `$or` et opérateurs riches (le SQL varie
271
- * avec la cardinalité — `$in` — ou la combinaison d'opérateurs), valeur
333
+ * Les comparaisons scalaires ({@link COMPARISONS}) et `$null` entrent dans la
334
+ * forme : la COMBINAISON d'opérateurs d'un champ fait partie de la clé, leurs
335
+ * valeurs deviennent des placeholders `p<i>$<op>`. C'est le chemin des
336
+ * lectures bornées dans le temps (`expiresAt: { $gt: now }` — la révocation
337
+ * des jetons, lue à chaque requête authentifiée).
338
+ *
339
+ * Repli (`null`) par construction : `$or`, `$in`/`$nin` (le SQL suit la
340
+ * cardinalité), `$like` (clause d'échappement propre au dialecte), valeur
272
341
  * `undefined` (le chemin actuel la rejette, même contrat), transaction
273
342
  * (cf ctor), cache plein ({@link PREPARED_CACHE_MAX}).
274
343
  */
@@ -278,7 +347,22 @@ var DrizzleRepository = class DrizzleRepository {
278
347
  if (criteria) {
279
348
  let i = 0;
280
349
  for (const [field, value] of Object.entries(criteria)) {
281
- if (field === "$or" || value === void 0 || isFieldOperators(value)) return null;
350
+ if (field === "$or" || value === void 0) return null;
351
+ if (isFieldOperators(value)) {
352
+ let ops = "";
353
+ for (const op in value) if (!PREPARABLE_OPERATORS.has(op)) return null;
354
+ for (const [op] of COMPARISONS) {
355
+ const operand = value[op];
356
+ if (operand !== void 0) {
357
+ ops += op;
358
+ params[`p${i}${op}`] = operand;
359
+ }
360
+ }
361
+ if (value.$null !== void 0) ops += value.$null ? "$null1" : "$null0";
362
+ key += `${field}\x04${ops}\x00`;
363
+ i++;
364
+ continue;
365
+ }
282
366
  if (value === null) key += `${field}\x01\x00`;
283
367
  else {
284
368
  key += `${field}\x00`;
@@ -322,6 +406,12 @@ var DrizzleRepository = class DrizzleRepository {
322
406
  let i = 0;
323
407
  for (const [field, value] of Object.entries(criteria)) {
324
408
  const col = this.#requireCol(field);
409
+ if (isFieldOperators(value)) {
410
+ for (const [op, compare] of COMPARISONS) if (value[op] !== void 0) conds.push(compare(col, sql.param(sql.placeholder(`p${i}${op}`), col)));
411
+ if (value.$null !== void 0) conds.push(value.$null ? isNull(col) : isNotNull(col));
412
+ i++;
413
+ continue;
414
+ }
325
415
  conds.push(value === null ? isNull(col) : eq(col, sql.param(sql.placeholder(`p${i}`), col)));
326
416
  i++;
327
417
  }
@@ -435,7 +525,7 @@ var DrizzleRepository = class DrizzleRepository {
435
525
  }
436
526
  async updateOne(criteria, data) {
437
527
  if (this.#dialect === "mysql") return this.#mysqlUpdateOneReturning(data, criteria);
438
- const pick = this.#pickOne(this.#where(criteria));
528
+ const pick = this.#pickOne(criteria);
439
529
  return (await this.#prof(this.#db.update(execTable(this.#table)).set(data).where(pick).returning()))[0] ?? null;
440
530
  }
441
531
  /**
@@ -502,7 +592,7 @@ var DrizzleRepository = class DrizzleRepository {
502
592
  setObj[field] = sql`${col} + ${delta}`;
503
593
  }
504
594
  if (this.#dialect === "mysql") return this.#mysqlUpdateOneReturning(setObj, criteria);
505
- const pick = this.#pickOne(this.#where(criteria));
595
+ const pick = this.#pickOne(criteria);
506
596
  return (await this.#prof(this.#db.update(execTable(this.#table)).set(setObj).where(pick).returning()))[0] ?? null;
507
597
  }
508
598
  async delete(criteria) {
@@ -521,7 +611,7 @@ var DrizzleRepository = class DrizzleRepository {
521
611
  }
522
612
  /** DELETE atomique d'AU PLUS une ligne (PK via `#pickOne`), RETURNING la ligne. */
523
613
  async #deleteOneReturning(criteria) {
524
- const pick = this.#pickOne(this.#where(criteria));
614
+ const pick = this.#pickOne(criteria);
525
615
  return this.#prof(this.#db.delete(execTable(this.#table)).where(pick).returning());
526
616
  }
527
617
  /** PK obligatoire en mysql (les verbes re-SELECTent par PK). Fail-loud sinon. */
@@ -0,0 +1,54 @@
1
+ //#region nodefony/src/sqliteJournal.ts
2
+ /** Échéance par défaut : au-delà, une base occupée l'est pour de bon. */
3
+ const WAL_DEADLINE_MS = 1e4;
4
+ const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+ /**
6
+ * L'erreur est-elle un conflit de verrou, qui se règle en réessayant ?
7
+ *
8
+ * @param e - erreur levée par `better-sqlite3`
9
+ * @returns vrai pour `SQLITE_BUSY*` et `SQLITE_LOCKED*`
10
+ */
11
+ function isSqliteBusy(e) {
12
+ const code = e?.code;
13
+ return typeof code === "string" && (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED"));
14
+ }
15
+ /**
16
+ * Passe la base en WAL (`synchronous = NORMAL`), en tolérant les ouvertures
17
+ * CONCURRENTES d'une base neuve.
18
+ *
19
+ * Basculer en WAL exige un verrou exclusif. Deux processus qui ouvrent la même
20
+ * base neuve au même instant — l'application et `orm:migrate` lancé par
21
+ * `docker exec`, ou les workers de `nodefony cluster` — tiennent chacun un
22
+ * verrou partagé et veulent l'exclusif : SQLite tranche l'impasse par un
23
+ * `SQLITE_BUSY` IMMÉDIAT, sans passer par l'attente (`timeout`) de la
24
+ * connexion. Mesuré : 23 ouvertures sur 40 échouaient avec trois processus
25
+ * synchronisés, et la forge a vu un `orm:migrate` refusé au démarrage.
26
+ *
27
+ * Le mode WAL est PERSISTANT (écrit dans le fichier) : on le lit d'abord, et
28
+ * seul le premier ouvreur bascule. Un conflit se réessaie, à délai croissant,
29
+ * jusqu'à {@link WAL_DEADLINE_MS} ; toute autre erreur remonte telle quelle.
30
+ *
31
+ * @param db - connexion ouverte sur un FICHIER (sans objet sur `:memory:`)
32
+ * @param options - échéance, attente et horloge (injectées en test)
33
+ * @throws La dernière erreur `SQLITE_BUSY` si l'échéance est dépassée, ou
34
+ * toute autre erreur au premier essai
35
+ */
36
+ async function enableWriteAheadLog(db, options = {}) {
37
+ const deadlineMs = options.deadlineMs ?? 1e4;
38
+ const sleep = options.sleep ?? sleepFor;
39
+ const now = options.now ?? Date.now;
40
+ const start = now();
41
+ for (let attempt = 1;; attempt++) try {
42
+ if (db.pragma("journal_mode", { simple: true }) !== "wal") {
43
+ const mode = db.pragma("journal_mode = WAL", { simple: true });
44
+ if (mode !== "wal") throw Object.assign(/* @__PURE__ */ new Error(`journal_mode resté « ${String(mode)} »`), { code: "SQLITE_BUSY" });
45
+ }
46
+ db.pragma("synchronous = NORMAL");
47
+ return;
48
+ } catch (e) {
49
+ if (!isSqliteBusy(e) || now() - start >= deadlineMs) throw e;
50
+ await sleep(Math.min(20 * attempt, 250));
51
+ }
52
+ }
53
+ //#endregion
54
+ export { WAL_DEADLINE_MS, enableWriteAheadLog, isSqliteBusy };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Mise en WAL d'une base SQLite — UNE implémentation, appelée par les deux
3
+ * ouvreurs de fichier du paquet : l'adapter ORM (`DrizzleOrm`) et le pilote de
4
+ * migration (`sqliteDriver`). Une base migrée puis ouverte par l'application ne
5
+ * doit pas changer de mode en route, et les deux doivent survivre à la même
6
+ * concurrence.
7
+ */
8
+ /** Ce que la mise en WAL lit d'une connexion `better-sqlite3`. */
9
+ export interface ISqlitePragmaClient {
10
+ pragma(source: string, options?: {
11
+ simple?: boolean;
12
+ }): unknown;
13
+ }
14
+ /** Réglages de la mise en WAL — injectables pour l'éprouver sans attendre. */
15
+ export interface IWriteAheadLogOptions {
16
+ /** Durée maximale des réessais, en millisecondes. */
17
+ deadlineMs?: number;
18
+ /** Attente entre deux essais. */
19
+ sleep?: (ms: number) => Promise<void>;
20
+ /** Horloge (millisecondes). */
21
+ now?: () => number;
22
+ }
23
+ /** Échéance par défaut : au-delà, une base occupée l'est pour de bon. */
24
+ export declare const WAL_DEADLINE_MS = 10000;
25
+ /**
26
+ * L'erreur est-elle un conflit de verrou, qui se règle en réessayant ?
27
+ *
28
+ * @param e - erreur levée par `better-sqlite3`
29
+ * @returns vrai pour `SQLITE_BUSY*` et `SQLITE_LOCKED*`
30
+ */
31
+ export declare function isSqliteBusy(e: unknown): boolean;
32
+ /**
33
+ * Passe la base en WAL (`synchronous = NORMAL`), en tolérant les ouvertures
34
+ * CONCURRENTES d'une base neuve.
35
+ *
36
+ * Basculer en WAL exige un verrou exclusif. Deux processus qui ouvrent la même
37
+ * base neuve au même instant — l'application et `orm:migrate` lancé par
38
+ * `docker exec`, ou les workers de `nodefony cluster` — tiennent chacun un
39
+ * verrou partagé et veulent l'exclusif : SQLite tranche l'impasse par un
40
+ * `SQLITE_BUSY` IMMÉDIAT, sans passer par l'attente (`timeout`) de la
41
+ * connexion. Mesuré : 23 ouvertures sur 40 échouaient avec trois processus
42
+ * synchronisés, et la forge a vu un `orm:migrate` refusé au démarrage.
43
+ *
44
+ * Le mode WAL est PERSISTANT (écrit dans le fichier) : on le lit d'abord, et
45
+ * seul le premier ouvreur bascule. Un conflit se réessaie, à délai croissant,
46
+ * jusqu'à {@link WAL_DEADLINE_MS} ; toute autre erreur remonte telle quelle.
47
+ *
48
+ * @param db - connexion ouverte sur un FICHIER (sans objet sur `:memory:`)
49
+ * @param options - échéance, attente et horloge (injectées en test)
50
+ * @throws La dernière erreur `SQLITE_BUSY` si l'échéance est dépassée, ou
51
+ * toute autre erreur au premier essai
52
+ */
53
+ export declare function enableWriteAheadLog(db: ISqlitePragmaClient, options?: IWriteAheadLogOptions): Promise<void>;
package/docs/index.md CHANGED
@@ -200,7 +200,7 @@ Le tableau pour situer en cinq secondes ; les cards en dessous pour savoir où l
200
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
201
  "meta": "à lire avant de changer de base, pas après le premier incident" },
202
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.",
203
+ "desc": "En développement, les tables sont créées au démarrage par un DDL dérivé de tes schémas : index et clés étrangères compris, aucun `DEFAULT` SQL, et seules les colonnes facultatives ajoutées sont rattrapées. La section dit ce que ça implique en production.",
204
204
  "meta": "le point à ne pas rater avant le premier déploiement" }
205
205
  ]
206
206
  ```
@@ -353,13 +353,18 @@ importable et testable sans serveur.
353
353
 
354
354
  ### Les options
355
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`. |
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
+ | `connectors.<n>.ddl` | `"auto" \| "migrate" \| "none"` | _par environnement_ | `auto` en développement et en test (`migrate` dès que l'app versionne une migration), `none` ailleurs. |
363
+ | `migrations.dir` | `string` | `"migrations"` | Dossier des migrations versionnées. |
364
+ | `migrations.check` | `"fail" \| "warn" \| "off"` | _par environnement_ | Schéma en retard : `fail` retient `/readyz` (production), `warn` ailleurs. |
365
+ | `migrations.lockTimeoutMs` | `number` | `30000` | Attente du verrou de migration entre exemplaires. |
366
+ | `migrations.divergence` | `"report" \| "fail" \| "off"` | `"report"` | Conduite face à une migration appliquée qui a changé depuis. |
367
+ | `frameworkEntities` | `boolean` | `true` | Déclare (ou non) le schéma des huit briques durables sur `default`. |
363
368
 
364
369
  Table dérivée de `drizzleConfigSchema` (`config.ts:136`) et de `SQL_DIALECTS` (`config.ts:37`).
365
370
 
@@ -420,20 +425,21 @@ la piste à vérifier.
420
425
 
421
426
  Pour les dialectes réseau, la connexion fait un **ping réel** au démarrage : les pools `pg` et `mysql2`
422
427
  sont paresseux, sans ce `SELECT 1` une base morte « se connecterait » et n'échouerait qu'à la première
423
- requête métier (`#connectPostgres()`, `DrizzleOrm.ts:1193` · `#connectMysql()`, `DrizzleOrm.ts:1514`).
428
+ requête métier (`#connectPostgres()`, `DrizzleOrm.ts:1206` · `#connectMysql()`, `DrizzleOrm.ts:1521`).
424
429
 
425
430
  ## Dialectes — une base par déploiement, un seul code
426
431
 
427
432
  Trois dialectes au même contrat. Le tableau dit ce qu'il faut installer et ce qu'on obtient.
428
433
 
429
- | Dialecte | Driver | Statut du paquet | Cible | Configuration |
430
- | ---------- | ---------------- | -------------------------- | ------------------------------------------ | ------------- |
431
- | `sqlite` | `better-sqlite3` | **dépendance** (fournie) | développement, tests, production mono-nœud | `filename` |
432
- | `postgres` | `pg` | dépendance **optionnelle** | production multi-pod (recommandé) | `url` |
433
- | `mysql` | `mysql2` | dépendance **optionnelle** | production — **MySQL 8.4 et MariaDB 11.4** | `url` |
434
+ | Dialecte | Driver | Statut du paquet | Cible | Configuration |
435
+ | ---------- | ---------------- | ---------------------------------- | ------------------------------------------ | ------------- |
436
+ | `sqlite` | `better-sqlite3` | dépendance de pair **optionnelle** | développement, tests, production mono-nœud | `filename` |
437
+ | `postgres` | `pg` | dépendance de pair **optionnelle** | production multi-pod (recommandé) | `url` |
438
+ | `mysql` | `mysql2` | dépendance de pair **optionnelle** | production — **MySQL 8.4 et MariaDB 11.4** | `url` |
434
439
 
435
- Les drivers réseau sont chargés **paresseusement** au moment de la connexion : une application SQLite
436
- ne paie ni l'installation ni le chargement de `pg`/`mysql2`.
440
+ Les trois drivers sont des dépendances de pair optionnelles, posées par le scaffold pour le dialecte
441
+ choisi et chargées **paresseusement** à la connexion : une application n'installe, et ne charge, que
442
+ le pilote de son dialecte.
437
443
 
438
444
  ### Ce qui ne change pas
439
445
 
@@ -467,7 +473,7 @@ En MySQL, les verbes « qui rendent la ligne écrite » (`create`, `updateOne`,
467
473
  `findOneAndDelete`) se décomposent en sélection de la cible → mutation bornée par la clé primaire **avec
468
474
  le critère revérifié dans le `WHERE`** → relecture. Deux à trois allers-retours au lieu d'un : c'est le
469
475
  prix du dialecte, payé **uniquement** en MySQL. Une course perdue rend `null`, jamais une mutation hors
470
- critère (`#mysqlInsertReturning()`, `DrizzleRepository.ts:1105`).
476
+ critère (`#mysqlInsertReturning()`, `DrizzleRepository.ts:1228`).
471
477
 
472
478
  Le SQL brut nécessaire aux entités du framework est lui aussi routé par dialecte, dans un seul fichier
473
479
  (`queryKit.ts`) : recherche dans une colonne JSON (`findUserIdBySocialProvider()`, `queryKit.ts:76`),
@@ -518,12 +524,17 @@ connexion**, donc leurs tables sont créées au moment où l'ORM s'ouvre.
518
524
  ### Le DDL dérivé — comment les tables apparaissent
519
525
 
520
526
  Drizzle ne « synchronise » pas un schéma. L'adapter dérive lui-même un `CREATE TABLE IF NOT EXISTS`
521
- depuis chaque table déclarée (`#buildCreateTable()`, `DrizzleOrm.ts:509`) et l'exécute à la connexion.
522
- Trois conséquences à connaître **avant** de dépendre de ce mécanisme :
523
-
524
- 1. il **crée**, il ne **modifie** pas — aucun `ALTER` n'est émis ;
525
- 2. il n'émet **ni `DEFAULT` SQL ni index** — d'où la règle des défauts en `$defaultFn` ;
526
- 3. il ne connaît que les colonnes, les clés primaires, `NOT NULL` et `UNIQUE`.
527
+ depuis chaque table déclarée (`#buildCreateTable()`, `DrizzleOrm.ts:509`) et l'exécute à la connexion
528
+ **quand le connecteur est en `ddl: "auto"`** — le défaut en développement et en test ; en production
529
+ le défaut est `none`, et un travail extérieur lance `orm:migrate`. Trois conséquences à connaître
530
+ **avant** de dépendre de ce mécanisme :
531
+
532
+ 1. il **crée** les tables, leurs index et leurs clés étrangères ; il ne rattrape ensuite que les
533
+ colonnes ajoutées qui **acceptent le vide** — jamais une colonne obligatoire, jamais une
534
+ suppression ;
535
+ 2. il n'émet **aucun `DEFAULT` SQL** — d'où la règle des défauts en `$defaultFn` ;
536
+ 3. pour tout le reste (renommer, rendre obligatoire, supprimer), c'est une migration
537
+ (`orm:generate`, `orm:migrate`).
527
538
 
528
539
  C'est un confort de développement, pas un outil de migration. La suite est dans
529
540
  [Migrations](#migrations--deux-chemins-un-seul-schéma).
@@ -559,7 +570,7 @@ await posts.count({ views: { $gte: 10 } });
559
570
 
560
571
  Les opérateurs (`$eq $ne $gt $gte $lt $lte $in $nin $like`) sont **ceux d'orm-core**, identiques sur
561
572
  tous les drivers ; la traduction en `eq()`/`inArray()` se fait dans `#where()`
562
- (`DrizzleRepository.ts:445`). Leur référence complète est dans
573
+ (`DrizzleRepository.ts:521`). Leur référence complète est dans
563
574
  [la page d'orm-core](../../orm-core/docs/index.md).
564
575
 
565
576
  `$like` est émis avec sa clause `ESCAPE '\'` (`likeSql.ts`), ce qui rend un `%` ou un `_` **littéral**
@@ -570,11 +581,12 @@ lui-même en SQLite : le même critère ne rendait pas les mêmes lignes selon l
570
581
  Deux points de comportement qui évitent des surprises :
571
582
 
572
583
  - **« au plus une ligne »** est garanti par construction pour `updateOne`/`deleteOne`/`increment` :
573
- la mutation est bornée par la clé primaire découverte de la table, jamais par un `LIMIT` sur un
574
- `UPDATE` (`#pickOne()`, `DrizzleRepository.ts:284`). C'est ce qui rend ces verbes portables — MySQL
584
+ la mutation est bornée à une ligne : `WHERE <pk> = ?` quand le critère fixe toute la clé primaire,
585
+ sinon `WHERE pk IN (SELECT pk FROM (SELECT pk … LIMIT 1) AS picked)` — jamais un `LIMIT` sur
586
+ l'`UPDATE` lui-même (`#pickOne()`, `DrizzleRepository.ts:324`). C'est ce qui rend ces verbes portables — MySQL
575
587
  interdit la forme naïve.
576
588
  - **l'eager-load est manuel** : une requête `IN (…)` par relation déclarée, puis regroupement en
577
- mémoire (`#populate()`, `DrizzleRepository.ts:694`). Choix assumé — pas de couche de relations à
589
+ mémoire (`#populate()`, `DrizzleRepository.ts:817`). Choix assumé — pas de couche de relations à
578
590
  déclarer une seconde fois, et le comportement est le même sur les trois dialectes.
579
591
 
580
592
  ### Transactions — une connexion dédiée, jamais le pool
@@ -627,7 +639,7 @@ const rows = await db.all(sql`
627
639
  `);
628
640
  ```
629
641
 
630
- C'est l'**anti-blocage** du modèle Repository (`getNativeConnection()`, `DrizzleOrm.ts:1783`) : CTE,
642
+ C'est l'**anti-blocage** du modèle Repository (`getNativeConnection()`, `DrizzleOrm.ts:1792`) : CTE,
631
643
  fonctions de fenêtre, sous-requêtes corrélées, jointures arbitraires. Deux contreparties assumées :
632
644
  ce SQL n'est plus portable entre dialectes, et il **ne passe pas** par la sonde de profilage des
633
645
  requêtes.
@@ -851,7 +863,7 @@ l'emplacement **est** l'infra déclarée, déjà affichée ailleurs (`location`,
851
863
  ## ⚡ Performance & mémoire
852
864
 
853
865
  **La sonde de requêtes ne coûte rien quand elle est éteinte.** Chaque exécution passe par un point de
854
- mesure unique (`#prof()`, `DrizzleRepository.ts:355`) qui alimente deux consommateurs — le profileur
866
+ mesure unique (`#prof()`, `DrizzleRepository.ts:433`) qui alimente deux consommateurs — le profileur
855
867
  par requête (barre de debug) et l'agrégat de flux. Les deux sont gardés par un drapeau : si aucun n'est
856
868
  actif, la fonction rend le constructeur de requête tel quel, sans allocation. Le flux est **désactivé
857
869
  en production** par défaut.
@@ -875,8 +887,7 @@ connexions. Les chiffres vivent dans la sortie du banc, pas ici — ils dépende
875
887
  | -------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
876
888
  | 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 |
877
889
  | `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(() => …)` |
878
- | Colonne ajoutée, table inchangée | le DDL dérivé **ne fait aucun `ALTER`** | supprimer la base de dev, ou passer par `drizzle-kit` |
879
- | Index déclaré, absent en base | les index ne sortent que via `drizzle-kit` | migration `drizzle-kit` en production |
890
+ | Colonne **obligatoire** ajoutée, table inchangée | le rattrapage de développement n'ajoute que les colonnes facultatives | `nodefony orm:reset` (dev), ou `orm:generate` puis `orm:migrate` |
880
891
  | Le démarrage échoue sur le connecteur | infra déclarée injoignable → `BootConfigurationError` (voulu) | corriger l'URL / démarrer la base / retirer le connecteur |
881
892
  | `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 |
882
893
  | 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 |
@@ -941,7 +952,7 @@ npm run test:load # charge, limites, mémoire
941
952
  - 🧭 **L'abstraction au-dessus** : [`@nodefony/orm-core`](../../orm-core/docs/index.md) — contrats
942
953
  `IOrm`/`IRepository`/`ITransaction`, `Criteria` et opérateurs riches, registres. À lire pour tout ce
943
954
  qui est **portable** ; cette page-ci ne documente que le driver SQL.
944
- - 🗄️ **Déployer un schéma** : [Migrations de schéma](migrations.md) — les cinq commandes, le
955
+ - 🗄️ **Déployer un schéma** : [Migrations de schéma](migrations.md) — les six commandes, le
945
956
  rattrapage de développement, le travail d'orchestrateur et l'expansion/contraction.
946
957
  - 📗 **Tutoriel** : [créer une entité pas à pas](../../orm-core/docs/tutorial-entity.md)
947
958
  - 🧩 **L'autre driver** : [`@nodefony/mongoose`](../../mongoose/docs/index.md) — même contrat, MongoDB.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/drizzle",
3
- "version": "10.0.0-beta.1",
3
+ "version": "10.0.0-beta.2",
4
4
  "description": "Moteur SQL de Nodefony sur Drizzle ORM (SQLite, PostgreSQL, MySQL) : dépôts typés, migrations de schéma et adoption d'une base existante",
5
5
  "nodefony": {
6
6
  "storeKind": "durable",
@@ -60,29 +60,29 @@
60
60
  "zod": "^4.6.5"
61
61
  },
62
62
  "devDependencies": {
63
- "@nodefony/framework": "^10.0.0-beta.1",
64
- "@nodefony/http": "^10.0.0-beta.1",
65
- "@nodefony/orm-core": "^10.0.0-beta.1",
66
- "@nodefony/security": "^10.0.0-beta.1",
67
- "@nodefony/user": "^10.0.0-beta.1",
63
+ "@nodefony/framework": "^10.0.0-beta.2",
64
+ "@nodefony/http": "^10.0.0-beta.2",
65
+ "@nodefony/orm-core": "^10.0.0-beta.2",
66
+ "@nodefony/security": "^10.0.0-beta.2",
67
+ "@nodefony/user": "^10.0.0-beta.2",
68
68
  "@types/better-sqlite3": "9.6.0",
69
69
  "@types/node": "26.6.3",
70
70
  "@types/pg": "8.23.1",
71
71
  "better-sqlite3": "13.0.3",
72
72
  "drizzle-kit": "0.31.11",
73
- "mysql2": "3.24.4",
74
- "nodefony": "^10.0.0-beta.1",
75
- "pg": "8.23.0"
73
+ "mysql2": "3.24.5",
74
+ "nodefony": "^10.0.0-beta.2",
75
+ "pg": "8.23.1"
76
76
  },
77
77
  "peerDependencies": {
78
- "@nodefony/framework": "^10.0.0-beta.1",
79
- "@nodefony/http": "^10.0.0-beta.1",
80
- "@nodefony/orm-core": "^10.0.0-beta.1",
81
- "@nodefony/security": "^10.0.0-beta.1",
82
- "@nodefony/user": "^10.0.0-beta.1",
78
+ "@nodefony/framework": "^10.0.0-beta.2",
79
+ "@nodefony/http": "^10.0.0-beta.2",
80
+ "@nodefony/orm-core": "^10.0.0-beta.2",
81
+ "@nodefony/security": "^10.0.0-beta.2",
82
+ "@nodefony/user": "^10.0.0-beta.2",
83
83
  "better-sqlite3": "^13.0.0",
84
84
  "mysql2": "^3.24.0",
85
- "nodefony": "^10.0.0-beta.1",
85
+ "nodefony": "^10.0.0-beta.2",
86
86
  "pg": "^8.23.0"
87
87
  },
88
88
  "repository": {