@nodefony/mongoose 10.0.0-alpha.8 → 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.
- 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 +10 -4
- 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 +41 -4
- package/dist/nodefony/service/MongooseService.js +6 -3
- package/dist/nodefony/src/MongooseAuditStore.js +213 -0
- package/dist/nodefony/src/MongooseIdempotencyStore.js +260 -0
- package/dist/nodefony/src/MongooseTokenStore.js +60 -17
- package/dist/nodefony/src/MongooseTotpSecretStore.js +151 -0
- 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 +62 -12
- package/dist/nodefony/src/orm-core/MongooseRepository.js +124 -14
- package/dist/nodefony/src/orm-core/MongooseTransaction.js +19 -6
- 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/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/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/docs/configuration.md +22 -22
- package/docs/index.md +107 -63
- package/package.json +21 -17
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
366
|
-
* la clé primaire
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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}
|
|
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;
|
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";
|