@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.
Files changed (143) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +162 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +105 -0
  6. package/dist/nodefony/command/migrateShared.js +247 -0
  7. package/dist/nodefony/command/orm-generate.js +356 -0
  8. package/dist/nodefony/command/orm-migrate-baseline.js +208 -0
  9. package/dist/nodefony/command/orm-migrate-repair.js +114 -0
  10. package/dist/nodefony/command/orm-migrate-status.js +67 -0
  11. package/dist/nodefony/command/orm-migrate.js +141 -0
  12. package/dist/nodefony/command/orm-reset.js +166 -0
  13. package/dist/nodefony/config/config.js +107 -0
  14. package/dist/nodefony/config/defineModuleConfig.js +63 -0
  15. package/dist/nodefony/entity/auditEventEntity.js +93 -0
  16. package/dist/nodefony/entity/colKit.js +260 -0
  17. package/dist/nodefony/entity/idempotencyEntity.js +74 -0
  18. package/dist/nodefony/entity/sessionEntity.js +75 -0
  19. package/dist/nodefony/entity/tokenEntity.js +198 -0
  20. package/dist/nodefony/entity/totpSecretEntity.js +98 -0
  21. package/dist/nodefony/entity/userTable.js +141 -0
  22. package/dist/nodefony/entity/webAuthnCredentialEntity.js +114 -0
  23. package/dist/nodefony/entity/webhookEndpointEntity.js +106 -0
  24. package/dist/nodefony/interfaces/IDrizzleConfig.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/migrations-schema/mysql.js +48 -0
  27. package/dist/nodefony/migrations-schema/postgres.js +48 -0
  28. package/dist/nodefony/migrations-schema/sqlite.js +48 -0
  29. package/dist/nodefony/registerStores.js +218 -0
  30. package/dist/nodefony/service/DrizzleService.js +282 -0
  31. package/dist/nodefony/src/DrizzleAuditStore.js +203 -0
  32. package/dist/nodefony/src/DrizzleIdempotencyStore.js +278 -0
  33. package/dist/nodefony/src/DrizzleTokenStore.js +244 -0
  34. package/dist/nodefony/src/DrizzleTotpSecretStore.js +151 -0
  35. package/dist/nodefony/src/DrizzleUserRepository.js +217 -0
  36. package/dist/nodefony/src/DrizzleWebAuthnCredentialStore.js +159 -0
  37. package/dist/nodefony/src/DrizzleWebhookStore.js +169 -0
  38. package/dist/nodefony/src/SessionStorage.js +259 -0
  39. package/dist/nodefony/src/connectorTarget.js +59 -0
  40. package/dist/nodefony/src/likeSql.js +50 -0
  41. package/dist/nodefony/src/migrator/DrizzleMigrator.js +775 -0
  42. package/dist/nodefony/src/migrator/adopt.js +553 -0
  43. package/dist/nodefony/src/migrator/appSchema.js +414 -0
  44. package/dist/nodefony/src/migrator/catalog.js +76 -0
  45. package/dist/nodefony/src/migrator/destructive.js +213 -0
  46. package/dist/nodefony/src/migrator/divergence.js +84 -0
  47. package/dist/nodefony/src/migrator/drivers/index.js +39 -0
  48. package/dist/nodefony/src/migrator/drivers/mysqlDriver.js +147 -0
  49. package/dist/nodefony/src/migrator/drivers/postgresDriver.js +151 -0
  50. package/dist/nodefony/src/migrator/drivers/sqliteDriver.js +121 -0
  51. package/dist/nodefony/src/migrator/explain.js +565 -0
  52. package/dist/nodefony/src/migrator/hash.js +47 -0
  53. package/dist/nodefony/src/migrator/history.js +219 -0
  54. package/dist/nodefony/src/migrator/index.js +16 -0
  55. package/dist/nodefony/src/migrator/kit.js +296 -0
  56. package/dist/nodefony/src/migrator/name.js +68 -0
  57. package/dist/nodefony/src/migrator/paths.js +88 -0
  58. package/dist/nodefony/src/migrator/refusals.js +143 -0
  59. package/dist/nodefony/src/migrator/resolve.js +281 -0
  60. package/dist/nodefony/src/migrator/schemaDiff.js +86 -0
  61. package/dist/nodefony/src/migrator/sources.js +419 -0
  62. package/dist/nodefony/src/migrator/status.js +231 -0
  63. package/dist/nodefony/src/migrator/types.js +91 -0
  64. package/dist/nodefony/src/orm-core/DrizzleOrm.js +1154 -0
  65. package/dist/nodefony/src/orm-core/DrizzleRepository.js +610 -0
  66. package/dist/nodefony/src/orm-core/DrizzleTransaction.js +106 -0
  67. package/dist/nodefony/src/orm-core/index.js +4 -0
  68. package/dist/nodefony/src/queryKit.js +318 -0
  69. package/dist/nodefony/src/safeTarget.js +55 -0
  70. package/dist/types/index.d.ts +76 -0
  71. package/dist/types/nodefony/command/migrateShared.d.ts +137 -0
  72. package/dist/types/nodefony/command/orm-generate.d.ts +53 -0
  73. package/dist/types/nodefony/command/orm-migrate-baseline.d.ts +87 -0
  74. package/dist/types/nodefony/command/orm-migrate-repair.d.ts +66 -0
  75. package/dist/types/nodefony/command/orm-migrate-status.d.ts +38 -0
  76. package/dist/types/nodefony/command/orm-migrate.d.ts +65 -0
  77. package/dist/types/nodefony/command/orm-reset.d.ts +55 -0
  78. package/dist/types/nodefony/config/config.d.ts +110 -0
  79. package/dist/types/nodefony/config/defineModuleConfig.d.ts +24 -0
  80. package/dist/types/nodefony/entity/auditEventEntity.d.ts +56 -0
  81. package/dist/types/nodefony/entity/colKit.d.ts +130 -0
  82. package/dist/types/nodefony/entity/idempotencyEntity.d.ts +52 -0
  83. package/dist/types/nodefony/entity/sessionEntity.d.ts +50 -0
  84. package/dist/types/nodefony/entity/tokenEntity.d.ts +53 -0
  85. package/dist/types/nodefony/entity/totpSecretEntity.d.ts +61 -0
  86. package/dist/types/nodefony/entity/userTable.d.ts +65 -0
  87. package/dist/types/nodefony/entity/webAuthnCredentialEntity.d.ts +57 -0
  88. package/dist/types/nodefony/entity/webhookEndpointEntity.d.ts +58 -0
  89. package/dist/types/nodefony/interfaces/IDrizzleConfig.d.ts +17 -0
  90. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  91. package/dist/types/nodefony/migrations-schema/mysql.d.ts +9 -0
  92. package/dist/types/nodefony/migrations-schema/postgres.d.ts +9 -0
  93. package/dist/types/nodefony/migrations-schema/sqlite.d.ts +9 -0
  94. package/dist/types/nodefony/registerStores.d.ts +52 -0
  95. package/dist/types/nodefony/service/DrizzleService.d.ts +27 -0
  96. package/dist/types/nodefony/src/DrizzleAuditStore.d.ts +65 -0
  97. package/dist/types/nodefony/src/DrizzleIdempotencyStore.d.ts +129 -0
  98. package/dist/types/nodefony/src/DrizzleTokenStore.d.ts +146 -0
  99. package/dist/types/nodefony/src/DrizzleTotpSecretStore.d.ts +60 -0
  100. package/dist/types/nodefony/src/DrizzleUserRepository.d.ts +67 -0
  101. package/dist/types/nodefony/src/DrizzleWebAuthnCredentialStore.d.ts +62 -0
  102. package/dist/types/nodefony/src/DrizzleWebhookStore.d.ts +79 -0
  103. package/dist/types/nodefony/src/SessionStorage.d.ts +72 -0
  104. package/dist/types/nodefony/src/connectorTarget.d.ts +48 -0
  105. package/dist/types/nodefony/src/likeSql.d.ts +29 -0
  106. package/dist/types/nodefony/src/migrator/DrizzleMigrator.d.ts +94 -0
  107. package/dist/types/nodefony/src/migrator/adopt.d.ts +280 -0
  108. package/dist/types/nodefony/src/migrator/appSchema.d.ts +223 -0
  109. package/dist/types/nodefony/src/migrator/catalog.d.ts +100 -0
  110. package/dist/types/nodefony/src/migrator/destructive.d.ts +123 -0
  111. package/dist/types/nodefony/src/migrator/divergence.d.ts +61 -0
  112. package/dist/types/nodefony/src/migrator/drivers/index.d.ts +26 -0
  113. package/dist/types/nodefony/src/migrator/drivers/mysqlDriver.d.ts +83 -0
  114. package/dist/types/nodefony/src/migrator/drivers/postgresDriver.d.ts +81 -0
  115. package/dist/types/nodefony/src/migrator/drivers/sqliteDriver.d.ts +53 -0
  116. package/dist/types/nodefony/src/migrator/explain.d.ts +424 -0
  117. package/dist/types/nodefony/src/migrator/hash.d.ts +39 -0
  118. package/dist/types/nodefony/src/migrator/history.d.ts +139 -0
  119. package/dist/types/nodefony/src/migrator/index.d.ts +20 -0
  120. package/dist/types/nodefony/src/migrator/kit.d.ts +141 -0
  121. package/dist/types/nodefony/src/migrator/name.d.ts +52 -0
  122. package/dist/types/nodefony/src/migrator/paths.d.ts +54 -0
  123. package/dist/types/nodefony/src/migrator/refusals.d.ts +170 -0
  124. package/dist/types/nodefony/src/migrator/resolve.d.ts +201 -0
  125. package/dist/types/nodefony/src/migrator/schemaDiff.d.ts +112 -0
  126. package/dist/types/nodefony/src/migrator/sources.d.ts +119 -0
  127. package/dist/types/nodefony/src/migrator/status.d.ts +132 -0
  128. package/dist/types/nodefony/src/migrator/types.d.ts +273 -0
  129. package/dist/types/nodefony/src/orm-core/DrizzleOrm.d.ts +241 -0
  130. package/dist/types/nodefony/src/orm-core/DrizzleRepository.d.ts +98 -0
  131. package/dist/types/nodefony/src/orm-core/DrizzleTransaction.d.ts +71 -0
  132. package/dist/types/nodefony/src/orm-core/index.d.ts +11 -0
  133. package/dist/types/nodefony/src/queryKit.d.ts +136 -0
  134. package/dist/types/nodefony/src/safeTarget.d.ts +42 -0
  135. package/docs/index.md +954 -0
  136. package/docs/migrations.md +691 -0
  137. package/migrations/mysql/0000_framework_init.sql +137 -0
  138. package/migrations/mysql/meta/_journal.json +13 -0
  139. package/migrations/postgres/0000_framework_init.sql +128 -0
  140. package/migrations/postgres/meta/_journal.json +13 -0
  141. package/migrations/sqlite/0000_framework_init.sql +127 -0
  142. package/migrations/sqlite/meta/_journal.json +13 -0
  143. package/package.json +126 -0
@@ -0,0 +1,203 @@
1
+ import { execTable } from "./orm-core/DrizzleRepository.js";
2
+ import { auditEventTable, createAuditEventTable } from "../entity/auditEventEntity.js";
3
+ import { assertPageQuery } from "nodefony";
4
+ import { and, count, desc, eq, gte, lt, lte, or } from "drizzle-orm";
5
+ //#region nodefony/src/DrizzleAuditStore.ts
6
+ /** Rétention par défaut d'un événement : 365 jours (aligné `MemoryAuditStore`). */
7
+ const DEFAULT_RETENTION_MS = 31536e6;
8
+ /** Taille de page par défaut / plafond (alignés `MemoryAuditStore`). */
9
+ const DEFAULT_LIMIT = 100;
10
+ const MAX_LIMIT = 500;
11
+ /** Séparateur du curseur composite `<ts>:<id>` (cf `#parseCursor`). */
12
+ const CURSOR_SEPARATOR = ":";
13
+ /**
14
+ * Journal d'audit **Drizzle** (driver `better-sqlite3` en test, Postgres/MySQL en
15
+ * prod) — implémentation SQL d'{@link IAuditStore} (append-only, tamper-evident)
16
+ * pour la **rétention longue** et le **partage cross-pod**, là où le store mémoire
17
+ * par défaut reste affine à un pod et volatile.
18
+ *
19
+ * **Append-only** : `append` est la seule écriture (INSERT) ; aucune mutation ni
20
+ * suppression ciblée d'un événement — seul {@link DrizzleAuditStore.gc} (rétention)
21
+ * retire des lignes. L'immuabilité EST la garantie d'audit.
22
+ *
23
+ * **Pagination curseur EXACTE (`listPage`)** — via le query builder Drizzle
24
+ * (dialect-agnostique), l'ordre total est `(ts DESC, id DESC)` : `ts` porte l'ordre
25
+ * chronologique, `id` casse les collisions à la milliseconde (rafales de login). Le
26
+ * curseur transporte **les deux** (`<ts>:<id>`) et se compare en **composite**
27
+ * `(ts, id) < (cursorTs, cursorId)` — non exprimable en critère `IRepository`
28
+ * AND-only, d'où la trappe native (ADR-0003, comme `DrizzleIdempotencyStore`). Une
29
+ * ligne de garde (`limit + 1`) détermine `hasNext` sans page vide parasite.
30
+ *
31
+ * **Résolution LAZY + dégradation gracieuse** (calqué sur les stores frères) : le
32
+ * handle Drizzle est résolu à CHAQUE appel, pas capturé à la construction — l'ordre
33
+ * de boot n'est pas garanti (l'app câble à `onKernelBoot`, l'ORM peut n'être pas
34
+ * encore connecté) et l'ORM se déconnecte au shutdown avant le drain des serveurs.
35
+ * Si le handle est `null` : `append` est un no-op **best-effort** (l'audit ne
36
+ * bloque ni ne fait échouer le flux métier — un événement au boot/shutdown est
37
+ * perdu plutôt que de crasher un login), `listPage` rend une page vide et `gc` rend 0.
38
+ *
39
+ * Horloge injectable (`now`) pour des tests déterministes.
40
+ */
41
+ var DrizzleAuditStore = class DrizzleAuditStore {
42
+ #resolveDb;
43
+ #table;
44
+ /** Colonnes par nom logique, vue canonique (les specs colKit partagent les
45
+ * NOMS entre dialectes → le query builder reste dialecte-agnostique). */
46
+ #c;
47
+ #now;
48
+ #retentionMs;
49
+ #location;
50
+ /**
51
+ * @param resolveDb - résolveur **lazy** du handle Drizzle (`null` = ORM non
52
+ * connecté → dégradation gracieuse).
53
+ * @param now - horloge (epoch ms) injectable pour des tests déterministes.
54
+ * @param retentionMs - fenêtre de rétention (ms) avant purge par `gc`.
55
+ * @param table - variante de table à utiliser (dialecte). Défaut = SQLite.
56
+ * @param location - emplacement physique de la base (fichier SQLite) pour Studio
57
+ * ({@link DrizzleOrm.location}) ; `undefined` pour un backend réseau/`:memory:`.
58
+ */
59
+ constructor(resolveDb, now = Date.now, retentionMs = DEFAULT_RETENTION_MS, table = auditEventTable, location) {
60
+ this.#resolveDb = resolveDb;
61
+ this.#table = table;
62
+ this.#c = execTable(table);
63
+ this.#now = now;
64
+ this.#retentionMs = retentionMs;
65
+ this.#location = location;
66
+ }
67
+ /**
68
+ * Emplacement physique de la base (fichier SQLite) pour l'écran Studio « Stores »
69
+ * — lu par `readStoreLocation`. `undefined` = backend réseau ou `:memory:`.
70
+ */
71
+ get location() {
72
+ return this.#location;
73
+ }
74
+ /**
75
+ * Construit le store depuis un {@link DrizzleOrm}. Le handle est résolu **lazy**
76
+ * (gardé par `isConnected()` → `null` tant que l'ORM n'est pas/plus connecté).
77
+ * L'entité (`registerAuditEntities`) doit avoir été enregistrée **avant**
78
+ * `orm.connect()` (la table est créée au connect) — sur la **variante de table
79
+ * du dialecte de l'ORM** (S3 multi-dialecte).
80
+ *
81
+ * @param orm - ORM Drizzle hébergeant la table `audit_event`.
82
+ * @param now - horloge injectable (tests).
83
+ * @param retentionMs - fenêtre de rétention (ms).
84
+ */
85
+ static from(orm, now, retentionMs) {
86
+ return new DrizzleAuditStore(() => orm.isConnected() ? orm.getNativeConnection() : null, now, retentionMs, createAuditEventTable(orm.dialect), orm.location);
87
+ }
88
+ async append(event) {
89
+ const db = this.#resolveDb();
90
+ if (!db) return;
91
+ await db.insert(execTable(this.#table)).values({
92
+ id: event.id,
93
+ ts: event.ts,
94
+ category: event.category,
95
+ action: event.action,
96
+ outcome: event.outcome,
97
+ actor: event.actor ?? null,
98
+ resource: event.resource ?? null,
99
+ reason: event.reason ?? null,
100
+ ip: event.ip ?? null,
101
+ userAgent: event.userAgent ?? null,
102
+ requestId: event.requestId ?? null,
103
+ flags: event.flags ?? null,
104
+ metadata: event.metadata ?? null
105
+ });
106
+ }
107
+ async listPage(query) {
108
+ assertPageQuery(query, "cursor");
109
+ const limit = Math.min(Math.max(1, query.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
110
+ const db = this.#resolveDb();
111
+ if (!db) return {
112
+ items: [],
113
+ limit,
114
+ hasNext: false,
115
+ nextCursor: null,
116
+ total: 0
117
+ };
118
+ const table = execTable(this.#table);
119
+ const c = this.#c;
120
+ const filterWhere = this.#buildFilter(query);
121
+ let total;
122
+ if (query.withTotal !== false) {
123
+ const totalRows = await db.select({ n: count() }).from(table).where(filterWhere);
124
+ total = Number(totalRows[0]?.n ?? 0);
125
+ }
126
+ let where = filterWhere;
127
+ const cursor = this.#parseCursor(query.cursor);
128
+ if (cursor) {
129
+ const cursorCond = or(lt(c.ts, cursor.ts), and(eq(c.ts, cursor.ts), lt(c.id, cursor.id)));
130
+ where = filterWhere ? and(filterWhere, cursorCond) : cursorCond;
131
+ }
132
+ const rows = await db.select().from(table).where(where).orderBy(desc(c.ts), desc(c.id)).limit(limit + 1);
133
+ const hasNext = rows.length > limit;
134
+ const pageRows = hasNext ? rows.slice(0, limit) : rows;
135
+ const items = pageRows.map((row) => this.#toEvent(row));
136
+ const last = pageRows[pageRows.length - 1];
137
+ return {
138
+ items,
139
+ limit,
140
+ hasNext,
141
+ nextCursor: hasNext && last ? `${last.ts}${CURSOR_SEPARATOR}${last.id}` : null,
142
+ ...total !== void 0 ? { total } : {}
143
+ };
144
+ }
145
+ /**
146
+ * Décode le curseur composite `<ts>:<id>` (format privé au store, comme son
147
+ * pendant mémoire). Jeton absent ou malformé → `null` : la lecture repart de
148
+ * la page la plus récente plutôt que d'échouer sur une consultation.
149
+ */
150
+ #parseCursor(cursor) {
151
+ if (cursor === void 0) return null;
152
+ const sep = cursor.indexOf(CURSOR_SEPARATOR);
153
+ if (sep <= 0) return null;
154
+ const ts = Number(cursor.slice(0, sep));
155
+ return Number.isFinite(ts) ? {
156
+ ts,
157
+ id: cursor.slice(sep + 1)
158
+ } : null;
159
+ }
160
+ async gc(now = this.#now()) {
161
+ const db = this.#resolveDb();
162
+ if (!db) return 0;
163
+ const threshold = now - this.#retentionMs;
164
+ const result = await db.delete(execTable(this.#table)).where(lt(this.#c.ts, threshold));
165
+ if (Array.isArray(result)) return result[0]?.affectedRows ?? 0;
166
+ const r = result;
167
+ return r.changes ?? r.rowCount ?? 0;
168
+ }
169
+ /** Compose la clause `WHERE` des filtres AND ; `undefined` si aucun (= tout). */
170
+ #buildFilter(filter) {
171
+ const c = this.#c;
172
+ const clauses = [];
173
+ if (filter.category !== void 0) clauses.push(eq(c.category, filter.category));
174
+ if (filter.outcome !== void 0) clauses.push(eq(c.outcome, filter.outcome));
175
+ if (filter.actor !== void 0) clauses.push(eq(c.actor, filter.actor));
176
+ if (filter.action !== void 0) clauses.push(eq(c.action, filter.action));
177
+ if (filter.requestId !== void 0) clauses.push(eq(c.requestId, filter.requestId));
178
+ if (filter.since !== void 0) clauses.push(gte(c.ts, filter.since));
179
+ if (filter.until !== void 0) clauses.push(lte(c.ts, filter.until));
180
+ return clauses.length > 0 ? and(...clauses) : void 0;
181
+ }
182
+ /** Mappe une ligne SQL vers un événement (NULL → optionnel absent). */
183
+ #toEvent(row) {
184
+ const event = {
185
+ id: row.id,
186
+ ts: row.ts,
187
+ category: row.category,
188
+ action: row.action,
189
+ outcome: row.outcome,
190
+ actor: row.actor,
191
+ resource: row.resource,
192
+ reason: row.reason,
193
+ ip: row.ip,
194
+ userAgent: row.userAgent,
195
+ requestId: row.requestId
196
+ };
197
+ if (row.flags) event.flags = row.flags;
198
+ if (row.metadata) event.metadata = row.metadata;
199
+ return event;
200
+ }
201
+ };
202
+ //#endregion
203
+ export { DrizzleAuditStore };
@@ -0,0 +1,278 @@
1
+ import { execTable } from "./orm-core/DrizzleRepository.js";
2
+ import { createIdempotencyTable, idempotencyKeyTable } from "../entity/idempotencyEntity.js";
3
+ import { reserveIdempotencyKeyMysql } from "./queryKit.js";
4
+ import { assertPageQuery } from "nodefony";
5
+ import { and, asc, count, eq, gt, is, like, lt, lte } from "drizzle-orm";
6
+ import { MySqlTable } from "drizzle-orm/mysql-core";
7
+ //#region nodefony/src/DrizzleIdempotencyStore.ts
8
+ /**
9
+ * Lignes affectées d'une mutation builder, normalisées par driver :
10
+ * better-sqlite3 `{changes}` / pg `{rowCount}` / mysql2 tuple
11
+ * `[ResultSetHeader{affectedRows}, fields]`.
12
+ */
13
+ function affectedOf(result) {
14
+ if (Array.isArray(result)) return result[0]?.affectedRows ?? 0;
15
+ const r = result;
16
+ return r.changes ?? r.rowCount ?? 0;
17
+ }
18
+ /** Bail par défaut d'une entrée *in-flight* : 60 s (au-delà = exécution abandonnée). */
19
+ const DEFAULT_LEASE_MS = 6e4;
20
+ /** Rétention par défaut d'une réponse mémorisée : 10 min (rejeu plausible). */
21
+ const DEFAULT_TTL_MS = 6e5;
22
+ /**
23
+ * Store d'idempotence **Drizzle** (driver `better-sqlite3` en test, Postgres/MySQL
24
+ * en prod) — implémentation SQL d'{@link IIdempotencyStore} (contrat au CORE) pour
25
+ * dédoublonner les mutations rejouées PARTAGÉ cross-pod, là où le store mémoire par
26
+ * défaut reste affine à un pod.
27
+ *
28
+ * **Pourquoi SQL plutôt que Redis** : un cluster qui possède déjà une base
29
+ * (Postgres) mais pas de Redis obtient la dédup cross-pod sans nouvelle infra.
30
+ *
31
+ * **Réservation atomique (`begin`)** — clé de voûte. Un `INSERT … ON CONFLICT(key)
32
+ * DO UPDATE SET … WHERE expiresAt < now RETURNING` = l'équivalent SQL du `SET … NX
33
+ * PX` Redis, en **une seule instruction atomique** côté serveur :
34
+ * - clé absente → l'`INSERT` passe → 1 ligne retournée → `fresh` ;
35
+ * - clé présente mais **morte** (bail/rétention expirés) → le `DO UPDATE … WHERE
36
+ * expirée` la **vole** atomiquement → 1 ligne retournée → `fresh` ;
37
+ * - clé présente et **vivante** → le `WHERE` du `DO UPDATE` échoue → 0 ligne
38
+ * retournée → on lit l'état (`in-flight` / `replayed` / `mismatch`).
39
+ *
40
+ * Cette atomicité au niveau de l'INSTRUCTION est ce qui rend la dédup correcte
41
+ * sous concurrence inter-pods (deux `begin` simultanés sur deux pods : un seul
42
+ * gagne la réservation). Le store ne renvoie JAMAIS `fresh` sur le chemin de
43
+ * contention → anti double-effet garanti (l'invariant capital d'un store
44
+ * d'idempotence).
45
+ *
46
+ * **Pas de TTL natif** (≠ Redis `PX`) → un {@link DrizzleIdempotencyStore.gc}
47
+ * applicatif purge les entrées expirées. À mutualiser avec la maintenance du store
48
+ * de session (chantier « GC moderne »).
49
+ *
50
+ * **Empreinte préservée à la complétion** : `complete()` ne touche pas la colonne
51
+ * `fingerprint` (UPDATE ciblé) → un rejeu de la clé avec un AUTRE payload après
52
+ * complétion est toujours détecté (`mismatch` 422, draft §2.7).
53
+ *
54
+ * ⚠️ **SQLite = banc de test de la sémantique** : un fichier SQLite est
55
+ * mono-machine (lock d'écriture) → aucun intérêt multi-pod. La cible RÉELLE est
56
+ * **Postgres/MySQL** (changement de driver). La preuve cross-pod réelle passe par
57
+ * un e2e Postgres (≠ test SQLite, qui valide la sémantique séquentielle).
58
+ *
59
+ * **Résolution LAZY + dégradation gracieuse** (calqué sur `RedisIdempotencyStore`,
60
+ * le frère idempotence) : le store ne capture PAS le handle Drizzle à la
61
+ * construction mais le résout à CHAQUE appel — l'ordre de boot n'est pas garanti
62
+ * (le framework résout ce store à `onKernelBoot`, quand l'ORM peut ne pas être
63
+ * encore connecté), et l'ORM se déconnecte au shutdown avant le drain des
64
+ * serveurs (cf le gotcha `SessionStorage`). Si le handle est `null` (ORM non
65
+ * connecté), `begin` renvoie `fresh` (la mutation s'exécute SANS dédup) et
66
+ * `complete`/`abort`/`gc` sont des no-op — l'idempotence est temporairement
67
+ * inactive plutôt que de crasher une mutation en vol. Trade-off identique à Redis
68
+ * (un rejeu pendant la fenêtre peut ré-exécuter) ; en pratique la base
69
+ * d'idempotence = la base applicative → « connectée » est vrai sur tout le service.
70
+ *
71
+ * **Câblage** : la classe reste PURE (`import type` du contrat core), mais le
72
+ * module drizzle s'enregistre LUI-MÊME au boot — `registerStores.ts:311-316` pose
73
+ * l'entité (`registerIdempotencyEntities`) puis la fabrique
74
+ * (`registerIdempotencyStore("drizzle", …)`, registre `@nodefony/framework`), sans
75
+ * rien demander à l'application. Celle-ci choisit seulement le store à employer
76
+ * (`idempotency.store`). L'enregistrement est idempotent : une fabrique déjà
77
+ * posée n'est pas écrasée, donc une app peut fournir la sienne avant le boot.
78
+ */
79
+ var DrizzleIdempotencyStore = class DrizzleIdempotencyStore {
80
+ #resolveDb;
81
+ #table;
82
+ /** Colonnes par nom logique, vue canonique (les specs colKit partagent les
83
+ * NOMS entre dialectes → les builders restent dialecte-agnostiques). */
84
+ #c;
85
+ /** `true` si la table injectée est la variante MySQL → `begin` route sur la
86
+ * réservation `ON DUPLICATE KEY UPDATE` du queryKit (pas de RETURNING). */
87
+ #mysql;
88
+ #now;
89
+ #leaseMs;
90
+ #ttlMs;
91
+ #resolveLocation;
92
+ /** Compteur LOCAL best-effort des réservations faites par CE pod (cf {@link size}). */
93
+ #pending = 0;
94
+ /**
95
+ * @param resolveDb - résolveur **lazy** du handle Drizzle (`null` = ORM non
96
+ * connecté → dégradation gracieuse). Lazy car l'ordre de boot/shutdown n'est
97
+ * pas garanti à la construction.
98
+ * @param now - horloge (epoch ms) injectable pour des tests déterministes.
99
+ * @param leaseMs - bail d'une entrée *in-flight* (ms).
100
+ * @param ttlMs - rétention d'une réponse mémorisée (ms).
101
+ * @param table - variante de table à utiliser (dialecte). Défaut = variante
102
+ * SQLite ; `from()` injecte la variante du dialecte de l'ORM.
103
+ * @param resolveLocation - résolveur **lazy** de l'emplacement physique de la
104
+ * base ({@link DrizzleOrm.location}) pour Studio. Lazy (comme `resolveDb`) car
105
+ * le store est fabriqué AVANT le connect de l'ORM → l'emplacement n'est lisible
106
+ * qu'une fois l'ORM enregistré (lu au `onReady`, pas à la construction).
107
+ */
108
+ constructor(resolveDb, now = Date.now, leaseMs = DEFAULT_LEASE_MS, ttlMs = DEFAULT_TTL_MS, table = idempotencyKeyTable, resolveLocation) {
109
+ this.#resolveDb = resolveDb;
110
+ this.#table = table;
111
+ this.#c = execTable(table);
112
+ this.#mysql = is(table, MySqlTable);
113
+ this.#now = now;
114
+ this.#leaseMs = leaseMs;
115
+ this.#ttlMs = ttlMs;
116
+ this.#resolveLocation = resolveLocation;
117
+ }
118
+ /**
119
+ * Emplacement physique de la base (fichier SQLite) pour l'écran Studio « Stores »
120
+ * — lu par `readStoreLocation`. Résolu **lazy** (l'ORM n'existe pas à la
121
+ * construction) : `undefined` tant que l'ORM n'est pas enregistré, en `:memory:`,
122
+ * ou sur un backend réseau (pg/mysql → voir l'infra déclarée).
123
+ */
124
+ get location() {
125
+ return this.#resolveLocation?.();
126
+ }
127
+ /**
128
+ * Construit le store depuis un {@link DrizzleOrm}. Le handle est résolu **lazy**
129
+ * (gardé par `isConnected()` → `null` tant que l'ORM n'est pas/plus connecté).
130
+ * L'entité (`registerIdempotencyEntities`) doit avoir été enregistrée **avant**
131
+ * `orm.connect()` (la table est créée au connect).
132
+ *
133
+ * @param orm - ORM Drizzle hébergeant la table `idempotency_key` (dialecte
134
+ * sélectionne la variante de table — `orm.dialect`).
135
+ * @param now - horloge injectable (tests).
136
+ * @param leaseMs - bail *in-flight* (ms).
137
+ * @param ttlMs - rétention d'une réponse mémorisée (ms).
138
+ */
139
+ static from(orm, now, leaseMs, ttlMs) {
140
+ return new DrizzleIdempotencyStore(() => orm.isConnected() ? orm.getNativeConnection() : null, now, leaseMs, ttlMs, createIdempotencyTable(orm.dialect), () => orm.location);
141
+ }
142
+ /**
143
+ * Approximation **per-pod, best-effort** : compteur local des réservations
144
+ * faites par CE pod (incrémenté au `fresh`, décrémenté au `complete`/`abort`),
145
+ * non décrémenté si le bail expire sans complétion, et désaligné cross-pod. La
146
+ * vérité cluster passe par un `COUNT(*)`, jamais ce getter (sync). Borné à ≥ 0.
147
+ */
148
+ get size() {
149
+ return this.#pending < 0 ? 0 : this.#pending;
150
+ }
151
+ async begin(key, fingerprint) {
152
+ const db = this.#resolveDb();
153
+ if (!db) return { state: "fresh" };
154
+ const now = this.#now();
155
+ const leaseExpiresAt = now + this.#leaseMs;
156
+ let reserved;
157
+ if (this.#mysql) reserved = await reserveIdempotencyKeyMysql(db, {
158
+ key,
159
+ fingerprint,
160
+ now,
161
+ leaseExpiresAt
162
+ });
163
+ else reserved = (await db.insert(execTable(this.#table)).values({
164
+ key,
165
+ fingerprint,
166
+ state: "if",
167
+ response: null,
168
+ expiresAt: leaseExpiresAt
169
+ }).onConflictDoUpdate({
170
+ target: this.#c.key,
171
+ set: {
172
+ fingerprint,
173
+ state: "if",
174
+ response: null,
175
+ expiresAt: leaseExpiresAt
176
+ },
177
+ setWhere: lt(this.#c.expiresAt, now)
178
+ }).returning({ key: this.#c.key })).length > 0;
179
+ if (reserved) {
180
+ this.#pending++;
181
+ return { state: "fresh" };
182
+ }
183
+ const existing = (await db.select().from(execTable(this.#table)).where(eq(this.#c.key, key)))[0];
184
+ if (existing === void 0) return { state: "in-flight" };
185
+ if (existing.fingerprint !== fingerprint) return { state: "mismatch" };
186
+ if (existing.state === "done" && existing.response !== null) return {
187
+ state: "replayed",
188
+ response: existing.response
189
+ };
190
+ return { state: "in-flight" };
191
+ }
192
+ async complete(key, response) {
193
+ const db = this.#resolveDb();
194
+ if (!db) return;
195
+ if (affectedOf(await db.update(execTable(this.#table)).set({
196
+ state: "done",
197
+ response,
198
+ expiresAt: this.#now() + this.#ttlMs
199
+ }).where(and(eq(this.#c.key, key), eq(this.#c.state, "if")))) > 0) this.#dec();
200
+ }
201
+ async abort(key) {
202
+ const db = this.#resolveDb();
203
+ if (!db) return;
204
+ if (affectedOf(await db.delete(execTable(this.#table)).where(and(eq(this.#c.key, key), eq(this.#c.state, "if")))) > 0) this.#dec();
205
+ }
206
+ /**
207
+ * Purge les entrées mortes (`expiresAt <= now`) — supplée l'absence de TTL natif
208
+ * SQL (≠ Redis `PX`). À déclencher périodiquement (timer de maintenance, à
209
+ * mutualiser avec le GC du store de session).
210
+ *
211
+ * @param now - horloge de purge (défaut : horloge injectée).
212
+ * @returns le nombre d'entrées supprimées.
213
+ */
214
+ async gc(now = this.#now()) {
215
+ const db = this.#resolveDb();
216
+ if (!db) return 0;
217
+ return affectedOf(await db.delete(execTable(this.#table)).where(lte(this.#c.expiresAt, now)));
218
+ }
219
+ /**
220
+ * {@inheritDoc IIdempotencyStore.listPage}
221
+ *
222
+ * Query builder dialect-agnostique (comme `gc`) : `LIMIT/OFFSET` + `COUNT`
223
+ * filtré. On ne SELECTe QUE les colonnes exposées — la réponse mémorisée
224
+ * (`response`) ne quitte jamais la base par ce chemin, quelle que soit la
225
+ * taille de la page.
226
+ *
227
+ * Les entrées expirées sont exclues (`expiresAt > now`) : le GC applicatif
228
+ * passe plus tard, mais une clé échue n'est déjà plus opposable.
229
+ */
230
+ async listPage(query) {
231
+ assertPageQuery(query, "offset");
232
+ const limit = Math.max(1, Math.floor(query.limit));
233
+ const offset = Math.max(0, Math.floor(query.offset ?? 0));
234
+ const db = this.#resolveDb();
235
+ if (!db) return {
236
+ items: [],
237
+ total: 0,
238
+ limit,
239
+ offset,
240
+ hasNext: false
241
+ };
242
+ const table = execTable(this.#table);
243
+ const conds = [gt(this.#c.expiresAt, this.#now())];
244
+ if (query.state !== void 0) conds.push(eq(this.#c.state, query.state === "in-flight" ? "if" : "done"));
245
+ if (query.q !== void 0 && query.q.length > 0) conds.push(like(this.#c.key, `${query.q}%`));
246
+ const where = conds.length === 1 ? conds[0] : and(...conds);
247
+ const rows = await db.select({
248
+ key: this.#c.key,
249
+ state: this.#c.state,
250
+ expiresAt: this.#c.expiresAt
251
+ }).from(table).where(where).orderBy(asc(this.#c.expiresAt), asc(this.#c.key)).limit(limit + 1).offset(offset);
252
+ const hasNext = rows.length > limit;
253
+ const page = hasNext ? rows.slice(0, limit) : rows;
254
+ let total;
255
+ if (query.withTotal !== false) {
256
+ const counted = await db.select({ cnt: count() }).from(table).where(where);
257
+ total = Number(counted[0]?.cnt ?? 0);
258
+ }
259
+ return {
260
+ items: page.map((row) => ({
261
+ key: row.key,
262
+ state: row.state === "if" ? "in-flight" : "done",
263
+ expiresAtMs: Number(row.expiresAt),
264
+ hasResponse: row.state !== "if"
265
+ })),
266
+ total,
267
+ limit,
268
+ offset,
269
+ hasNext
270
+ };
271
+ }
272
+ /** Décrémente le compteur local borné à 0. */
273
+ #dec() {
274
+ if (this.#pending > 0) this.#pending--;
275
+ }
276
+ };
277
+ //#endregion
278
+ export { DrizzleIdempotencyStore };