@nodefony/drizzle 10.0.0-alpha.9 → 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.
Files changed (44) hide show
  1. package/README.md +28 -26
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +5 -5
  5. package/dist/nodefony/command/orm-generate.js +3 -3
  6. package/dist/nodefony/config/defineModuleConfig.js +2 -2
  7. package/dist/nodefony/entity/userTable.js +1 -1
  8. package/dist/nodefony/registerStores.js +2 -2
  9. package/dist/nodefony/service/DrizzleService.js +26 -12
  10. package/dist/nodefony/src/DrizzleAuditStore.js +1 -1
  11. package/dist/nodefony/src/connectorTarget.js +1 -1
  12. package/dist/nodefony/src/migrator/DrizzleMigrator.js +3 -3
  13. package/dist/nodefony/src/migrator/adopt.js +1 -1
  14. package/dist/nodefony/src/migrator/destructive.js +2 -3
  15. package/dist/nodefony/src/migrator/drivers/mysqlDriver.js +1 -1
  16. package/dist/nodefony/src/migrator/drivers/postgresDriver.js +1 -1
  17. package/dist/nodefony/src/migrator/drivers/sqliteDriver.js +6 -3
  18. package/dist/nodefony/src/migrator/explain.js +7 -10
  19. package/dist/nodefony/src/migrator/history.js +25 -2
  20. package/dist/nodefony/src/migrator/kit.js +23 -9
  21. package/dist/nodefony/src/migrator/rebuildCopy.js +6 -6
  22. package/dist/nodefony/src/migrator/resolve.js +4 -4
  23. package/dist/nodefony/src/migrator/sources.js +13 -8
  24. package/dist/nodefony/src/orm-core/DrizzleOrm.js +55 -32
  25. package/dist/nodefony/src/orm-core/DrizzleRepository.js +142 -40
  26. package/dist/nodefony/src/queryKit.js +5 -5
  27. package/dist/nodefony/src/sqliteJournal.js +54 -0
  28. package/dist/types/nodefony/src/DrizzleUserRepository.d.ts +1 -1
  29. package/dist/types/nodefony/src/connectorTarget.d.ts +1 -1
  30. package/dist/types/nodefony/src/migrator/DrizzleMigrator.d.ts +4 -4
  31. package/dist/types/nodefony/src/migrator/destructive.d.ts +1 -1
  32. package/dist/types/nodefony/src/migrator/drivers/index.d.ts +2 -2
  33. package/dist/types/nodefony/src/migrator/explain.d.ts +9 -9
  34. package/dist/types/nodefony/src/migrator/name.d.ts +1 -1
  35. package/dist/types/nodefony/src/migrator/types.d.ts +2 -2
  36. package/dist/types/nodefony/src/orm-core/DrizzleOrm.d.ts +3 -3
  37. package/dist/types/nodefony/src/orm-core/DrizzleRepository.d.ts +1 -1
  38. package/dist/types/nodefony/src/orm-core/ddlPlan.d.ts +2 -2
  39. package/dist/types/nodefony/src/queryKit.d.ts +9 -9
  40. package/dist/types/nodefony/src/safeTarget.d.ts +2 -2
  41. package/dist/types/nodefony/src/sqliteJournal.d.ts +53 -0
  42. package/docs/index.md +50 -39
  43. package/docs/migrations.md +1 -1
  44. package/package.json +16 -16
@@ -30,12 +30,12 @@ import { sql } from "drizzle-orm";
30
30
  */
31
31
  async function findUserIdBySocialProvider(db, dialect, provider, providerId) {
32
32
  switch (dialect) {
33
- case "sqlite": return (await db.all(sql`SELECT "id" AS id FROM ${ident("sqlite", USER_TABLE_NAME)}
33
+ case "sqlite": return db.all(sql`SELECT "id" AS id FROM ${ident("sqlite", USER_TABLE_NAME)}
34
34
  WHERE EXISTS (
35
35
  SELECT 1 FROM json_each(${ident("sqlite", USER_TABLE_NAME)}."socialProviders")
36
36
  WHERE json_extract(value, '$.provider') = ${provider}
37
37
  AND json_extract(value, '$.providerId') = ${providerId}
38
- ) LIMIT 1`))[0]?.id ?? null;
38
+ ) LIMIT 1`)[0]?.id ?? null;
39
39
  case "postgres": {
40
40
  const pattern = JSON.stringify([{
41
41
  provider,
@@ -95,7 +95,7 @@ async function reserveIdempotencyKeyMysql(db, args) {
95
95
  const [inserted] = await exec.execute(sql`INSERT IGNORE INTO ${sql.raw("`idempotency_key`")}
96
96
  (${sql.raw("`key`, `fingerprint`, `state`, `response`, `expiresAt`")})
97
97
  VALUES (${key}, ${fingerprint}, 'if', NULL, ${leaseExpiresAt})`);
98
- if ((inserted?.affectedRows ?? 0) === 1) return true;
98
+ if ((inserted.affectedRows ?? 0) === 1) return true;
99
99
  const [stolen] = await exec.execute(sql`UPDATE ${sql.raw("`idempotency_key`")}
100
100
  SET ${sql.raw("`fingerprint`")} = ${fingerprint},
101
101
  ${sql.raw("`state`")} = 'if',
@@ -103,7 +103,7 @@ async function reserveIdempotencyKeyMysql(db, args) {
103
103
  ${sql.raw("`expiresAt`")} = ${leaseExpiresAt}
104
104
  WHERE ${sql.raw("`key`")} = ${key}
105
105
  AND ${sql.raw("`expiresAt`")} < ${now}`);
106
- return (stolen?.affectedRows ?? 0) > 0;
106
+ return (stolen.affectedRows ?? 0) > 0;
107
107
  }
108
108
  /** Identifiant quoté selon le dialecte (`"x"` SQL standard, `` `x` `` MySQL). */
109
109
  function ident(dialect, name) {
@@ -189,7 +189,7 @@ function orderBySql(dialect, order, allowed, fallback, tiebreaker) {
189
189
  /** Exécute un SELECT et normalise le retour en tableau de lignes (API native divergente). */
190
190
  async function runSelect(db, dialect, query) {
191
191
  switch (dialect) {
192
- case "sqlite": return await db.all(query);
192
+ case "sqlite": return db.all(query);
193
193
  case "postgres": return (await db.execute(query)).rows;
194
194
  case "mysql": {
195
195
  const [rows] = await db.execute(query);
@@ -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 };
@@ -40,7 +40,7 @@ export declare class DrizzleUserRepository implements IUserRepository {
40
40
  updateMany(criteria: Criteria<IPasswordAuthenticatedUser>, data: Partial<IPasswordAuthenticatedUser>): Promise<number>;
41
41
  delete(criteria: Criteria<IPasswordAuthenticatedUser>): Promise<number>;
42
42
  count(criteria?: Criteria<IPasswordAuthenticatedUser>): Promise<number>;
43
- countDistinct(field: keyof IPasswordAuthenticatedUser & string, criteria?: Criteria<IPasswordAuthenticatedUser>): Promise<number>;
43
+ countDistinct(field: Extract<keyof IPasswordAuthenticatedUser, string>, criteria?: Criteria<IPasswordAuthenticatedUser>): Promise<number>;
44
44
  withTransaction(tx: ITransaction): IUserRepository;
45
45
  findByIdentifier(identifier: string): Promise<IPasswordAuthenticatedUser | null>;
46
46
  /**
@@ -35,7 +35,7 @@ export declare function defaultConnectorFilename(kernel: Kernel | null, name: st
35
35
  export interface IConnectorTarget {
36
36
  dialect: SqlDialect;
37
37
  filename?: string;
38
- url?: string;
38
+ url?: string | undefined;
39
39
  }
40
40
  /**
41
41
  * Résout les coordonnées d'un connecteur : dialecte, et fichier ou URL.
@@ -10,7 +10,7 @@ export interface IDrizzleMigratorOptions extends IMigrationTarget {
10
10
  /** Registre de sources, espace de noms OUVERT. */
11
11
  sources: readonly IMigrationSource[];
12
12
  /** Délai d'attente du verrou (ms). */
13
- lockTimeoutMs?: number;
13
+ lockTimeoutMs?: number | undefined;
14
14
  /** Qui applique — hôte du job par défaut. */
15
15
  appliedBy?: string;
16
16
  /** Horloge, injectable pour les tests. */
@@ -71,12 +71,12 @@ export declare class DrizzleMigrator {
71
71
  * @returns ce qui a été levé et ré-aligné.
72
72
  */
73
73
  repair(options?: {
74
- source?: string;
75
- updateHashes?: boolean;
74
+ source?: string | undefined;
75
+ updateHashes?: boolean | undefined;
76
76
  forget?: readonly {
77
77
  source: string;
78
78
  tag: string;
79
- }[];
79
+ }[] | undefined;
80
80
  }): Promise<{
81
81
  cleared: {
82
82
  source: string;
@@ -155,5 +155,5 @@ export declare function touchesExistingRows(files: readonly {
155
155
  */
156
156
  export declare function checkDataAdvice(connector: string, target?: {
157
157
  dialect: string;
158
- target?: string;
158
+ target?: string | undefined;
159
159
  }): string;
@@ -8,9 +8,9 @@ export interface IMigrationTarget {
8
8
  /** Dialecte du connecteur. */
9
9
  dialect: SqlDialect;
10
10
  /** Fichier SQLite (dialecte `sqlite`). */
11
- filename?: string;
11
+ filename?: string | undefined;
12
12
  /** URL de connexion DIRECTE (dialectes `postgres` et `mysql`). */
13
- url?: string;
13
+ url?: string | undefined;
14
14
  }
15
15
  /**
16
16
  * Ouvre le pilote à connexion unique du dialecte demandé.
@@ -108,15 +108,15 @@ export interface IMigrationEntryReport {
108
108
  /** Où en est cette migration pour CE connecteur. */
109
109
  status: "applied" | "pending" | "failed" | "drifted" | "missing";
110
110
  /** Début de l'application, en millisecondes — absent si jamais appliquée. */
111
- appliedAt?: number;
111
+ appliedAt?: number | undefined;
112
112
  /** Durée de l'application, en millisecondes. */
113
- durationMs?: number;
113
+ durationMs?: number | undefined;
114
114
  /** Ce qui l'a appliquée, tel que l'historique l'a retenu. */
115
- appliedBy?: string;
115
+ appliedBy?: string | undefined;
116
116
  /** Déploiement qui l'a portée — groupe les migrations d'un même passage. */
117
- runId?: string;
117
+ runId?: string | undefined;
118
118
  /** Motif de l'échec, quand elle a échoué. */
119
- error?: string;
119
+ error?: string | undefined;
120
120
  }
121
121
  /**
122
122
  * La charge utile figée — cœur NEUTRE au premier niveau, spécifique du pilote
@@ -155,7 +155,7 @@ export interface IMigrationReport {
155
155
  * Son ABSENCE est un fait, pas un oubli : sur une base conforme, il n'y a
156
156
  * rien à nommer.
157
157
  */
158
- divergence?: ISchemaComparison;
158
+ divergence?: ISchemaComparison | undefined;
159
159
  /**
160
160
  * Ce que le passage a DÉTRUIT, nommé — présent au seul run réel qui a
161
161
  * appliqué une migration destructive, absent partout ailleurs.
@@ -166,7 +166,7 @@ export interface IMigrationReport {
166
166
  * octet de ce qu'il relit ne la mentionne. Son absence est un fait : rien
167
167
  * n'a été détruit.
168
168
  */
169
- destructive?: IDestructiveFinding[];
169
+ destructive?: IDestructiveFinding[] | undefined;
170
170
  /** Tout ce qui est propre au pilote SQL vit ici, et nulle part ailleurs. */
171
171
  driver: {
172
172
  kind: "sql";
@@ -174,9 +174,9 @@ export interface IMigrationReport {
174
174
  ddl: string;
175
175
  historyTable: string;
176
176
  /** La base visée, sans identifiant ni mot de passe. */
177
- target?: string;
177
+ target?: string | undefined;
178
178
  /** `true` quand la cible vient de `NF_MIGRATE_DATABASE_URL`. */
179
- fromMigrateUrl?: boolean;
179
+ fromMigrateUrl?: boolean | undefined;
180
180
  };
181
181
  }
182
182
  /** Grille des codes de sortie — **figée, jamais réaffectée**. */
@@ -34,7 +34,7 @@ export type MigrationNameCheck = {
34
34
  * geste qui échoue est pire que ne rien proposer, parce qu'il fait perdre
35
35
  * un aller-retour ET la confiance dans les autres suggestions.
36
36
  */
37
- suggestion?: string;
37
+ suggestion?: string | undefined;
38
38
  };
39
39
  /**
40
40
  * Dérive un nom acceptable d'une saisie qui ne l'est pas.
@@ -98,9 +98,9 @@ export interface IMigrationVerdict {
98
98
  /** Connecteur concerné. */
99
99
  connector: string;
100
100
  /** Source de migrations concernée, quand le refus en désigne une. */
101
- source?: string;
101
+ source?: string | undefined;
102
102
  /** Tag concerné, quand le refus en désigne un. */
103
- tag?: string;
103
+ tag?: string | undefined;
104
104
  /** Faits constatés, sans mise en forme — de quoi rendre la phrase. */
105
105
  facts: Record<string, string | number | boolean | readonly string[]>;
106
106
  /** Ce qu'il faut faire ensuite, du plus direct au plus assumé. */
@@ -13,12 +13,12 @@ export interface DrizzleOrmOptions {
13
13
  */
14
14
  dialect?: SqlDialect;
15
15
  /** Fichier SQLite (`":memory:"` par défaut) — dialecte `sqlite` uniquement. */
16
- filename?: string;
16
+ filename?: string | undefined;
17
17
  /**
18
18
  * Chaîne de connexion (`postgres://…`, `mysql://…`) — dialectes `postgres`/
19
19
  * `mysql`. Requise pour ces dialectes.
20
20
  */
21
- url?: string;
21
+ url?: string | undefined;
22
22
  /**
23
23
  * Container du service qui construit l'ORM — il porte le `syslog` du kernel.
24
24
  *
@@ -28,7 +28,7 @@ export interface DrizzleOrmOptions {
28
28
  * échecs des sondes, jamais le diagnostic de la reprise. Omis en usage
29
29
  * direct (bancs de test) : l'ORM reste alors autonome, comme avant.
30
30
  */
31
- container?: Container;
31
+ container?: Container | undefined;
32
32
  /**
33
33
  * Le schéma doit-il être DÉRIVÉ du code à la connexion ?
34
34
  *
@@ -15,7 +15,7 @@ import type { Criteria, IRepository, ITransaction, RepositoryReadOptions, Update
15
15
  * La surface d'exécution native qui DIVERGE (`db.all` vs `db.execute().rows`)
16
16
  * est routée par le `queryKit` — jamais ici.
17
17
  */
18
- export type DrizzleDb = BetterSQLite3Database<Record<string, never>>;
18
+ export type DrizzleDb = BetterSQLite3Database;
19
19
  /** Table Drizzle multi-dialecte (l'union honnête des variantes colKit). */
20
20
  export type DrizzleTable = SQLiteTable | PgTable | MySqlTable;
21
21
  /** Colonne Drizzle dialecte-agnostique (classe de base — acceptée par les
@@ -24,9 +24,9 @@ export interface IResolvedForeignKey {
24
24
  /** Colonnes visées, dans le même ordre que {@link columns}. */
25
25
  readonly foreignColumns: readonly string[];
26
26
  /** Politique d'effacement du parent (`restrict`, `set null`, `cascade`…). */
27
- readonly onDelete?: string;
27
+ readonly onDelete?: string | undefined;
28
28
  /** Politique de mise à jour de la clé du parent. */
29
- readonly onUpdate?: string;
29
+ readonly onUpdate?: string | undefined;
30
30
  }
31
31
  /** Une table à créer, et les tables que ses clés étrangères désignent. */
32
32
  export interface ITableDependency {
@@ -66,11 +66,11 @@ export declare function reserveIdempotencyKeyMysql(db: DrizzleDb, args: {
66
66
  }): Promise<boolean>;
67
67
  /** Filtres du listing utilisateur (sous-ensemble non portable de `IUserListQuery`). */
68
68
  export interface UserListFilters {
69
- role?: string;
70
- enabled?: boolean;
71
- locked?: boolean;
72
- hasSocial?: boolean;
73
- q?: string;
69
+ role?: string | undefined;
70
+ enabled?: boolean | undefined;
71
+ locked?: boolean | undefined;
72
+ hasSocial?: boolean | undefined;
73
+ q?: string | undefined;
74
74
  }
75
75
  /** Fenêtre + tri résolus (le défaut `identifier ASC` est posé par l'appelant). */
76
76
  export interface UserPageWindow {
@@ -91,10 +91,10 @@ export declare function listUserIdsPage(db: DrizzleDb, dialect: SqlDialect, filt
91
91
  }>;
92
92
  /** Filtres du listing d'endpoints (sous-ensemble non portable d'`IWebhookListQuery`). */
93
93
  export interface WebhookListFilters {
94
- enabled?: boolean;
95
- event?: string;
96
- failing?: boolean;
97
- q?: string;
94
+ enabled?: boolean | undefined;
95
+ event?: string | undefined;
96
+ failing?: boolean | undefined;
97
+ q?: string | undefined;
98
98
  }
99
99
  /**
100
100
  * Sélectionne les `id` d'une **page** d'endpoints webhook (filtres + tri +
@@ -37,6 +37,6 @@ import type { SqlDialect } from "../config/config.js";
37
37
  */
38
38
  export declare function describeTargetSafely(target: {
39
39
  dialect: SqlDialect;
40
- filename?: string;
41
- url?: string;
40
+ filename?: string | undefined;
41
+ url?: string | undefined;
42
42
  }, cwd?: string, grammar?: typeof path): string;
@@ -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,19 +353,24 @@ 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
 
366
371
  **`filename` est volontairement sans défaut.** Le chemin dépend du kernel, qui n'existe pas quand le
367
372
  schéma est évalué. Il est résolu **au démarrage** par `DrizzleService.#defaultFilename()`
368
- (`DrizzleService.ts:202`) vers `<app>/var/databases/nodefony-<connecteur>.db` — sous `var/`, le dossier
373
+ (`DrizzleService.ts:230`) vers `<app>/var/databases/nodefony-<connecteur>.db` — sous `var/`, le dossier
369
374
  commun des données runtime : « où sont mes données ? » a une réponse unique, un seul chemin à
370
375
  sauvegarder et à ignorer dans git.
371
376
 
@@ -406,13 +411,13 @@ use("@nodefony/drizzle", {
406
411
 
407
412
  > [!WARNING]
408
413
  > Une URL de connexion **porte un mot de passe**. Le module ne la journalise jamais telle quelle :
409
- > `redactUrl()` (`DrizzleService.ts:86`) remplace le mot de passe par `***` avant tout log de
414
+ > `redactUrl()` (`DrizzleService.ts:99`) remplace le mot de passe par `***` avant tout log de
410
415
  > démarrage, et la sonde d'administration applique la même règle.
411
416
 
412
417
  ### Quand la connexion échoue, le démarrage échoue
413
418
 
414
419
  Un connecteur **déclaré** qui ne se connecte pas lève une `BootConfigurationError`
415
- (`DrizzleService.ts:340`) — en développement **comme** en production. Ce n'est pas une sévérité
420
+ (`DrizzleService.ts:368`) — en développement **comme** en production. Ce n'est pas une sévérité
416
421
  gratuite : une infrastructure déclarée mais injoignable ne se répare pas en continuant. Un serveur qui
417
422
  démarrerait « vivant » avec ses stores morts accepterait des requêtes pour échouer plus tard, la cause
418
423
  noyée dans un avertissement. Le message d'erreur nomme le connecteur, le dialecte, la cible rédigée et
@@ -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:1504`).
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:1149`).
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`),
@@ -507,7 +513,7 @@ connexion**, donc leurs tables sont créées au moment où l'ORM s'ouvre.
507
513
  | Pièce | Rôle | Ancre |
508
514
  | ---------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------ |
509
515
  | `Drizzle` (le module) | valide la config, déclare le schéma framework, monte le data plane | `index.ts` du module |
510
- | `DrizzleService` | ouvre un ORM par connecteur au boot, ferme tout à l'arrêt | `connectAll()`, `DrizzleService.ts:186` |
516
+ | `DrizzleService` | ouvre un ORM par connecteur au boot, ferme tout à l'arrêt | `connectAll()`, `DrizzleService.ts:214` |
511
517
  | `DrizzleOrm` | la connexion : DDL dérivé, repositories, transactions, sonde | `DrizzleOrm.ts:214` |
512
518
  | `DrizzleRepository<T>` | le CRUD portable, les opérateurs riches, l'eager-load | `DrizzleRepository.ts:146` |
513
519
  | `DrizzleTransaction` | `BEGIN`/`COMMIT`/`ROLLBACK` pilotés à la main, sur les trois dialectes | `DrizzleTransaction.ts:70` |
@@ -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:420`). 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:259`). 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:1745`) : 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.
@@ -718,7 +730,7 @@ le framework résout ses stores avant que l'ORM ne soit connecté — et parce q
718
730
  l'arrêt** avant que les serveurs HTTP n'aient fini de vider leurs requêtes en vol. Handle absent =
719
731
  dégradation annoncée, pas un plantage : le `SessionStorage` rend une session vide et ignore les
720
732
  écritures (`#repo()`, `SessionStorage.ts:86`), le store d'idempotence laisse passer la mutation sans
721
- dédup (`begin()`, `DrizzleIdempotencyStore.ts:220`).
733
+ dédup (`begin()`, `DrizzleIdempotencyStore.ts:225`).
722
734
 
723
735
  **SQL n'a pas de TTL.** Contrairement à Redis, rien n'expire tout seul : chaque store expose un `gc()`
724
736
  applicatif qui supprime les lignes échues, déclenché par un minuteur hors du chemin chaud —
@@ -735,7 +747,7 @@ c'est ce qui interdit de conclure « nouvelle mutation » hors d'une réservatio
735
747
  en deux instructions au verdict non ambigu (`reserveIdempotencyKeyMysql()`, `queryKit.ts:152`).
736
748
 
737
749
  **Le journal d'audit** pagine par un curseur **composite auto-portant** `<horodatage>:<id>` sur un ordre
738
- total, plutôt que par un identifiant seul (`listPage()`, `DrizzleAuditStore.ts:169`). Deux gains : plus
750
+ total, plutôt que par un identifiant seul (`listPage()`, `DrizzleAuditStore.ts:175`). Deux gains : plus
739
751
  d'aller-retour pour résoudre le curseur, et une pagination qui ne rembobine pas à la première page si
740
752
  l'événement de référence a été purgé entre deux appels.
741
753
 
@@ -834,7 +846,7 @@ faire lui-même).
834
846
  Côté écrans : **Database**, **ORM (vue d'ensemble et par entité)** et **Stores** — ce dernier répond à
835
847
  la question « où sont écrites mes données ? » pour chaque brique.
836
848
 
837
- La sonde d'un connecteur s'adapte au dialecte (`probe()`, `DrizzleOrm.ts:1835`) :
849
+ La sonde d'un connecteur s'adapte au dialecte (`probe()`, `DrizzleOrm.ts:1873`) :
838
850
 
839
851
  - **SQLite** → `storage` : taille du fichier, mode de journal, pages libres (lus par `PRAGMA`) ;
840
852
  - **PostgreSQL / MySQL** → `pool` : taille, connexions libres, empruntées, en attente — **compteurs en
@@ -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:330`) 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.
@@ -426,7 +426,7 @@ Trois propriétés en découlent, et elles valent d'être nommées :
426
426
  - **Rien n'est appliqué deux fois.** L'historique (`nodefony_migrations`, `types.ts:23`) est écrit
427
427
  dans la même transaction que le DDL, là où le moteur le permet.
428
428
  - **Un pod en retard ne reçoit pas de trafic.** En `none` comme en `migrate`, l'état du schéma est
429
- publié à la sonde de disponibilité (`#publishReadiness()`, `DrizzleService.ts:527`) : `/readyz`
429
+ publié à la sonde de disponibilité (`#publishReadiness()`, `DrizzleService.ts:557`) : `/readyz`
430
430
  répond 503, l'orchestrateur sort l'exemplaire du répartiteur de charge, et l'ancien continue de
431
431
  servir. `/livez` n'est jamais touché — un schéma en retard n'est pas un processus malade, et le
432
432
  redémarrer ne réparerait rien. La vérification est rejouée toutes les 15 secondes : dès que le