@nodefony/mongoose 10.0.0-alpha.7 → 10.0.0-alpha.9

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 (38) hide show
  1. package/README.md +32 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +10 -4
  5. package/dist/nodefony/entity/auditEventEntity.js +124 -0
  6. package/dist/nodefony/entity/idempotencyEntity.js +96 -0
  7. package/dist/nodefony/entity/sessionEntity.js +1 -1
  8. package/dist/nodefony/entity/totpSecretEntity.js +103 -0
  9. package/dist/nodefony/registerStores.js +41 -4
  10. package/dist/nodefony/service/MongooseService.js +6 -3
  11. package/dist/nodefony/src/MongooseAuditStore.js +213 -0
  12. package/dist/nodefony/src/MongooseIdempotencyStore.js +260 -0
  13. package/dist/nodefony/src/MongooseTokenStore.js +60 -17
  14. package/dist/nodefony/src/MongooseTotpSecretStore.js +151 -0
  15. package/dist/nodefony/src/MongooseWebAuthnCredentialStore.js +13 -1
  16. package/dist/nodefony/src/MongooseWebhookStore.js +14 -2
  17. package/dist/nodefony/src/SessionStorage.js +8 -0
  18. package/dist/nodefony/src/orm-core/MongooseOrm.js +62 -12
  19. package/dist/nodefony/src/orm-core/MongooseRepository.js +124 -14
  20. package/dist/nodefony/src/orm-core/MongooseTransaction.js +19 -6
  21. package/dist/types/index.d.ts +9 -0
  22. package/dist/types/nodefony/entity/auditEventEntity.d.ts +76 -0
  23. package/dist/types/nodefony/entity/idempotencyEntity.d.ts +75 -0
  24. package/dist/types/nodefony/entity/totpSecretEntity.d.ts +75 -0
  25. package/dist/types/nodefony/registerStores.d.ts +6 -3
  26. package/dist/types/nodefony/src/MongooseAuditStore.d.ts +70 -0
  27. package/dist/types/nodefony/src/MongooseIdempotencyStore.d.ts +121 -0
  28. package/dist/types/nodefony/src/MongooseTokenStore.d.ts +8 -1
  29. package/dist/types/nodefony/src/MongooseTotpSecretStore.d.ts +63 -0
  30. package/dist/types/nodefony/src/MongooseWebAuthnCredentialStore.d.ts +6 -0
  31. package/dist/types/nodefony/src/MongooseWebhookStore.d.ts +6 -0
  32. package/dist/types/nodefony/src/SessionStorage.d.ts +6 -0
  33. package/dist/types/nodefony/src/orm-core/MongooseOrm.d.ts +10 -3
  34. package/dist/types/nodefony/src/orm-core/MongooseRepository.d.ts +22 -1
  35. package/dist/types/nodefony/src/orm-core/MongooseTransaction.d.ts +12 -4
  36. package/docs/configuration.md +22 -22
  37. package/docs/index.md +107 -63
  38. package/package.json +22 -18
@@ -0,0 +1,151 @@
1
+ import { TOTP_SECRET_ENTITY } from "../entity/totpSecretEntity.js";
2
+ import { assertPageQuery } from "nodefony";
3
+ import { paginate, searchCriteria } from "@nodefony/orm-core";
4
+ //#region nodefony/src/MongooseTotpSecretStore.ts
5
+ /**
6
+ * Store de secrets TOTP **Mongoose** (NoSQL) — implémentation documentaire d'
7
+ * {@link ITotpSecretStore} au-dessus d'un unique repository `@nodefony/orm-core`
8
+ * (`totp_secret`). Pendant documentaire de `DrizzleTotpSecretStore` : là où
9
+ * `MemoryTotpSecretStore` est volatile, ce store survit au redémarrage et se
10
+ * partage entre pods.
11
+ *
12
+ * **Modèle 1 secret / utilisateur** (clé = `userId`, portée par `_id`) → `save`
13
+ * est un upsert par clé primaire.
14
+ *
15
+ * **Approche B** : `@nodefony/security` n'est connu qu'en `import type` (0 dép
16
+ * runtime). L'entité (`registerTotpSecretEntity`) doit être enregistrée
17
+ * **avant** `orm.connect()` (le modèle est compilé au connect).
18
+ *
19
+ * **100 % portable** (aucune query native) — toutes les opérations passent par
20
+ * le contrat `IRepository`, comme son frère SQL : l'`userId` est cherché sous
21
+ * `id` (le contrat traduit `{ id }` → `{ _id }`), et le listing par `paginate()`.
22
+ *
23
+ * **`secretEnc` opaque** : le store persiste le secret DÉJÀ chiffré (AES-256-GCM
24
+ * côté service) — il ne déchiffre jamais, ne voit que des octets. Il ne sort
25
+ * JAMAIS par `listPage` (cf {@link ITotpEnrollmentSummary}).
26
+ */
27
+ var MongooseTotpSecretStore = class MongooseTotpSecretStore {
28
+ #repo;
29
+ /**
30
+ * @param repo - repository de la collection `totp_secret`.
31
+ */
32
+ constructor(repo) {
33
+ this.#repo = repo;
34
+ }
35
+ /** Connecteur ORM qui porte ce store — posé par {@link MongooseTotpSecretStore.from}. */
36
+ #connector;
37
+ /**
38
+ * Connecteur ORM qui porte ce store, lu par `readStoreConnector` pour le
39
+ * registre des stores : la console rattache la brique à SON connecteur au
40
+ * lieu de le déduire. `undefined` pour un store construit sans ORM (bancs).
41
+ */
42
+ get connector() {
43
+ return this.#connector;
44
+ }
45
+ /**
46
+ * Construit le store depuis un {@link MongooseOrm} connecté. L'entité
47
+ * (`registerTotpSecretEntity`) doit avoir été enregistrée **avant**
48
+ * `orm.connect()`.
49
+ *
50
+ * @param orm - ORM Mongoose connecté hébergeant la collection du store.
51
+ */
52
+ static from(orm) {
53
+ const store = new MongooseTotpSecretStore(orm.getRepository(TOTP_SECRET_ENTITY));
54
+ store.#connector = orm.name;
55
+ return store;
56
+ }
57
+ /** Row plate → secret du contrat (recoveryCodes copié = mutable indépendant). */
58
+ #toSecret(row) {
59
+ return {
60
+ userId: row.userId,
61
+ secretEnc: row.secretEnc,
62
+ algorithm: row.algorithm,
63
+ digits: row.digits,
64
+ period: row.period,
65
+ recoveryCodes: [...row.recoveryCodes],
66
+ confirmedAt: row.confirmedAt,
67
+ lastUsedStep: row.lastUsedStep,
68
+ createdAt: row.createdAt,
69
+ lastUsedAt: row.lastUsedAt
70
+ };
71
+ }
72
+ async findByUser(userId) {
73
+ const row = await this.#repo.findOne({ id: userId });
74
+ return row ? this.#toSecret(row) : null;
75
+ }
76
+ async save(secret) {
77
+ await this.#repo.upsert({ id: secret.userId }, {
78
+ userId: secret.userId,
79
+ secretEnc: secret.secretEnc,
80
+ algorithm: secret.algorithm,
81
+ digits: secret.digits,
82
+ period: secret.period,
83
+ recoveryCodes: [...secret.recoveryCodes],
84
+ confirmedAt: secret.confirmedAt,
85
+ lastUsedStep: secret.lastUsedStep,
86
+ createdAt: secret.createdAt,
87
+ lastUsedAt: secret.lastUsedAt
88
+ });
89
+ }
90
+ async update(userId, patch) {
91
+ const set = {};
92
+ if (patch.confirmedAt !== void 0) set.confirmedAt = patch.confirmedAt;
93
+ if (patch.recoveryCodes !== void 0) set.recoveryCodes = [...patch.recoveryCodes];
94
+ if (patch.lastUsedStep !== void 0) set.lastUsedStep = patch.lastUsedStep;
95
+ if (patch.lastUsedAt !== void 0) set.lastUsedAt = patch.lastUsedAt;
96
+ if (Object.keys(set).length === 0) return;
97
+ await this.#repo.updateOne({ id: userId }, set);
98
+ }
99
+ async delete(userId) {
100
+ await this.#repo.delete({ id: userId });
101
+ }
102
+ /**
103
+ * Critère portable des filtres du listing. `confirmed` s'exprime en `$null`
104
+ * sur `confirmedAt` (pas de champ booléen dérivé à maintenir), `q` en `$like`
105
+ * **ancré à gauche** (`préfixe%`) — traduit en `$regex` ancré par le
106
+ * repository, donc servable par un index.
107
+ */
108
+ #listCriteria(query) {
109
+ const criteria = {};
110
+ if (query.confirmed !== void 0) criteria.confirmedAt = { $null: !query.confirmed };
111
+ Object.assign(criteria, searchCriteria(query.q, ["userId"]) ?? {});
112
+ return criteria;
113
+ }
114
+ /**
115
+ * {@inheritDoc ITotpSecretStore.listPage}
116
+ *
117
+ * 100 % portable : le helper `paginate()` d'orm-core (limite/décalage + total
118
+ * optionnel) sur un critère simple. La projection en vue d'enrôlement retire
119
+ * `secretEnc` et les condensats — ils ne franchissent jamais la frontière du
120
+ * store, quel que soit l'appelant.
121
+ */
122
+ async listPage(query) {
123
+ assertPageQuery(query, "offset");
124
+ const page = await paginate(this.#repo, {
125
+ criteria: this.#listCriteria(query),
126
+ limit: query.limit,
127
+ offset: query.offset,
128
+ withTotal: query.withTotal,
129
+ order: [["createdAt", "DESC"], ["userId", "ASC"]]
130
+ });
131
+ return {
132
+ ...page,
133
+ items: page.items.map((row) => ({
134
+ userId: row.userId,
135
+ algorithm: row.algorithm,
136
+ digits: row.digits,
137
+ period: row.period,
138
+ confirmedAt: row.confirmedAt,
139
+ createdAt: row.createdAt,
140
+ lastUsedAt: row.lastUsedAt,
141
+ recoveryCodesLeft: row.recoveryCodes.length
142
+ }))
143
+ };
144
+ }
145
+ /** {@inheritDoc ITotpSecretStore.countEnrollments} */
146
+ countEnrollments(query) {
147
+ return this.#repo.count(this.#listCriteria(query));
148
+ }
149
+ };
150
+ //#endregion
151
+ export { MongooseTotpSecretStore };
@@ -25,6 +25,16 @@ var MongooseWebAuthnCredentialStore = class MongooseWebAuthnCredentialStore {
25
25
  constructor(repo) {
26
26
  this.#repo = repo;
27
27
  }
28
+ /** Connecteur ORM qui porte ce store — posé par {@link MongooseWebAuthnCredentialStore.from}. */
29
+ #connector;
30
+ /**
31
+ * Connecteur ORM qui porte ce store, lu par `readStoreConnector` pour le
32
+ * registre des stores : la console rattache la brique à SON connecteur au
33
+ * lieu de le déduire. `undefined` pour un store construit sans ORM (bancs).
34
+ */
35
+ get connector() {
36
+ return this.#connector;
37
+ }
28
38
  /**
29
39
  * Construit le store depuis un {@link MongooseOrm} connecté. L'entité
30
40
  * (`registerWebAuthnCredentialEntity`) doit avoir été enregistrée **avant**
@@ -33,7 +43,9 @@ var MongooseWebAuthnCredentialStore = class MongooseWebAuthnCredentialStore {
33
43
  * @param orm - ORM Mongoose connecté hébergeant la collection du store.
34
44
  */
35
45
  static from(orm) {
36
- return new MongooseWebAuthnCredentialStore(orm.getRepository(WEBAUTHN_CREDENTIAL_ENTITY));
46
+ const store = new MongooseWebAuthnCredentialStore(orm.getRepository(WEBAUTHN_CREDENTIAL_ENTITY));
47
+ store.#connector = orm.name;
48
+ return store;
37
49
  }
38
50
  /** Identité réelle d'un credential : `_id` fait foi, le virtuel `id` en repli. */
39
51
  #idOf(row) {
@@ -1,5 +1,5 @@
1
- import { WEBHOOK_ENDPOINT_ENTITY } from "../entity/webhookEndpointEntity.js";
2
1
  import { mongoOrder, toMongoSort } from "./mongoOrder.js";
2
+ import { WEBHOOK_ENDPOINT_ENTITY } from "../entity/webhookEndpointEntity.js";
3
3
  import { assertPageQuery, escapeRegExp } from "nodefony";
4
4
  import { WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS } from "@nodefony/security";
5
5
  //#region nodefony/src/MongooseWebhookStore.ts
@@ -41,6 +41,16 @@ var MongooseWebhookStore = class MongooseWebhookStore {
41
41
  this.#repo = repo;
42
42
  this.#model = model;
43
43
  }
44
+ /** Connecteur ORM qui porte ce store — posé par {@link MongooseWebhookStore.from}. */
45
+ #connector;
46
+ /**
47
+ * Connecteur ORM qui porte ce store, lu par `readStoreConnector` pour le
48
+ * registre des stores : la console rattache la brique à SON connecteur au
49
+ * lieu de le déduire. `undefined` pour un store construit sans ORM (bancs).
50
+ */
51
+ get connector() {
52
+ return this.#connector;
53
+ }
44
54
  /**
45
55
  * Construit le store depuis un {@link MongooseOrm} connecté. L'entité
46
56
  * (`registerWebhookEndpointEntity`) doit avoir été enregistrée **avant**
@@ -50,7 +60,9 @@ var MongooseWebhookStore = class MongooseWebhookStore {
50
60
  */
51
61
  static from(orm) {
52
62
  const connection = orm.getNativeConnection();
53
- return new MongooseWebhookStore(orm.getRepository(WEBHOOK_ENDPOINT_ENTITY), connection.model(WEBHOOK_ENDPOINT_ENTITY));
63
+ const store = new MongooseWebhookStore(orm.getRepository(WEBHOOK_ENDPOINT_ENTITY), connection.model(WEBHOOK_ENDPOINT_ENTITY));
64
+ store.#connector = orm.name;
65
+ return store;
54
66
  }
55
67
  /** Identité réelle d'un endpoint : `_id` fait foi, le virtuel `id` en repli. */
56
68
  #idOf(row) {
@@ -27,6 +27,14 @@ var SessionStorage = class SessionStorage {
27
27
  this.absoluteTimeoutS = manager.options.absoluteTimeoutS;
28
28
  }
29
29
  /**
30
+ * Connecteur ORM qui porte les sessions — lu par `readStoreConnector` pour le
31
+ * registre des stores. `undefined` tant que l'ORM de ce connecteur n'est pas
32
+ * enregistré : on ne publie pas un rattachement que rien ne porte.
33
+ */
34
+ get connector() {
35
+ return ormRegistry.has("nodefony") ? SESSION_CONNECTOR : void 0;
36
+ }
37
+ /**
30
38
  * Repository de l'entité session, ou `null` si l'ORM n'est pas (ou plus)
31
39
  * connecté.
32
40
  *
@@ -56,9 +56,12 @@ var MongooseOrm = class MongooseOrm extends Orm {
56
56
  * @param name - clé unique de l'ORM dans le `ormRegistry`.
57
57
  * @param uri - URI de connexion MongoDB (replica set requis pour les tx).
58
58
  * @param options - options de connexion Mongoose (auth, pool, timeouts).
59
+ * @param container - container du service qui construit l'ORM : il porte le
60
+ * `syslog` du kernel. Sans lui, la perte et la reprise de connexion
61
+ * s'écrivent dans un journal que rien ne relie à la sortie du serveur.
59
62
  */
60
- constructor(name, uri, options) {
61
- super(name);
63
+ constructor(name, uri, options, container) {
64
+ super(name, container);
62
65
  this.#uri = uri;
63
66
  this.#options = options;
64
67
  }
@@ -83,11 +86,18 @@ var MongooseOrm = class MongooseOrm extends Orm {
83
86
  this.#models = Object.create(null);
84
87
  const entities = this.#ownEntities();
85
88
  const schemas = /* @__PURE__ */ new Map();
86
- for (const entity of entities) schemas.set(entity.name, new mongoose.Schema(entity.schema, {
87
- toObject: { virtuals: true },
88
- toJSON: { virtuals: true },
89
- timestamps: entity.timestamps ?? false
90
- }));
89
+ for (const entity of entities) {
90
+ const schema = new mongoose.Schema(entity.schema, {
91
+ toObject: { virtuals: true },
92
+ toJSON: { virtuals: true },
93
+ timestamps: entity.timestamps ?? false
94
+ });
95
+ for (const index of entity.indexes ?? []) schema.index({ ...index.fields }, {
96
+ ...index.unique !== void 0 ? { unique: index.unique } : {},
97
+ ...index.name !== void 0 ? { name: index.name } : {}
98
+ });
99
+ schemas.set(entity.name, schema);
100
+ }
91
101
  for (const entity of entities) {
92
102
  if (!entity.relations) continue;
93
103
  const sourceSchema = schemas.get(entity.name);
@@ -126,6 +136,7 @@ var MongooseOrm = class MongooseOrm extends Orm {
126
136
  this.#models[entity.name] = model;
127
137
  entity.model = model;
128
138
  }
139
+ this.#referrers = this.#indexReferrers(this.#models);
129
140
  const audit = this.verifyIndexes();
130
141
  this.#indexAudit = audit;
131
142
  audit.catch(() => void 0);
@@ -294,15 +305,51 @@ var MongooseOrm = class MongooseOrm extends Orm {
294
305
  this.#connection = null;
295
306
  this.#models = null;
296
307
  this.#repositories = null;
308
+ this.#referrers = null;
297
309
  this.#indexAudit = null;
298
310
  }
311
+ /**
312
+ * Index inverse des références : pour chaque entité visée, les champs des
313
+ * autres entités qui la désignent. `null` quand aucune référence n'existe.
314
+ */
315
+ #referrers = null;
316
+ /**
317
+ * Bâtit l'index inverse des références à partir des schémas COMPILÉS.
318
+ *
319
+ * Tout champ `ObjectId` qui porte un `ref` vers une entité de cette connexion
320
+ * compte — qu'il vienne d'un schéma généré (`author: { type: ObjectId, ref }`)
321
+ * ou d'une relation déclarée (`relations`, que l'étape 2 traduit en champ).
322
+ * Un tableau de références n'y entre pas : son effacement (retirer l'élément)
323
+ * n'a pas d'équivalent dans la politique `restrict` / `set null` du SQL.
324
+ *
325
+ * @param models - modèles compilés de la connexion.
326
+ * @returns l'index, ou `null` si aucune entité n'en référence une autre.
327
+ */
328
+ #indexReferrers(models) {
329
+ let index = null;
330
+ for (const model of Object.values(models)) {
331
+ const leading = new Set(model.schema.indexes().map(([fields]) => Object.keys(fields)[0]));
332
+ model.schema.eachPath((fieldPath, type) => {
333
+ const ref = type.options?.ref;
334
+ if (fieldPath === "_id" || type.instance !== "ObjectId" || typeof ref !== "string" || models[ref] === void 0) return;
335
+ if (index === null) index = Object.create(null);
336
+ (index[ref] ??= []).push({
337
+ model,
338
+ path: fieldPath,
339
+ required: type.isRequired === true
340
+ });
341
+ if (!leading.has(fieldPath)) this.log(`référence "${model.modelName}.${fieldPath}" → "${ref}" sans index : chaque suppression d'un "${ref}" parcourra toute la collection "${model.collection.collectionName}" — ajouter \`index: true\``, "WARNING");
342
+ });
343
+ }
344
+ return index;
345
+ }
299
346
  getRepository(name) {
300
347
  const model = this.#models?.[name];
301
348
  if (!model) throw new Error(`MongooseOrm "${this.name}": no entity model registered under "${name}".`);
302
349
  if (this.#repositories === null) this.#repositories = Object.create(null);
303
350
  let repository = this.#repositories[name];
304
351
  if (repository === void 0) {
305
- repository = new MongooseRepository(model, this.name);
352
+ repository = new MongooseRepository(model, this.name, null, this.#referrers?.[name] ?? null);
306
353
  this.#repositories[name] = repository;
307
354
  }
308
355
  return repository;
@@ -362,8 +409,11 @@ var MongooseOrm = class MongooseOrm extends Orm {
362
409
  }
363
410
  /**
364
411
  * Colonnes normalisées d'une entité depuis les `paths` du schéma Mongoose —
365
- * alimente le graphe canonique / ERD / contexte IA. Pas de PK SQL : `_id` est
366
- * la clé primaire implicite de tout document.
412
+ * alimente le graphe canonique / ERD / contexte IA. Rendues dans le
413
+ * vocabulaire du CONTRAT, pas du moteur : la clé primaire `_id` s'y nomme
414
+ * `id` (comme dans les critères et les lignes lues), et la clé de version
415
+ * interne `__v` n'y figure pas — un graphe qui changerait de noms selon
416
+ * l'adaptateur ne décrirait plus la même application.
367
417
  *
368
418
  * @param name - nom logique de l'entité.
369
419
  * @returns colonnes (`[]` si l'entité n'est pas connue de cet ORM).
@@ -372,8 +422,8 @@ var MongooseOrm = class MongooseOrm extends Orm {
372
422
  const model = this.#models?.[name];
373
423
  if (!model) return [];
374
424
  const paths = model.schema.paths;
375
- return Object.entries(paths).map(([field, schemaType]) => ({
376
- name: field,
425
+ return Object.entries(paths).filter(([field]) => field !== "__v").map(([field, schemaType]) => ({
426
+ name: field === "_id" ? "id" : field,
377
427
  type: schemaType.instance || "Mixed",
378
428
  primaryKey: field === "_id",
379
429
  nullable: field === "_id" ? false : schemaType.isRequired !== true,
@@ -1,7 +1,20 @@
1
+ import { MONGO_ORDER_ALIASES } from "../mongoOrder.js";
1
2
  import { RequestContext, redactSecrets } from "nodefony";
2
- import { UnknownCriteriaField, assertOrderOption, isFieldOperators, isUpdateOperators, likePatternToRegExp, queryFlowMonitor } from "@nodefony/orm-core";
3
+ import { ReferencedEntityError, UnknownCriteriaField, assertOrderOption, isFieldOperators, isUpdateOperators, likePatternToRegExp, queryFlowMonitor } from "@nodefony/orm-core";
3
4
  //#region nodefony/src/orm-core/MongooseRepository.ts
4
5
  /**
6
+ * Clé Mongo d'un champ de TRI — `id` public → `_id` au repos.
7
+ *
8
+ * Mongo ne se plaint pas d'un tri sur un champ absent : il rend les documents
9
+ * dans un ordre arbitraire. `id` n'est qu'un virtuel de lecture ; sans cette
10
+ * traduction, `order: [["id", "DESC"]]` — le départage du tri par défaut de
11
+ * tout CRUD généré — serait silencieusement inerte ici et correct en SQL. Même
12
+ * table que les stores Mongo ({@link MONGO_ORDER_ALIASES}).
13
+ */
14
+ function sortKey(field) {
15
+ return MONGO_ORDER_ALIASES[field] ?? field;
16
+ }
17
+ /**
5
18
  * Repository portable (contrat {@link IRepository}) au-dessus d'un modèle Mongoose.
6
19
  *
7
20
  * Démontre la portabilité du contrat sur un **store documentaire hétérogène** :
@@ -19,15 +32,20 @@ var MongooseRepository = class MongooseRepository {
19
32
  #session;
20
33
  /** Connecteur ORM (clé du registre) — tag des métriques de flux. */
21
34
  #connector;
35
+ /** Champs qui référencent cette entité — `null` si personne ne la vise (cas courant, coût nul). */
36
+ #referrers;
22
37
  /**
23
38
  * @param model - modèle Mongoose compilé.
24
39
  * @param connector - nom de la connexion (clé du registre) — défaut `"nodefony"`.
25
40
  * @param session - session transactionnelle à laquelle lier les ops (ou `null`).
41
+ * @param referrers - champs d'autres entités qui référencent celle-ci (index
42
+ * inverse de {@link MongooseOrm}) ; `null` quand aucune ne la vise.
26
43
  */
27
- constructor(model, connector = "nodefony", session = null) {
44
+ constructor(model, connector = "nodefony", session = null, referrers = null) {
28
45
  this.#model = model;
29
46
  this.#connector = connector;
30
47
  this.#session = session;
48
+ this.#referrers = referrers;
31
49
  }
32
50
  /**
33
51
  * Tap dev-only : mesure la durée d'une opération et alimente **deux** sondes
@@ -77,6 +95,13 @@ var MongooseRepository = class MongooseRepository {
77
95
  * (`$eq`/`$ne`) — en Mongo `null` matche aussi le champ **absent**, ce qui est
78
96
  * bien l'équivalent du `NULL` SQL (colonne sans valeur).
79
97
  *
98
+ * `$ne`/`$nin` ne matchent JAMAIS l'absence de valeur, comme en SQL (où
99
+ * `col <> 'x'` est inconnu, donc faux, sur un NULL). Natifs, ils la
100
+ * matcheraient : `{ note: { $ne: "x" } }` ramenait sous MongoDB les documents
101
+ * sans note, que PostgreSQL écarte — la même requête rendait plus de lignes en
102
+ * production MongoDB, sans un mot. `null` est donc ajouté à l'ensemble exclu.
103
+ * Un `$nin` VIDE reste neutre (SQL : aucune exclusion, NULL compris).
104
+ *
80
105
  * @throws Error si `$null` est combiné à `$eq`/`$ne` sur le même champ : les
81
106
  * deux viseraient la même clé Mongo et l'une écraserait l'autre **en
82
107
  * silence** (le contrat les déclare exclusifs — cf `FieldOperators.$null`).
@@ -90,6 +115,17 @@ var MongooseRepository = class MongooseRepository {
90
115
  out[target] = null;
91
116
  } 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
117
  else out[key] = value;
118
+ const ne = ops.$ne;
119
+ const nin = ops.$nin;
120
+ if (ne !== void 0 && ne !== null || nin !== void 0 && nin.length > 0) {
121
+ const excluded = [...nin ?? []];
122
+ if (ne !== void 0 && ne !== null) {
123
+ excluded.push(ne);
124
+ delete out.$ne;
125
+ }
126
+ excluded.push(null);
127
+ out.$nin = excluded;
128
+ }
93
129
  return out;
94
130
  }
95
131
  /**
@@ -141,7 +177,7 @@ var MongooseRepository = class MongooseRepository {
141
177
  if (options?.relations?.length) query = query.populate(options.relations);
142
178
  if (options?.offset !== void 0) query = query.skip(options.offset);
143
179
  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])));
180
+ if (options?.order?.length) query = query.sort(Object.fromEntries(options.order.map(([field, dir]) => [sortKey(field), dir === "DESC" ? -1 : 1])));
145
181
  return (await query.exec()).map((doc) => this.#plain(doc));
146
182
  }, (docs) => docs.length);
147
183
  }
@@ -152,7 +188,7 @@ var MongooseRepository = class MongooseRepository {
152
188
  let query = this.#model.findOne(filter);
153
189
  if (this.#session) query = query.session(this.#session);
154
190
  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])));
191
+ if (options?.order?.length) query = query.sort(Object.fromEntries(options.order.map(([field, dir]) => [sortKey(field), dir === "DESC" ? -1 : 1])));
156
192
  const doc = await query.exec();
157
193
  return doc ? this.#plain(doc) : null;
158
194
  }, (doc) => doc ? 1 : 0);
@@ -238,23 +274,97 @@ var MongooseRepository = class MongooseRepository {
238
274
  }, (doc) => doc ? 1 : 0);
239
275
  }
240
276
  async delete(criteria) {
241
- const filter = this.#filter(criteria);
242
- return this.#prof(() => this.#descr("deleteMany", filter), async () => {
277
+ let filter = this.#filter(criteria);
278
+ const ids = await this.#referencedTargets(filter, false);
279
+ if (ids !== null) {
280
+ if (ids.length === 0) return 0;
281
+ filter = { _id: { $in: ids } };
282
+ }
283
+ const removed = await this.#prof(() => this.#descr("deleteMany", filter), async () => {
243
284
  return (await this.#model.deleteMany(filter, { session: this.#session ?? void 0 })).deletedCount ?? 0;
244
285
  }, (n) => n);
286
+ if (ids !== null && removed > 0) await this.#releaseOptional(ids);
287
+ return removed;
245
288
  }
246
289
  async deleteOne(criteria) {
247
- const filter = this.#filter(criteria);
248
- return this.#prof(() => this.#descr("deleteOne", filter), async () => {
290
+ let filter = this.#filter(criteria);
291
+ const ids = await this.#referencedTargets(filter, true);
292
+ if (ids !== null) {
293
+ if (ids.length === 0) return false;
294
+ filter = { _id: ids[0] };
295
+ }
296
+ const removed = await this.#prof(() => this.#descr("deleteOne", filter), async () => {
249
297
  return ((await this.#model.deleteOne(filter, { session: this.#session ?? void 0 })).deletedCount ?? 0) > 0;
250
298
  }, (ok) => ok ? 1 : 0);
299
+ if (ids !== null && removed) await this.#releaseOptional(ids);
300
+ return removed;
251
301
  }
252
302
  async findOneAndDelete(criteria) {
253
- const filter = this.#filter(criteria);
254
- return this.#prof(() => this.#descr("findOneAndDelete", filter), async () => {
255
- const doc = await this.#model.findOneAndDelete(filter, { session: this.#session ?? void 0 }).exec();
256
- return doc ? this.#plain(doc) : null;
257
- }, (doc) => doc ? 1 : 0);
303
+ let filter = this.#filter(criteria);
304
+ const ids = await this.#referencedTargets(filter, true);
305
+ if (ids !== null) {
306
+ if (ids.length === 0) return null;
307
+ filter = { _id: ids[0] };
308
+ }
309
+ const doc = await this.#prof(() => this.#descr("findOneAndDelete", filter), async () => {
310
+ const found = await this.#model.findOneAndDelete(filter, { session: this.#session ?? void 0 }).exec();
311
+ return found ? this.#plain(found) : null;
312
+ }, (d) => d ? 1 : 0);
313
+ if (ids !== null && doc !== null) await this.#releaseOptional(ids);
314
+ return doc;
315
+ }
316
+ /**
317
+ * Garde d'intégrité d'une suppression — le `restrict` que MongoDB ne tient pas.
318
+ *
319
+ * Rend `null` (et ne coûte rien) quand aucune entité ne référence celle-ci.
320
+ * Sinon, lit les `_id` que la suppression viserait, REFUSE si l'un d'eux est
321
+ * encore désigné par une référence obligatoire, et les rend : la suppression
322
+ * porte alors sur ces `_id` exacts, pas sur le filtre — ce qui a été contrôlé
323
+ * est ce qui part.
324
+ *
325
+ * ⚠️ Hors transaction, le contrôle et la suppression sont deux opérations : un
326
+ * enfant créé entre les deux n'est pas vu. Le SQL, lui, est atomique ; dans
327
+ * une transaction (`withTransaction`), les deux lisent la même session.
328
+ * ⚠️ Les `_id` visés sont tous chargés : une suppression en MASSE garde sa
329
+ * règle « un seul parent retenu bloque le lot », au prix d'une mémoire
330
+ * proportionnelle au lot. Le CRUD généré supprime par `id`, donc un à un.
331
+ *
332
+ * @param filter - filtre Mongo déjà traduit.
333
+ * @param single - vrai pour `deleteOne`/`findOneAndDelete` (un seul document).
334
+ * @returns les `_id` visés, ou `null` si l'entité n'est référencée par personne.
335
+ * @throws ReferencedEntityError si une référence obligatoire désigne l'un d'eux.
336
+ */
337
+ async #referencedTargets(filter, single) {
338
+ const referrers = this.#referrers;
339
+ if (referrers === null) return null;
340
+ const session = this.#session ?? void 0;
341
+ const query = this.#model.find(filter, { _id: 1 }, { session }).lean();
342
+ if (single) query.limit(1);
343
+ const docs = await query.exec();
344
+ if (docs.length === 0) return [];
345
+ const ids = docs.map((d) => d._id);
346
+ for (const referrer of referrers) {
347
+ if (!referrer.required) continue;
348
+ if (await referrer.model.exists(referrer.model === this.#model ? {
349
+ [referrer.path]: { $in: ids },
350
+ _id: { $nin: ids }
351
+ } : { [referrer.path]: { $in: ids } }).session(session ?? null).exec() !== null) throw new ReferencedEntityError(this.#model.modelName, referrer.model.modelName, referrer.path);
352
+ }
353
+ return ids;
354
+ }
355
+ /**
356
+ * Remet à `null` les références FACULTATIVES aux documents supprimés — le
357
+ * `set null` du SQL généré : l'enfant survit à son parent, sans pointer dans
358
+ * le vide.
359
+ *
360
+ * @param ids - `_id` des documents supprimés.
361
+ */
362
+ async #releaseOptional(ids) {
363
+ const session = this.#session ?? void 0;
364
+ for (const referrer of this.#referrers ?? []) {
365
+ if (referrer.required) continue;
366
+ await referrer.model.updateMany({ [referrer.path]: { $in: ids } }, { $set: { [referrer.path]: null } }, { session });
367
+ }
258
368
  }
259
369
  async count(criteria) {
260
370
  const filter = this.#filter(criteria);
@@ -293,7 +403,7 @@ var MongooseRepository = class MongooseRepository {
293
403
  });
294
404
  }
295
405
  withTransaction(tx) {
296
- return new MongooseRepository(this.#model, this.#connector, tx.getNative());
406
+ return new MongooseRepository(this.#model, this.#connector, tx.getNative(), this.#referrers);
297
407
  }
298
408
  };
299
409
  //#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} sont des no-op documentés (limite du
11
- * driver, prévue par le contrat).
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
- /** No-op : MongoDB ne gère pas les savepoints. */
44
- async savepoint(_name) {}
45
- /** No-op : MongoDB ne gère pas les savepoints. */
46
- async rollbackTo(_name) {}
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;
@@ -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";