@nodefony/mongoose 10.0.0-alpha.8 → 10.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +13 -7
- package/dist/nodefony/config/defineModuleConfig.js +4 -3
- package/dist/nodefony/entity/auditEventEntity.js +124 -0
- package/dist/nodefony/entity/idempotencyEntity.js +96 -0
- package/dist/nodefony/entity/sessionEntity.js +1 -1
- package/dist/nodefony/entity/totpSecretEntity.js +103 -0
- package/dist/nodefony/registerStores.js +42 -5
- package/dist/nodefony/service/MongooseService.js +15 -5
- package/dist/nodefony/src/MongooseAuditStore.js +215 -0
- package/dist/nodefony/src/MongooseIdempotencyStore.js +264 -0
- package/dist/nodefony/src/MongooseTokenStore.js +60 -17
- package/dist/nodefony/src/MongooseTotpSecretStore.js +151 -0
- package/dist/nodefony/src/MongooseUserRepository.js +1 -1
- package/dist/nodefony/src/MongooseWebAuthnCredentialStore.js +13 -1
- package/dist/nodefony/src/MongooseWebhookStore.js +14 -2
- package/dist/nodefony/src/SessionStorage.js +8 -0
- package/dist/nodefony/src/orm-core/MongooseOrm.js +68 -22
- package/dist/nodefony/src/orm-core/MongooseRepository.js +147 -25
- package/dist/nodefony/src/orm-core/MongooseTransaction.js +19 -6
- package/dist/nodefony/src/writeCount.js +17 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/types/nodefony/entity/auditEventEntity.d.ts +76 -0
- package/dist/types/nodefony/entity/idempotencyEntity.d.ts +75 -0
- package/dist/types/nodefony/entity/totpSecretEntity.d.ts +75 -0
- package/dist/types/nodefony/registerStores.d.ts +6 -3
- package/dist/types/nodefony/service/MongooseService.d.ts +2 -2
- package/dist/types/nodefony/src/MongooseAuditStore.d.ts +70 -0
- package/dist/types/nodefony/src/MongooseIdempotencyStore.d.ts +121 -0
- package/dist/types/nodefony/src/MongooseTokenStore.d.ts +8 -1
- package/dist/types/nodefony/src/MongooseTotpSecretStore.d.ts +63 -0
- package/dist/types/nodefony/src/MongooseUserRepository.d.ts +1 -1
- package/dist/types/nodefony/src/MongooseWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/src/MongooseWebhookStore.d.ts +6 -0
- package/dist/types/nodefony/src/SessionStorage.d.ts +6 -0
- package/dist/types/nodefony/src/orm-core/MongooseOrm.d.ts +10 -3
- package/dist/types/nodefony/src/orm-core/MongooseRepository.d.ts +22 -1
- package/dist/types/nodefony/src/orm-core/MongooseTransaction.d.ts +12 -4
- package/dist/types/nodefony/src/writeCount.d.ts +12 -0
- package/docs/configuration.md +27 -27
- package/docs/index.md +133 -82
- package/package.json +23 -19
|
@@ -1,7 +1,21 @@
|
|
|
1
|
+
import { MONGO_ORDER_ALIASES } from "../mongoOrder.js";
|
|
2
|
+
import { writeCount } from "../writeCount.js";
|
|
1
3
|
import { RequestContext, redactSecrets } from "nodefony";
|
|
2
|
-
import { UnknownCriteriaField, assertOrderOption, isFieldOperators, isUpdateOperators, likePatternToRegExp, queryFlowMonitor } from "@nodefony/orm-core";
|
|
4
|
+
import { ReferencedEntityError, UnknownCriteriaField, assertOrderOption, isFieldOperators, isUpdateOperators, likePatternToRegExp, queryFlowMonitor } from "@nodefony/orm-core";
|
|
3
5
|
//#region nodefony/src/orm-core/MongooseRepository.ts
|
|
4
6
|
/**
|
|
7
|
+
* Clé Mongo d'un champ de TRI — `id` public → `_id` au repos.
|
|
8
|
+
*
|
|
9
|
+
* Mongo ne se plaint pas d'un tri sur un champ absent : il rend les documents
|
|
10
|
+
* dans un ordre arbitraire. `id` n'est qu'un virtuel de lecture ; sans cette
|
|
11
|
+
* traduction, `order: [["id", "DESC"]]` — le départage du tri par défaut de
|
|
12
|
+
* tout CRUD généré — serait silencieusement inerte ici et correct en SQL. Même
|
|
13
|
+
* table que les stores Mongo ({@link MONGO_ORDER_ALIASES}).
|
|
14
|
+
*/
|
|
15
|
+
function sortKey(field) {
|
|
16
|
+
return MONGO_ORDER_ALIASES[field] ?? field;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
5
19
|
* Repository portable (contrat {@link IRepository}) au-dessus d'un modèle Mongoose.
|
|
6
20
|
*
|
|
7
21
|
* Démontre la portabilité du contrat sur un **store documentaire hétérogène** :
|
|
@@ -19,15 +33,29 @@ var MongooseRepository = class MongooseRepository {
|
|
|
19
33
|
#session;
|
|
20
34
|
/** Connecteur ORM (clé du registre) — tag des métriques de flux. */
|
|
21
35
|
#connector;
|
|
36
|
+
/** Champs qui référencent cette entité — `null` si personne ne la vise (cas courant, coût nul). */
|
|
37
|
+
#referrers;
|
|
22
38
|
/**
|
|
23
39
|
* @param model - modèle Mongoose compilé.
|
|
24
40
|
* @param connector - nom de la connexion (clé du registre) — défaut `"nodefony"`.
|
|
25
41
|
* @param session - session transactionnelle à laquelle lier les ops (ou `null`).
|
|
42
|
+
* @param referrers - champs d'autres entités qui référencent celle-ci (index
|
|
43
|
+
* inverse de {@link MongooseOrm}) ; `null` quand aucune ne la vise.
|
|
26
44
|
*/
|
|
27
|
-
constructor(model, connector = "nodefony", session = null) {
|
|
45
|
+
constructor(model, connector = "nodefony", session = null, referrers = null) {
|
|
28
46
|
this.#model = model;
|
|
29
47
|
this.#connector = connector;
|
|
30
48
|
this.#session = session;
|
|
49
|
+
this.#referrers = referrers;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Options de session pour les méthodes dont le type refuse `session: null`
|
|
53
|
+
* (`updateMany`, `deleteMany`, `countDocuments`…) : la clé est ABSENTE hors
|
|
54
|
+
* transaction. Un objet neuf par appel, comme le littéral qu'il remplace —
|
|
55
|
+
* Mongoose peut enrichir l'objet d'options qu'il reçoit.
|
|
56
|
+
*/
|
|
57
|
+
#sessionOptions() {
|
|
58
|
+
return this.#session === null ? {} : { session: this.#session };
|
|
31
59
|
}
|
|
32
60
|
/**
|
|
33
61
|
* Tap dev-only : mesure la durée d'une opération et alimente **deux** sondes
|
|
@@ -77,6 +105,13 @@ var MongooseRepository = class MongooseRepository {
|
|
|
77
105
|
* (`$eq`/`$ne`) — en Mongo `null` matche aussi le champ **absent**, ce qui est
|
|
78
106
|
* bien l'équivalent du `NULL` SQL (colonne sans valeur).
|
|
79
107
|
*
|
|
108
|
+
* `$ne`/`$nin` ne matchent JAMAIS l'absence de valeur, comme en SQL (où
|
|
109
|
+
* `col <> 'x'` est inconnu, donc faux, sur un NULL). Natifs, ils la
|
|
110
|
+
* matcheraient : `{ note: { $ne: "x" } }` ramenait sous MongoDB les documents
|
|
111
|
+
* sans note, que PostgreSQL écarte — la même requête rendait plus de lignes en
|
|
112
|
+
* production MongoDB, sans un mot. `null` est donc ajouté à l'ensemble exclu.
|
|
113
|
+
* Un `$nin` VIDE reste neutre (SQL : aucune exclusion, NULL compris).
|
|
114
|
+
*
|
|
80
115
|
* @throws Error si `$null` est combiné à `$eq`/`$ne` sur le même champ : les
|
|
81
116
|
* deux viseraient la même clé Mongo et l'une écraserait l'autre **en
|
|
82
117
|
* silence** (le contrat les déclare exclusifs — cf `FieldOperators.$null`).
|
|
@@ -90,6 +125,17 @@ var MongooseRepository = class MongooseRepository {
|
|
|
90
125
|
out[target] = null;
|
|
91
126
|
} else if ((key === "$eq" || key === "$ne") && key in out) throw new Error(`criteria: $null ne se combine pas avec ${key} sur le même champ (${this.#model.modelName})`);
|
|
92
127
|
else out[key] = value;
|
|
128
|
+
const ne = ops.$ne;
|
|
129
|
+
const nin = ops.$nin;
|
|
130
|
+
if (ne !== void 0 && ne !== null || nin !== void 0 && nin.length > 0) {
|
|
131
|
+
const excluded = [...nin ?? []];
|
|
132
|
+
if (ne !== void 0 && ne !== null) {
|
|
133
|
+
excluded.push(ne);
|
|
134
|
+
delete out.$ne;
|
|
135
|
+
}
|
|
136
|
+
excluded.push(null);
|
|
137
|
+
out.$nin = excluded;
|
|
138
|
+
}
|
|
93
139
|
return out;
|
|
94
140
|
}
|
|
95
141
|
/**
|
|
@@ -106,7 +152,7 @@ var MongooseRepository = class MongooseRepository {
|
|
|
106
152
|
#resolveField(field) {
|
|
107
153
|
const key = field === "id" ? "_id" : field;
|
|
108
154
|
const paths = this.#model.schema.paths;
|
|
109
|
-
if (key === "_id" || paths[key] !== void 0 || paths[key.split(".")[0]] !== void 0) return key;
|
|
155
|
+
if (key === "_id" || paths[key] !== void 0 || paths[key.split(".")[0] ?? key] !== void 0) return key;
|
|
110
156
|
throw new UnknownCriteriaField(field, this.#model.modelName, Object.keys(paths));
|
|
111
157
|
}
|
|
112
158
|
/**
|
|
@@ -141,7 +187,7 @@ var MongooseRepository = class MongooseRepository {
|
|
|
141
187
|
if (options?.relations?.length) query = query.populate(options.relations);
|
|
142
188
|
if (options?.offset !== void 0) query = query.skip(options.offset);
|
|
143
189
|
if (options?.limit !== void 0) query = query.limit(options.limit);
|
|
144
|
-
if (options?.order?.length) query = query.sort(Object.fromEntries(options.order.map(([field, dir]) => [field, dir === "DESC" ? -1 : 1])));
|
|
190
|
+
if (options?.order?.length) query = query.sort(Object.fromEntries(options.order.map(([field, dir]) => [sortKey(field), dir === "DESC" ? -1 : 1])));
|
|
145
191
|
return (await query.exec()).map((doc) => this.#plain(doc));
|
|
146
192
|
}, (docs) => docs.length);
|
|
147
193
|
}
|
|
@@ -152,21 +198,22 @@ var MongooseRepository = class MongooseRepository {
|
|
|
152
198
|
let query = this.#model.findOne(filter);
|
|
153
199
|
if (this.#session) query = query.session(this.#session);
|
|
154
200
|
if (options?.relations?.length) query = query.populate(options.relations);
|
|
155
|
-
if (options?.order?.length) query = query.sort(Object.fromEntries(options.order.map(([field, dir]) => [field, dir === "DESC" ? -1 : 1])));
|
|
201
|
+
if (options?.order?.length) query = query.sort(Object.fromEntries(options.order.map(([field, dir]) => [sortKey(field), dir === "DESC" ? -1 : 1])));
|
|
156
202
|
const doc = await query.exec();
|
|
157
203
|
return doc ? this.#plain(doc) : null;
|
|
158
204
|
}, (doc) => doc ? 1 : 0);
|
|
159
205
|
}
|
|
160
206
|
async create(data) {
|
|
161
207
|
return this.#prof(() => this.#descr("create"), async () => {
|
|
162
|
-
const [doc] = await this.#model.create([data], { session: this.#session
|
|
208
|
+
const [doc] = await this.#model.create([data], { session: this.#session });
|
|
209
|
+
if (doc === void 0) throw new Error(`create: le pilote n'a rendu aucun document (${this.#model.modelName})`);
|
|
163
210
|
return this.#plain(doc);
|
|
164
211
|
}, () => 1);
|
|
165
212
|
}
|
|
166
213
|
async createMany(data) {
|
|
167
214
|
if (data.length === 0) return [];
|
|
168
215
|
return this.#prof(() => this.#descr("insertMany"), async () => {
|
|
169
|
-
return (await this.#model.insertMany(data, { session: this.#session
|
|
216
|
+
return (await this.#model.insertMany(data, { session: this.#session })).map((doc) => this.#plain(doc));
|
|
170
217
|
}, (rows) => rows.length);
|
|
171
218
|
}
|
|
172
219
|
async updateOne(criteria, data) {
|
|
@@ -174,7 +221,7 @@ var MongooseRepository = class MongooseRepository {
|
|
|
174
221
|
return this.#prof(() => this.#descr("findOneAndUpdate", filter), async () => {
|
|
175
222
|
const doc = await this.#model.findOneAndUpdate(filter, data, {
|
|
176
223
|
returnDocument: "after",
|
|
177
|
-
session: this.#session
|
|
224
|
+
session: this.#session
|
|
178
225
|
}).exec();
|
|
179
226
|
return doc ? this.#plain(doc) : null;
|
|
180
227
|
}, (doc) => doc ? 1 : 0);
|
|
@@ -188,9 +235,8 @@ var MongooseRepository = class MongooseRepository {
|
|
|
188
235
|
}, {
|
|
189
236
|
upsert: true,
|
|
190
237
|
returnDocument: "after",
|
|
191
|
-
session: this.#session
|
|
238
|
+
session: this.#session
|
|
192
239
|
}).exec();
|
|
193
|
-
if (!doc) throw new Error("MongooseRepository.upsert: aucun document retourné");
|
|
194
240
|
return this.#plain(doc);
|
|
195
241
|
}, () => 1);
|
|
196
242
|
}
|
|
@@ -224,7 +270,8 @@ var MongooseRepository = class MongooseRepository {
|
|
|
224
270
|
async updateMany(criteria, data) {
|
|
225
271
|
const filter = this.#filter(criteria);
|
|
226
272
|
return this.#prof(() => this.#descr("updateMany", filter), async () => {
|
|
227
|
-
|
|
273
|
+
const res = await this.#model.updateMany(filter, data, this.#sessionOptions());
|
|
274
|
+
return writeCount(res.modifiedCount);
|
|
228
275
|
}, (n) => n);
|
|
229
276
|
}
|
|
230
277
|
async increment(criteria, changes) {
|
|
@@ -232,33 +279,108 @@ var MongooseRepository = class MongooseRepository {
|
|
|
232
279
|
return this.#prof(() => this.#descr("findOneAndUpdate", filter), async () => {
|
|
233
280
|
const doc = await this.#model.findOneAndUpdate(filter, { $inc: changes }, {
|
|
234
281
|
returnDocument: "after",
|
|
235
|
-
session: this.#session
|
|
282
|
+
session: this.#session
|
|
236
283
|
}).exec();
|
|
237
284
|
return doc ? this.#plain(doc) : null;
|
|
238
285
|
}, (doc) => doc ? 1 : 0);
|
|
239
286
|
}
|
|
240
287
|
async delete(criteria) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
288
|
+
let filter = this.#filter(criteria);
|
|
289
|
+
const ids = await this.#referencedTargets(filter, false);
|
|
290
|
+
if (ids !== null) {
|
|
291
|
+
if (ids.length === 0) return 0;
|
|
292
|
+
filter = { _id: { $in: ids } };
|
|
293
|
+
}
|
|
294
|
+
const removed = await this.#prof(() => this.#descr("deleteMany", filter), async () => {
|
|
295
|
+
const res = await this.#model.deleteMany(filter, this.#sessionOptions());
|
|
296
|
+
return writeCount(res.deletedCount);
|
|
244
297
|
}, (n) => n);
|
|
298
|
+
if (ids !== null && removed > 0) await this.#releaseOptional(ids);
|
|
299
|
+
return removed;
|
|
245
300
|
}
|
|
246
301
|
async deleteOne(criteria) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
302
|
+
let filter = this.#filter(criteria);
|
|
303
|
+
const ids = await this.#referencedTargets(filter, true);
|
|
304
|
+
if (ids !== null) {
|
|
305
|
+
if (ids.length === 0) return false;
|
|
306
|
+
filter = { _id: ids[0] };
|
|
307
|
+
}
|
|
308
|
+
const removed = await this.#prof(() => this.#descr("deleteOne", filter), async () => {
|
|
309
|
+
const res = await this.#model.deleteOne(filter, this.#sessionOptions());
|
|
310
|
+
return writeCount(res.deletedCount) > 0;
|
|
250
311
|
}, (ok) => ok ? 1 : 0);
|
|
312
|
+
if (ids !== null && removed) await this.#releaseOptional(ids);
|
|
313
|
+
return removed;
|
|
251
314
|
}
|
|
252
315
|
async findOneAndDelete(criteria) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
316
|
+
let filter = this.#filter(criteria);
|
|
317
|
+
const ids = await this.#referencedTargets(filter, true);
|
|
318
|
+
if (ids !== null) {
|
|
319
|
+
if (ids.length === 0) return null;
|
|
320
|
+
filter = { _id: ids[0] };
|
|
321
|
+
}
|
|
322
|
+
const doc = await this.#prof(() => this.#descr("findOneAndDelete", filter), async () => {
|
|
323
|
+
const found = await this.#model.findOneAndDelete(filter, { session: this.#session }).exec();
|
|
324
|
+
return found ? this.#plain(found) : null;
|
|
325
|
+
}, (d) => d ? 1 : 0);
|
|
326
|
+
if (ids !== null && doc !== null) await this.#releaseOptional(ids);
|
|
327
|
+
return doc;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Garde d'intégrité d'une suppression — le `restrict` que MongoDB ne tient pas.
|
|
331
|
+
*
|
|
332
|
+
* Rend `null` (et ne coûte rien) quand aucune entité ne référence celle-ci.
|
|
333
|
+
* Sinon, lit les `_id` que la suppression viserait, REFUSE si l'un d'eux est
|
|
334
|
+
* encore désigné par une référence obligatoire, et les rend : la suppression
|
|
335
|
+
* porte alors sur ces `_id` exacts, pas sur le filtre — ce qui a été contrôlé
|
|
336
|
+
* est ce qui part.
|
|
337
|
+
*
|
|
338
|
+
* ⚠️ Hors transaction, le contrôle et la suppression sont deux opérations : un
|
|
339
|
+
* enfant créé entre les deux n'est pas vu. Le SQL, lui, est atomique ; dans
|
|
340
|
+
* une transaction (`withTransaction`), les deux lisent la même session.
|
|
341
|
+
* ⚠️ Les `_id` visés sont tous chargés : une suppression en MASSE garde sa
|
|
342
|
+
* règle « un seul parent retenu bloque le lot », au prix d'une mémoire
|
|
343
|
+
* proportionnelle au lot. Le CRUD généré supprime par `id`, donc un à un.
|
|
344
|
+
*
|
|
345
|
+
* @param filter - filtre Mongo déjà traduit.
|
|
346
|
+
* @param single - vrai pour `deleteOne`/`findOneAndDelete` (un seul document).
|
|
347
|
+
* @returns les `_id` visés, ou `null` si l'entité n'est référencée par personne.
|
|
348
|
+
* @throws ReferencedEntityError si une référence obligatoire désigne l'un d'eux.
|
|
349
|
+
*/
|
|
350
|
+
async #referencedTargets(filter, single) {
|
|
351
|
+
const referrers = this.#referrers;
|
|
352
|
+
if (referrers === null) return null;
|
|
353
|
+
const session = this.#session;
|
|
354
|
+
const query = this.#model.find(filter, { _id: 1 }, { session }).lean();
|
|
355
|
+
if (single) query.limit(1);
|
|
356
|
+
const docs = await query.exec();
|
|
357
|
+
if (docs.length === 0) return [];
|
|
358
|
+
const ids = docs.map((d) => d._id);
|
|
359
|
+
for (const referrer of referrers) {
|
|
360
|
+
if (!referrer.required) continue;
|
|
361
|
+
if (await referrer.model.exists(referrer.model === this.#model ? {
|
|
362
|
+
[referrer.path]: { $in: ids },
|
|
363
|
+
_id: { $nin: ids }
|
|
364
|
+
} : { [referrer.path]: { $in: ids } }).session(session ?? null).exec() !== null) throw new ReferencedEntityError(this.#model.modelName, referrer.model.modelName, referrer.path);
|
|
365
|
+
}
|
|
366
|
+
return ids;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Remet à `null` les références FACULTATIVES aux documents supprimés — le
|
|
370
|
+
* `set null` du SQL généré : l'enfant survit à son parent, sans pointer dans
|
|
371
|
+
* le vide.
|
|
372
|
+
*
|
|
373
|
+
* @param ids - `_id` des documents supprimés.
|
|
374
|
+
*/
|
|
375
|
+
async #releaseOptional(ids) {
|
|
376
|
+
for (const referrer of this.#referrers ?? []) {
|
|
377
|
+
if (referrer.required) continue;
|
|
378
|
+
await referrer.model.updateMany({ [referrer.path]: { $in: ids } }, { $set: { [referrer.path]: null } }, this.#sessionOptions());
|
|
379
|
+
}
|
|
258
380
|
}
|
|
259
381
|
async count(criteria) {
|
|
260
382
|
const filter = this.#filter(criteria);
|
|
261
|
-
return this.#prof(() => this.#descr("countDocuments", filter), () => this.#model.countDocuments(filter,
|
|
383
|
+
return this.#prof(() => this.#descr("countDocuments", filter), () => this.#model.countDocuments(filter, this.#sessionOptions()));
|
|
262
384
|
}
|
|
263
385
|
/**
|
|
264
386
|
* `COUNT(DISTINCT …)` en agrégation — `$group` puis `$count`, donc la
|
|
@@ -293,7 +415,7 @@ var MongooseRepository = class MongooseRepository {
|
|
|
293
415
|
});
|
|
294
416
|
}
|
|
295
417
|
withTransaction(tx) {
|
|
296
|
-
return new MongooseRepository(this.#model, this.#connector, tx.getNative());
|
|
418
|
+
return new MongooseRepository(this.#model, this.#connector, tx.getNative(), this.#referrers);
|
|
297
419
|
}
|
|
298
420
|
};
|
|
299
421
|
//#endregion
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SavepointNotSupportedError } from "@nodefony/orm-core";
|
|
1
2
|
//#region nodefony/src/orm-core/MongooseTransaction.ts
|
|
2
3
|
/**
|
|
3
4
|
* Adapte une `ClientSession` MongoDB au contrat portable {@link ITransaction}.
|
|
@@ -7,8 +8,8 @@
|
|
|
7
8
|
* MongoDB **exigent un replica set** (un standalone ne les supporte pas).
|
|
8
9
|
*
|
|
9
10
|
* MongoDB n'a **pas de savepoints** : {@link MongooseTransaction.savepoint} /
|
|
10
|
-
* {@link MongooseTransaction.rollbackTo}
|
|
11
|
-
*
|
|
11
|
+
* {@link MongooseTransaction.rollbackTo} les REFUSENT (`SavepointNotSupportedError`).
|
|
12
|
+
* Un no-op laissait croire un rollback partiel qui n'annulait rien.
|
|
12
13
|
*/
|
|
13
14
|
var MongooseTransaction = class {
|
|
14
15
|
#session;
|
|
@@ -40,10 +41,22 @@ var MongooseTransaction = class {
|
|
|
40
41
|
this.#done = true;
|
|
41
42
|
await this.#session.abortTransaction();
|
|
42
43
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Refusé : MongoDB n'a pas de savepoints.
|
|
46
|
+
*
|
|
47
|
+
* @throws SavepointNotSupportedError toujours.
|
|
48
|
+
*/
|
|
49
|
+
async savepoint(_name) {
|
|
50
|
+
throw new SavepointNotSupportedError("mongodb", "savepoint");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Refusé : MongoDB n'a pas de savepoints.
|
|
54
|
+
*
|
|
55
|
+
* @throws SavepointNotSupportedError toujours.
|
|
56
|
+
*/
|
|
57
|
+
async rollbackTo(_name) {
|
|
58
|
+
throw new SavepointNotSupportedError("mongodb", "rollbackTo");
|
|
59
|
+
}
|
|
47
60
|
/** Expose la `ClientSession` native (trappe bas niveau). */
|
|
48
61
|
getNative() {
|
|
49
62
|
return this.#session;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region nodefony/src/writeCount.ts
|
|
2
|
+
/**
|
|
3
|
+
* Compte rendu par une écriture MongoDB (`deletedCount`, `modifiedCount`), 0 s'il manque.
|
|
4
|
+
*
|
|
5
|
+
* Le pilote le type `number`, mais le recopie de la réponse du serveur
|
|
6
|
+
* (`res.n`) : une écriture NON acquittée (`w: 0`) n'en reçoit aucune, et le
|
|
7
|
+
* compte arrive `undefined`. Sans ce repli, un `gc()` rendrait `undefined` et
|
|
8
|
+
* un `> 0` conclurait « rien écrit » pour une raison qui n'est pas la bonne.
|
|
9
|
+
*
|
|
10
|
+
* @param count - le compte tel que le pilote le rend.
|
|
11
|
+
* @returns le compte, ou 0 quand le serveur n'en a rendu aucun.
|
|
12
|
+
*/
|
|
13
|
+
function writeCount(count) {
|
|
14
|
+
return count ?? 0;
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
export { writeCount };
|
package/dist/types/index.d.ts
CHANGED
|
@@ -70,5 +70,14 @@ export { MongooseWebAuthnCredentialStore } from "./nodefony/src/MongooseWebAuthn
|
|
|
70
70
|
export { webhookEndpointSchema, createWebhookEndpointEntity, registerWebhookEndpointEntity, WEBHOOK_ENDPOINT_ENTITY, } from "./nodefony/entity/webhookEndpointEntity.js";
|
|
71
71
|
export type { WebhookEndpointRow } from "./nodefony/entity/webhookEndpointEntity.js";
|
|
72
72
|
export { MongooseWebhookStore } from "./nodefony/src/MongooseWebhookStore.js";
|
|
73
|
+
export { auditEventSchema, createAuditEntities, registerAuditEntities, AUDIT_ENTITY_NAMES, } from "./nodefony/entity/auditEventEntity.js";
|
|
74
|
+
export type { AuditEventRow } from "./nodefony/entity/auditEventEntity.js";
|
|
75
|
+
export { MongooseAuditStore } from "./nodefony/src/MongooseAuditStore.js";
|
|
76
|
+
export { totpSecretSchema, createTotpSecretEntity, registerTotpSecretEntity, TOTP_SECRET_ENTITY, } from "./nodefony/entity/totpSecretEntity.js";
|
|
77
|
+
export type { TotpSecretRow } from "./nodefony/entity/totpSecretEntity.js";
|
|
78
|
+
export { MongooseTotpSecretStore } from "./nodefony/src/MongooseTotpSecretStore.js";
|
|
79
|
+
export { idempotencyKeySchema, createIdempotencyEntities, registerIdempotencyEntities, IDEMPOTENCY_ENTITY_NAME, } from "./nodefony/entity/idempotencyEntity.js";
|
|
80
|
+
export type { IdempotencyKeyRow } from "./nodefony/entity/idempotencyEntity.js";
|
|
81
|
+
export { MongooseIdempotencyStore } from "./nodefony/src/MongooseIdempotencyStore.js";
|
|
73
82
|
export { registerMongooseFrameworkStores, FRAMEWORK_CONNECTOR, } from "./nodefony/registerStores.js";
|
|
74
83
|
export type { IFrameworkStoresReport } from "./nodefony/registerStores.js";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { SchemaDefinition } from "mongoose";
|
|
2
|
+
import type { IEntity } from "@nodefony/orm-core";
|
|
3
|
+
import type { AuditCategory, AuditOutcome, IAuditEventFlags } from "@nodefony/security";
|
|
4
|
+
/**
|
|
5
|
+
* Schéma Mongoose du **journal d'audit** `@nodefony/security` — pendant
|
|
6
|
+
* documentaire de la table Drizzle `audit_event`, implémentation NoSQL d'
|
|
7
|
+
* `IAuditStore` (append-only, tamper-evident).
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ **`_id` = clé naturelle (String), PAS un ObjectId auto** : l'`id` d'un
|
|
10
|
+
* événement est posé par l'`AuditService` (corrélation + curseur de pagination)
|
|
11
|
+
* → on force `_id: String`, l'id EST la clé primaire. Même convention que
|
|
12
|
+
* `webhookEndpointEntity` et `tokenEntity`.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ **Horodatage = `Number` (epoch ms), `timestamps:false`** : `IAuditEvent.ts`
|
|
15
|
+
* porte un `number` (`Date.now()`), pas une `Date` — et c'est ce champ qui porte
|
|
16
|
+
* l'ordre du journal.
|
|
17
|
+
*
|
|
18
|
+
* **Index** — mêmes axes que la variante SQL, et pour la même raison (filtrage
|
|
19
|
+
* de la console d'audit) ; perf de lecture seule, jamais de sémantique.
|
|
20
|
+
*
|
|
21
|
+
* L'ORDRE TOTAL du store (`ts DESC, _id DESC`) est porté par un index
|
|
22
|
+
* COMPOSITE déclaré sur l'entité (`indexes`, cf `createAuditEntities`) : un
|
|
23
|
+
* `SchemaDefinition` plat n'exprime que des index par champ, et sans lui la
|
|
24
|
+
* requête du journal parcourait toute la collection puis triait en mémoire
|
|
25
|
+
* (plan `COLLSCAN` + `SORT`, mesuré). C'est lui qui garantit, au coût d'un
|
|
26
|
+
* parcours d'index, qu'aucun événement ne se répète ni ne se perd d'une page à
|
|
27
|
+
* l'autre — `audit-store.test.ts` en lit le plan d'exécution.
|
|
28
|
+
*/
|
|
29
|
+
export declare const auditEventSchema: SchemaDefinition;
|
|
30
|
+
/**
|
|
31
|
+
* Forme **plate** d'une ligne du journal renvoyée par le repository Mongoose —
|
|
32
|
+
* `id` = virtuel (= `_id` = l'id d'événement). Miroir d'`IAuditEvent` avec les
|
|
33
|
+
* champs optionnels rendus explicitement `| null` (le document Mongo les porte
|
|
34
|
+
* à `null`, jamais absents). `MongooseAuditStore` mappe `Row ↔ IAuditEvent`.
|
|
35
|
+
*/
|
|
36
|
+
export interface AuditEventRow {
|
|
37
|
+
id: string;
|
|
38
|
+
ts: number;
|
|
39
|
+
category: AuditCategory;
|
|
40
|
+
action: string;
|
|
41
|
+
outcome: AuditOutcome;
|
|
42
|
+
actor: string | null;
|
|
43
|
+
resource: string | null;
|
|
44
|
+
reason: string | null;
|
|
45
|
+
ip: string | null;
|
|
46
|
+
userAgent: string | null;
|
|
47
|
+
requestId: string | null;
|
|
48
|
+
flags: IAuditEventFlags | null;
|
|
49
|
+
metadata: Record<string, unknown> | null;
|
|
50
|
+
}
|
|
51
|
+
/** Noms logiques des entités du store (clés de lookup `getRepository`). */
|
|
52
|
+
export declare const AUDIT_ENTITY_NAMES: {
|
|
53
|
+
readonly events: "audit_event";
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Construit les descripteurs d'entités du journal d'audit pour un ORM nommé.
|
|
57
|
+
*
|
|
58
|
+
* Le `connector` est **dynamique** (nom du connecteur de l'app, ex. `"nodefony"`) :
|
|
59
|
+
* le schéma est statique mais sa liaison à un ORM dépend de la config → pas
|
|
60
|
+
* d'`@entity` figé. Pas de dialecte (NoSQL) : le schéma est portable par
|
|
61
|
+
* construction. À enregistrer **avant** `orm.connect()` (modèle compilé au connect).
|
|
62
|
+
*
|
|
63
|
+
* `module: "security"` → la collection est regroupée sous @nodefony/security dans
|
|
64
|
+
* l'ERD Studio (l'audit est une feature security, hébergée par l'ORM).
|
|
65
|
+
*
|
|
66
|
+
* @param connector - clé de l'ORM cible dans le `ormRegistry`.
|
|
67
|
+
* @returns les descripteurs {@link IEntity} du journal d'audit.
|
|
68
|
+
*/
|
|
69
|
+
export declare function createAuditEntities(connector: string): IEntity[];
|
|
70
|
+
/**
|
|
71
|
+
* Enregistre les entités du journal d'audit dans le `entityRegistry` pour un ORM
|
|
72
|
+
* donné. À appeler **avant** `orm.connect()` (le modèle est compilé au connect).
|
|
73
|
+
*
|
|
74
|
+
* @param connector - nom de la connexion cible (clé du registre).
|
|
75
|
+
*/
|
|
76
|
+
export declare function registerAuditEntities(connector: string): void;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SchemaDefinition } from "mongoose";
|
|
2
|
+
import type { IEntity } from "@nodefony/orm-core";
|
|
3
|
+
import type { IdempotentResponse } from "nodefony";
|
|
4
|
+
/**
|
|
5
|
+
* Schéma Mongoose du **store d'idempotence** — pendant documentaire de la table
|
|
6
|
+
* Drizzle `idempotency_key`, implémentation NoSQL d'`IIdempotencyStore` (contrat
|
|
7
|
+
* au CORE) pour dédoublonner les mutations rejouées PARTAGÉ cross-pod, là où le
|
|
8
|
+
* store mémoire par défaut reste affine à un pod.
|
|
9
|
+
*
|
|
10
|
+
* **Pourquoi MongoDB plutôt que Redis** : un cluster qui possède déjà une base
|
|
11
|
+
* Mongo mais pas de Redis obtient la dédup cross-pod sans nouvelle infra — le
|
|
12
|
+
* même argument qui fonde la variante SQL.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ **`_id` = la clé d'idempotence (String)** : c'est la contrainte d'unicité de
|
|
15
|
+
* la clé primaire qui porte la **réservation atomique** de `begin`
|
|
16
|
+
* (`findOneAndUpdate(… , {upsert:true})` → `E11000` pour le perdant). Faire
|
|
17
|
+
* porter l'unicité par un index secondaire serait un faux ami : Mongoose
|
|
18
|
+
* construit ses index en tâche de fond, donc la contrainte n'existerait pas
|
|
19
|
+
* pendant la fenêtre de construction, et deux `begin` concurrents renverraient
|
|
20
|
+
* tous deux `fresh` — exactement le double-effet que ce store existe pour
|
|
21
|
+
* empêcher. Sur `_id`, la contrainte existe dès le premier document.
|
|
22
|
+
*
|
|
23
|
+
* Le champ `key` est **dupliqué** à côté de `_id` : c'est lui que le vocabulaire
|
|
24
|
+
* public filtre (`q` = préfixe de clé) et que `listPage` rend. Les deux sont
|
|
25
|
+
* écrits ensemble, jamais séparément.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ **PAS de TTL index natif** (≠ `expireAfterSeconds`) : l'échéance d'une
|
|
28
|
+
* entrée n'est pas une durée fixe depuis sa création — c'est d'abord un **bail**
|
|
29
|
+
* *in-flight* (`now + lease`), puis une **rétention** (`now + ttl`) posée à la
|
|
30
|
+
* complétion. Un TTL index effacerait selon une règle qui ignore cette bascule.
|
|
31
|
+
* `expiresAt` est donc un `Number` (epoch ms) indexé, purgé par le `gc()`
|
|
32
|
+
* applicatif — même contrepartie que la variante SQL.
|
|
33
|
+
*
|
|
34
|
+
* `state` : `if` = réservation *in-flight* ; `done` = réponse mémorisée
|
|
35
|
+
* (rejouée en `replayed`). `response` = la réponse mémorisée, `null` tant
|
|
36
|
+
* qu'*in-flight* ; elle ne sort JAMAIS par `listPage` (anti-IDOR sur le cache).
|
|
37
|
+
*/
|
|
38
|
+
export declare const idempotencyKeySchema: SchemaDefinition;
|
|
39
|
+
/**
|
|
40
|
+
* Forme plate d'une ligne du store d'idempotence telle que rendue par le
|
|
41
|
+
* repository Mongoose. Identique aux champs de {@link idempotencyKeySchema}
|
|
42
|
+
* → zéro mapping store ↔ entité.
|
|
43
|
+
*/
|
|
44
|
+
export interface IdempotencyKeyRow {
|
|
45
|
+
key: string;
|
|
46
|
+
fingerprint: string;
|
|
47
|
+
state: "if" | "done";
|
|
48
|
+
response: IdempotentResponse | null;
|
|
49
|
+
expiresAt: number;
|
|
50
|
+
}
|
|
51
|
+
/** Nom logique de l'entité (clé de lookup `getRepository` / `entityRegistry`). */
|
|
52
|
+
export declare const IDEMPOTENCY_ENTITY_NAME: "idempotency_key";
|
|
53
|
+
/**
|
|
54
|
+
* Construit le descripteur d'entité du store d'idempotence pour un ORM nommé.
|
|
55
|
+
*
|
|
56
|
+
* Le `connector` est **dynamique** (nom du connecteur de l'app, ex. `"nodefony"`) :
|
|
57
|
+
* le schéma est statique mais sa liaison à un ORM dépend de la config → pas
|
|
58
|
+
* d'`@entity` figé. À enregistrer **avant** `orm.connect()` (le modèle est
|
|
59
|
+
* compilé au connect).
|
|
60
|
+
*
|
|
61
|
+
* `module: "framework"` → la collection est regroupée sous @nodefony/framework
|
|
62
|
+
* dans l'ERD Studio (l'idempotence est une feature framework — data plane admin
|
|
63
|
+
* + `@Idempotent` — simplement hébergée par l'ORM).
|
|
64
|
+
*
|
|
65
|
+
* @param connector - clé de l'ORM cible dans le `ormRegistry`.
|
|
66
|
+
* @returns les descripteurs {@link IEntity} du store d'idempotence.
|
|
67
|
+
*/
|
|
68
|
+
export declare function createIdempotencyEntities(connector: string): IEntity[];
|
|
69
|
+
/**
|
|
70
|
+
* Enregistre l'entité du store d'idempotence dans le `entityRegistry` pour un
|
|
71
|
+
* ORM donné. À appeler **avant** `orm.connect()`.
|
|
72
|
+
*
|
|
73
|
+
* @param connector - nom de la connexion cible (clé du registre).
|
|
74
|
+
*/
|
|
75
|
+
export declare function registerIdempotencyEntities(connector: string): void;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SchemaDefinition } from "mongoose";
|
|
2
|
+
import type { IEntity } from "@nodefony/orm-core";
|
|
3
|
+
/**
|
|
4
|
+
* Algorithme HMAC du code TOTP (RFC 6238) — union littérale (miroir du
|
|
5
|
+
* `TotpAlgorithm` de `@nodefony/security`, non ré-exporté à la racine). Écrite en
|
|
6
|
+
* clair plutôt que dérivée d'`ITotpSecret["algorithm"]` : sinon le `.d.ts` généré
|
|
7
|
+
* référencerait un chemin interne du package security (non portable, TS2883).
|
|
8
|
+
*/
|
|
9
|
+
type TotpAlgorithm = "SHA1" | "SHA256" | "SHA512";
|
|
10
|
+
/**
|
|
11
|
+
* Schéma Mongoose du **store de secrets TOTP** `@nodefony/security` (2FA) —
|
|
12
|
+
* pendant documentaire de la table Drizzle `totp_secret`, implémentation NoSQL
|
|
13
|
+
* d'`ITotpSecretStore`.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ **`_id` = `userId` (String)** : le modèle est **1 secret / utilisateur**, donc
|
|
16
|
+
* la clé naturelle EST l'utilisateur — `save` devient un upsert par `_id`, et
|
|
17
|
+
* l'unicité est portée par la clé primaire elle-même (aucun index à construire
|
|
18
|
+
* en tâche de fond, donc aucune fenêtre où la contrainte n'existerait pas).
|
|
19
|
+
*
|
|
20
|
+
* Le champ `userId` est **dupliqué** à côté de `_id` : c'est lui que le
|
|
21
|
+
* vocabulaire public trie et filtre (`q` = préfixe d'`userId`), et un document
|
|
22
|
+
* Mongo n'expose pas `_id` sous un autre nom. Les deux sont écrits ensemble par
|
|
23
|
+
* le store, jamais séparément.
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ **`secretEnc` = secret déjà CHIFFRÉ** (AES-256-GCM par le service détenteur
|
|
26
|
+
* de la clé) : le store ne voit que des octets opaques, jamais le secret `K` en
|
|
27
|
+
* clair. Il ne sort JAMAIS par `listPage` (cf `ITotpEnrollmentSummary`).
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ **Horodatages = `Number` (epoch ms), `timestamps:false`** — sauf
|
|
30
|
+
* `lastUsedStep`, qui est une **tranche `T`** RFC 6238 (`floor(epochSeconds /
|
|
31
|
+
* period)`), pas un horodatage.
|
|
32
|
+
*
|
|
33
|
+
* `recoveryCodes` = tableau de condensats `sha256` des codes NON consommés ; le
|
|
34
|
+
* store les réécrit en bloc (jamais de mutation partielle in-place).
|
|
35
|
+
*/
|
|
36
|
+
export declare const totpSecretSchema: SchemaDefinition;
|
|
37
|
+
/**
|
|
38
|
+
* Forme **plate** d'une ligne de secret renvoyée par le repository Mongoose.
|
|
39
|
+
* Structure identique à `ITotpSecret` (tout `| null` déjà présent côté contrat)
|
|
40
|
+
* → mapping quasi-identité dans `MongooseTotpSecretStore`.
|
|
41
|
+
*/
|
|
42
|
+
export interface TotpSecretRow {
|
|
43
|
+
userId: string;
|
|
44
|
+
secretEnc: string;
|
|
45
|
+
algorithm: TotpAlgorithm;
|
|
46
|
+
digits: number;
|
|
47
|
+
period: number;
|
|
48
|
+
recoveryCodes: string[];
|
|
49
|
+
confirmedAt: number | null;
|
|
50
|
+
lastUsedStep: number | null;
|
|
51
|
+
createdAt: number;
|
|
52
|
+
lastUsedAt: number | null;
|
|
53
|
+
}
|
|
54
|
+
/** Nom logique de l'entité (clé de lookup `getRepository`). */
|
|
55
|
+
export declare const TOTP_SECRET_ENTITY = "totp_secret";
|
|
56
|
+
/**
|
|
57
|
+
* Construit le descripteur d'entité du store de secrets TOTP pour un ORM nommé.
|
|
58
|
+
*
|
|
59
|
+
* Le `connector` est **dynamique** (nom du connecteur de l'app, ex. `"nodefony"`) :
|
|
60
|
+
* le schéma est statique mais sa liaison à un ORM dépend de la config → pas
|
|
61
|
+
* d'`@entity` figé. À enregistrer **avant** `orm.connect()` (le modèle est
|
|
62
|
+
* compilé au connect). `module: "security"` → regroupé sous @nodefony/security
|
|
63
|
+
* dans l'ERD Studio.
|
|
64
|
+
*
|
|
65
|
+
* @param connector - clé de l'ORM cible dans le `ormRegistry`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function createTotpSecretEntity(connector: string): IEntity;
|
|
68
|
+
/**
|
|
69
|
+
* Enregistre l'entité du store de secrets TOTP dans le `entityRegistry` pour un
|
|
70
|
+
* ORM donné. À appeler **avant** `orm.connect()`.
|
|
71
|
+
*
|
|
72
|
+
* @param connector - nom de la connexion cible (clé du registre).
|
|
73
|
+
*/
|
|
74
|
+
export declare function registerTotpSecretEntity(connector: string): void;
|
|
75
|
+
export {};
|
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
* modèles sont compilés à la connexion). Pas de dialecte (NoSQL) : les schémas
|
|
8
8
|
* sont portables par construction.
|
|
9
9
|
*
|
|
10
|
-
* Couverture
|
|
11
|
-
* webhooks
|
|
12
|
-
*
|
|
10
|
+
* Couverture : session (auto via `@entity`), utilisateurs, tokens, webauthn,
|
|
11
|
+
* webhooks, audit, TOTP et idempotence — soit **la totalité des briques
|
|
12
|
+
* durables**. C'est la propriété que Mongo doit tenir : une application doit
|
|
13
|
+
* pouvoir tourner SANS aucun backend SQL, et un backend durable est un chemin
|
|
14
|
+
* complet ou n'en est pas un. Une brique qui manquerait se sélectionnerait en
|
|
15
|
+
* échec franc (« store inconnu »), jamais en silence.
|
|
13
16
|
*
|
|
14
17
|
* Mêmes garde-fous que Drizzle : entité `has`-guarded (l'app garde la main),
|
|
15
18
|
* fabrique `get`-guarded (premier-arrivé-premier-servi).
|
|
@@ -28,7 +28,7 @@ declare class MongooseService extends Service {
|
|
|
28
28
|
*/
|
|
29
29
|
connectAll(connect?: boolean): Promise<void>;
|
|
30
30
|
/** Assemble l'URI de connexion à partir de la config (`uri` ou composants). */
|
|
31
|
-
static buildUri(cfg: IMongooseConnectorConfig): string;
|
|
31
|
+
static buildUri(cfg: Partial<IMongooseConnectorConfig>): string;
|
|
32
32
|
/**
|
|
33
33
|
* Options de connexion Mongoose d'un connecteur.
|
|
34
34
|
*
|
|
@@ -45,7 +45,7 @@ declare class MongooseService extends Service {
|
|
|
45
45
|
* @param cfg - configuration validée du connecteur.
|
|
46
46
|
* @returns les options à passer à la connexion, ou `undefined` s'il n'y en a aucune.
|
|
47
47
|
*/
|
|
48
|
-
static buildConnectOptions(cfg: IMongooseConnectorConfig): ConnectOptions | undefined;
|
|
48
|
+
static buildConnectOptions(cfg: Partial<IMongooseConnectorConfig>): ConnectOptions | undefined;
|
|
49
49
|
/** Ferme toutes les connexions. */
|
|
50
50
|
disconnectAll(): Promise<void>;
|
|
51
51
|
/** Retourne l'ORM Mongoose d'un connecteur (défaut : `"nodefony"`). */
|