@classytic/mongokit 3.39.0 → 3.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +71 -0
  2. package/dist/{PaginationEngine-D-P19Y9m.d.mts → PaginationEngine-CRHyuUtw.d.mts} +17 -3
  3. package/dist/{PaginationEngine-DQ7beTCR.mjs → PaginationEngine-CfT2Cxkj.mjs} +320 -145
  4. package/dist/{Repository-D7fDpyFx.mjs → Repository-CYhSC9ut.mjs} +844 -794
  5. package/dist/{Repository-IxI983_m.d.mts → Repository-pnLw1rG5.d.mts} +113 -47
  6. package/dist/actions/index.d.mts +1 -1
  7. package/dist/actions/index.mjs +1 -1
  8. package/dist/adapter/index.mjs +1 -1
  9. package/dist/{adapter-BrVfPFSW.mjs → adapter-CkMoMF83.mjs} +1 -1
  10. package/dist/ai/index.d.mts +1 -1
  11. package/dist/better-auth/index.mjs +2 -2
  12. package/dist/{create-repository-CRXjDa3N.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
  13. package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BYyWzLbt.mjs} +2 -2
  14. package/dist/idempotency/index.d.mts +23 -0
  15. package/dist/idempotency/index.mjs +134 -0
  16. package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
  17. package/dist/index.d.mts +105 -39
  18. package/dist/index.mjs +332 -676
  19. package/dist/indexes/verify-indexes.d.mts +14 -1
  20. package/dist/indexes/verify-indexes.mjs +54 -7
  21. package/dist/join-scope-DSAznE8E.mjs +168 -0
  22. package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
  23. package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +19 -3
  24. package/dist/{operations-CrRIoc8Z.d.mts → operations-XiFgG5US.d.mts} +36 -13
  25. package/dist/outbox/index.d.mts +1 -1
  26. package/dist/outbox/index.mjs +6 -4
  27. package/dist/pagination/PaginationEngine.d.mts +2 -2
  28. package/dist/pagination/PaginationEngine.mjs +1 -1
  29. package/dist/pagination/utils/limits.d.mts +1 -1
  30. package/dist/{pagination-BpIv2bdS.d.mts → pagination-D6psses0.d.mts} +116 -13
  31. package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-CRhIvSzx.d.mts} +16 -2
  32. package/dist/plugins/index.d.mts +8 -3
  33. package/dist/plugins/index.mjs +98 -25
  34. package/dist/query-defaults-BxUcSOoF.mjs +208 -0
  35. package/dist/read-model/index.d.mts +66 -0
  36. package/dist/read-model/index.mjs +193 -0
  37. package/dist/{repository-ZbLtL7Nk.d.mts → repository-DNHfjAZX.d.mts} +36 -4
  38. package/dist/sync/index.d.mts +53 -0
  39. package/dist/sync/index.mjs +371 -0
  40. package/dist/tenant/index.mjs +2 -0
  41. package/dist/testkit/index.d.mts +40 -3
  42. package/dist/testkit/index.mjs +113 -2
  43. package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-zfdLYZiO.mjs} +135 -32
  44. package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
  45. package/dist/transaction-BrRYtH_b.mjs +655 -0
  46. package/dist/{update-c_01A0if.mjs → update-WgeAUJ_I.mjs} +212 -33
  47. package/dist/utils/index.d.mts +1 -1
  48. package/dist/utils/index.mjs +1 -1
  49. package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-CHUHWMtk.mjs} +204 -279
  50. package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +27 -5
  51. package/package.json +18 -4
@@ -0,0 +1,371 @@
1
+ import { i as withTransaction } from "../transaction-BrRYtH_b.mjs";
2
+ import { Schema } from "mongoose";
3
+ //#region src/sync/index.ts
4
+ function createChangeLogModels(connection, options = {}) {
5
+ const { collection = "sync_changes", modelName = "SyncChange" } = options;
6
+ const entriesName = modelName;
7
+ const counterName = `${modelName}Counter`;
8
+ return {
9
+ entries: connection.models[entriesName] ?? connection.model(entriesName, (() => {
10
+ const schema = new Schema({
11
+ seq: {
12
+ type: Number,
13
+ required: true
14
+ },
15
+ scope: {
16
+ type: String,
17
+ required: true
18
+ },
19
+ docId: {
20
+ type: String,
21
+ required: true
22
+ },
23
+ op: {
24
+ type: String,
25
+ enum: ["upsert", "delete"],
26
+ required: true
27
+ },
28
+ version: {
29
+ type: Number,
30
+ required: true
31
+ },
32
+ doc: { type: Schema.Types.Mixed },
33
+ tenantId: { type: String },
34
+ at: {
35
+ type: Date,
36
+ required: true
37
+ }
38
+ }, {
39
+ collection,
40
+ versionKey: false,
41
+ minimize: false
42
+ });
43
+ schema.index({ seq: 1 }, {
44
+ unique: true,
45
+ name: "feed_order"
46
+ });
47
+ schema.index({
48
+ tenantId: 1,
49
+ scope: 1,
50
+ seq: 1
51
+ }, { name: "tenant_scope_feed" });
52
+ schema.index({
53
+ scope: 1,
54
+ docId: 1,
55
+ seq: 1
56
+ }, { name: "doc_versions" });
57
+ return schema;
58
+ })()),
59
+ counter: connection.models[counterName] ?? connection.model(counterName, new Schema({
60
+ _id: {
61
+ type: String,
62
+ required: true
63
+ },
64
+ seq: {
65
+ type: Number,
66
+ required: true
67
+ }
68
+ }, {
69
+ collection: `${collection}_counter`,
70
+ versionKey: false
71
+ })),
72
+ feed: collection
73
+ };
74
+ }
75
+ /** Fixed-width, so the opaque cursor also sorts as text. */
76
+ const toCursor = (seq) => String(seq).padStart(16, "0");
77
+ const fromCursor = (cursor) => {
78
+ if (cursor === "") return 0;
79
+ const seq = Number(cursor);
80
+ if (!Number.isSafeInteger(seq) || seq < 0) throw new Error(`[mongokit:sync] "${cursor}" is not a cursor from this feed`);
81
+ return seq;
82
+ };
83
+ const DEFAULT_LIMIT = 500;
84
+ function createChangeLogStore(models) {
85
+ const { entries, counter, feed } = models;
86
+ const sessionOf = (options) => options?.session ? { session: options.session } : {};
87
+ const filterOf = (after, options = {}) => {
88
+ const { tenantId, scopes, sharedScopes } = options;
89
+ const tenant = tenantId === void 0 ? {} : sharedScopes?.length ? { $or: [{ tenantId }, { scope: { $in: [...sharedScopes] } }] } : { tenantId };
90
+ return {
91
+ seq: { $gt: after },
92
+ ...tenant,
93
+ ...scopes ? { scope: { $in: [...scopes] } } : {}
94
+ };
95
+ };
96
+ const toEntry = (row) => ({
97
+ scope: row.scope,
98
+ docId: row.docId,
99
+ op: row.op,
100
+ version: row.version,
101
+ ...row.doc !== void 0 && row.doc !== null ? { doc: row.doc } : {},
102
+ ...typeof row.tenantId === "string" ? { tenantId: row.tenantId } : {},
103
+ at: row.at,
104
+ cursor: toCursor(row.seq)
105
+ });
106
+ return {
107
+ async append(entry, options) {
108
+ const session = sessionOf(options);
109
+ const seq = (await counter.findOneAndUpdate({ _id: feed }, { $inc: { seq: 1 } }, {
110
+ upsert: true,
111
+ returnDocument: "after",
112
+ lean: true,
113
+ ...session
114
+ }).exec()).seq;
115
+ const at = /* @__PURE__ */ new Date();
116
+ await entries.create([{
117
+ seq,
118
+ scope: entry.scope,
119
+ docId: entry.docId,
120
+ op: entry.op,
121
+ version: entry.version,
122
+ ...entry.op === "upsert" && entry.doc !== void 0 ? { doc: entry.doc } : {},
123
+ ...entry.tenantId !== void 0 ? { tenantId: entry.tenantId } : {},
124
+ at
125
+ }], session);
126
+ return {
127
+ ...entry,
128
+ at,
129
+ cursor: toCursor(seq)
130
+ };
131
+ },
132
+ async since(cursor, options = {}) {
133
+ const limit = options.limit ?? DEFAULT_LIMIT;
134
+ const rows = await entries.find(filterOf(fromCursor(cursor), options)).sort({ seq: 1 }).limit(limit + 1).lean().exec();
135
+ const page = rows.slice(0, limit).map(toEntry);
136
+ const last = page[page.length - 1];
137
+ return {
138
+ changes: page,
139
+ cursor: last ? last.cursor : cursor,
140
+ hasMore: rows.length > limit
141
+ };
142
+ },
143
+ async latestCursor(options = {}) {
144
+ const row = await entries.findOne(filterOf(0, options)).sort({ seq: -1 }).select({ seq: 1 }).lean().exec();
145
+ return row?.seq ? toCursor(row.seq) : "";
146
+ },
147
+ async compactSuperseded(options = {}) {
148
+ const batchSize = options.batchSize ?? 1e3;
149
+ const groups = entries.aggregate([
150
+ { $group: {
151
+ _id: {
152
+ scope: "$scope",
153
+ docId: "$docId"
154
+ },
155
+ latest: { $max: "$seq" },
156
+ n: { $sum: 1 }
157
+ } },
158
+ { $match: { n: { $gt: 1 } } },
159
+ { $project: { latest: 1 } }
160
+ ]).allowDiskUse(true).cursor({ batchSize });
161
+ let removed = 0;
162
+ let ops = [];
163
+ const flush = async () => {
164
+ if (ops.length === 0) return;
165
+ removed += (await entries.bulkWrite(ops, { ordered: false })).deletedCount;
166
+ ops = [];
167
+ };
168
+ for await (const g of groups) {
169
+ ops.push({ deleteMany: { filter: {
170
+ scope: g._id.scope,
171
+ docId: g._id.docId,
172
+ seq: { $lt: g.latest }
173
+ } } });
174
+ if (ops.length >= batchSize) await flush();
175
+ }
176
+ await flush();
177
+ return { removed };
178
+ }
179
+ };
180
+ }
181
+ function createCommandStreamModels(connection, options = {}) {
182
+ const { prefix = "sync", modelName = "SyncStream" } = options;
183
+ const model = (name, schema) => connection.models[name] ?? connection.model(name, schema());
184
+ return {
185
+ streams: model(modelName, () => new Schema({
186
+ _id: {
187
+ type: String,
188
+ required: true
189
+ },
190
+ tenantId: {
191
+ type: String,
192
+ required: true
193
+ },
194
+ processed: {
195
+ type: Number,
196
+ required: true
197
+ }
198
+ }, {
199
+ collection: `${prefix}_streams`,
200
+ versionKey: false
201
+ })),
202
+ verdicts: model(`${modelName}Verdict`, () => {
203
+ const schema = new Schema({
204
+ _id: {
205
+ type: String,
206
+ required: true
207
+ },
208
+ stream: {
209
+ type: String,
210
+ required: true
211
+ },
212
+ seq: {
213
+ type: Number,
214
+ required: true
215
+ },
216
+ outcome: {
217
+ type: String,
218
+ enum: [
219
+ "applied",
220
+ "rejected",
221
+ "blocked",
222
+ "resolved"
223
+ ],
224
+ required: true
225
+ },
226
+ code: { type: String },
227
+ fingerprint: {
228
+ type: String,
229
+ required: true
230
+ },
231
+ body: { type: String },
232
+ dependsOn: {
233
+ type: [String],
234
+ default: void 0
235
+ },
236
+ resolution: {
237
+ type: {
238
+ action: String,
239
+ by: String,
240
+ at: String,
241
+ _id: false
242
+ },
243
+ default: void 0
244
+ }
245
+ }, {
246
+ collection: `${prefix}_verdicts`,
247
+ versionKey: false
248
+ });
249
+ schema.index({
250
+ stream: 1,
251
+ seq: 1
252
+ }, {
253
+ unique: true,
254
+ name: "stream_seq"
255
+ });
256
+ schema.index({
257
+ dependsOn: 1,
258
+ outcome: 1
259
+ }, {
260
+ name: "blocked_on",
261
+ partialFilterExpression: { outcome: "blocked" }
262
+ });
263
+ return schema;
264
+ }),
265
+ aliases: model(`${modelName}Alias`, () => {
266
+ const schema = new Schema({
267
+ tenantId: {
268
+ type: String,
269
+ required: true
270
+ },
271
+ localId: {
272
+ type: String,
273
+ required: true
274
+ },
275
+ serverId: {
276
+ type: String,
277
+ required: true
278
+ }
279
+ }, {
280
+ collection: `${prefix}_aliases`,
281
+ versionKey: false
282
+ });
283
+ schema.index({
284
+ tenantId: 1,
285
+ localId: 1
286
+ }, {
287
+ unique: true,
288
+ name: "tenant_local"
289
+ });
290
+ return schema;
291
+ })
292
+ };
293
+ }
294
+ /**
295
+ * The mongo {@link CommandStreamStore} (replica set). Proven by `runCommandStreamStoreConformance`.
296
+ * Concurrent transactions on one stream write-conflict on its document and `withTransaction`
297
+ * retries the loser, which then reads the winner's watermark.
298
+ */
299
+ function createCommandStreamStore(connection, models, changes) {
300
+ const { streams, verdicts, aliases } = models;
301
+ const toVerdict = ({ _id, code, ...rest }) => ({
302
+ commandId: _id,
303
+ ...rest,
304
+ ...code == null ? {} : { code }
305
+ });
306
+ return {
307
+ changes,
308
+ transaction: (work) => withTransaction(connection, (session) => work({ session })),
309
+ async stream({ session }, id) {
310
+ const row = await streams.findById(id).session(session).lean().exec();
311
+ return row ? {
312
+ tenantId: row.tenantId,
313
+ processed: row.processed
314
+ } : null;
315
+ },
316
+ async advance({ session }, id, tenantId, from, to) {
317
+ if (from === 0) {
318
+ if (await streams.exists({ _id: id }).session(session)) return false;
319
+ await streams.create([{
320
+ _id: id,
321
+ tenantId,
322
+ processed: to
323
+ }], { session });
324
+ return true;
325
+ }
326
+ return (await streams.updateOne({
327
+ _id: id,
328
+ processed: from
329
+ }, { $set: { processed: to } }, { session }).exec()).modifiedCount === 1;
330
+ },
331
+ async verdict({ session }, commandId) {
332
+ const row = await verdicts.findById(commandId).session(session).lean().exec();
333
+ return row ? toVerdict(row) : null;
334
+ },
335
+ async record({ session }, { commandId, ...rest }) {
336
+ await verdicts.replaceOne({ _id: commandId }, rest, {
337
+ upsert: true,
338
+ session
339
+ }).exec();
340
+ },
341
+ async blockedOn({ session }, commandId) {
342
+ return (await verdicts.find({
343
+ outcome: "blocked",
344
+ dependsOn: commandId
345
+ }).sort({ seq: 1 }).session(session).lean().exec()).map(toVerdict);
346
+ },
347
+ async alias({ session }, { tenantId, localId, serverId }) {
348
+ const existing = await aliases.findOne({
349
+ tenantId,
350
+ localId
351
+ }).session(session).lean().exec();
352
+ if (existing) {
353
+ if (existing.serverId !== serverId) throw new Error(`[mongokit:sync] "${localId}" already names "${existing.serverId}"`);
354
+ return;
355
+ }
356
+ await aliases.create([{
357
+ tenantId,
358
+ localId,
359
+ serverId
360
+ }], { session });
361
+ },
362
+ async aliasOf({ session }, tenantId, localId) {
363
+ return (await aliases.findOne({
364
+ tenantId,
365
+ localId
366
+ }).session(session).lean().exec())?.serverId ?? null;
367
+ }
368
+ };
369
+ }
370
+ //#endregion
371
+ export { createChangeLogModels, createChangeLogStore, createCommandStreamModels, createCommandStreamStore };
@@ -64,6 +64,8 @@ function declareIndexes(schema, tenant, declarations) {
64
64
  */
65
65
  function injectTenantField(schema, tenant, options = {}) {
66
66
  for (const declaration of options.indexes ?? []) assertIndexDeclaration(declaration, tenant);
67
+ const declaredNames = new Set(indexEntries(schema).map(([, o]) => o?.name).filter((n) => typeof n === "string"));
68
+ for (const name of options.skipIndexes ?? []) if (!declaredNames.has(name)) throw new Error(`[mongokit/tenant] skipIndexes names '${name}', but no index on this schema has that name — rename the exemption with the index, or the index silently gets the tenant prefix`);
67
69
  const isObjectId = tenant.fieldType === "objectId";
68
70
  const scoped = isScoped(tenant);
69
71
  schema.add({ [tenant.tenantField]: {
@@ -1,7 +1,44 @@
1
- import { n as Repository } from "../Repository-IxI983_m.mjs";
2
- import { n as CreateRepositoryConfig } from "../create-repository-CRXjDa3N.mjs";
1
+ import { r as Repository } from "../Repository-pnLw1rG5.mjs";
2
+ import { n as CreateRepositoryConfig } from "../create-repository-BUx9vfG9.mjs";
3
3
  import { Connection, Model, Schema } from "mongoose";
4
4
  import { MongoMemoryReplSet, MongoMemoryServer } from "mongodb-memory-server";
5
+ //#region src/testkit/query-plan.d.ts
6
+ interface RecordedCommand {
7
+ name: string;
8
+ collection: string | undefined;
9
+ command: Record<string, unknown>;
10
+ }
11
+ /** Run `fn` and return every command it started on `connection` (in start order). */
12
+ declare function recordCommands<T>(connection: Connection, fn: () => Promise<T>): Promise<{
13
+ result: T;
14
+ commands: RecordedCommand[];
15
+ }>;
16
+ interface QueryPlanOptions {
17
+ connection: Connection;
18
+ /** Plan stages that fail the gate. Default `['COLLSCAN', 'SORT']` (a scan, an unindexed sort). */
19
+ forbid?: readonly string[];
20
+ /** The examined collection must hold at least this many documents. */
21
+ minDocs: number;
22
+ /** Every index scan must lead with these keys (directly, or after one equality scope field). */
23
+ leadingKeys?: readonly string[];
24
+ /** Only gate commands on these collections (default: every plannable command). */
25
+ collections?: readonly string[];
26
+ }
27
+ interface PlannedCommand {
28
+ name: string;
29
+ collection: string;
30
+ stages: string[];
31
+ indexes: string[][];
32
+ }
33
+ /**
34
+ * Run `run`, explain every read it issued and throw on a forbidden stage, a wrong leading index,
35
+ * an undersized fixture, or no plannable command at all. Returns what each command used.
36
+ */
37
+ declare function assertQueryPlan<T>(run: () => Promise<T>, options: QueryPlanOptions): Promise<{
38
+ result: T;
39
+ plans: PlannedCommand[];
40
+ }>;
41
+ //#endregion
5
42
  //#region src/testkit/index.d.ts
6
43
  interface MongoMemoryOptions {
7
44
  /**
@@ -132,4 +169,4 @@ interface MongoMemoryBackend {
132
169
  */
133
170
  declare function mongoMemoryBackend(options?: MongoMemoryOptions): MongoMemoryBackend;
134
171
  //#endregion
135
- export { CreateTestRepositoryOptions, MongoMemoryBackend, MongoMemoryHandle, MongoMemoryOptions, MongoTestContext, TestConnection, TestRepository, clearCollections, createMongoMemory, createTestConnection, createTestRepository, mongoMemoryBackend, withMongoMemory };
172
+ export { CreateTestRepositoryOptions, MongoMemoryBackend, MongoMemoryHandle, MongoMemoryOptions, MongoTestContext, type PlannedCommand, type QueryPlanOptions, type RecordedCommand, TestConnection, TestRepository, assertQueryPlan, clearCollections, createMongoMemory, createTestConnection, createTestRepository, mongoMemoryBackend, recordCommands, withMongoMemory };
@@ -1,5 +1,116 @@
1
- import { t as createRepository } from "../create-repository-DOe9E_5F.mjs";
1
+ import { t as createRepository } from "../create-repository-BYyWzLbt.mjs";
2
2
  import { createConnection } from "mongoose";
3
+ //#region src/testkit/query-plan.ts
4
+ const PLANNABLE = /* @__PURE__ */ new Set([
5
+ "find",
6
+ "aggregate",
7
+ "count",
8
+ "distinct"
9
+ ]);
10
+ /** Session/transport fields the server refuses inside an `explain`. */
11
+ const STRIP = /* @__PURE__ */ new Set([
12
+ "lsid",
13
+ "txnNumber",
14
+ "autocommit",
15
+ "startTransaction",
16
+ "readConcern",
17
+ "maxTimeMS"
18
+ ]);
19
+ function assertMonitored(connection) {
20
+ if (connection.getClient().options.monitorCommands !== true) throw new Error("[mongokit/testkit] the connection was not opened with { monitorCommands: true }: no command can be observed");
21
+ }
22
+ /** Run `fn` and return every command it started on `connection` (in start order). */
23
+ async function recordCommands(connection, fn) {
24
+ assertMonitored(connection);
25
+ const client = connection.getClient();
26
+ const commands = [];
27
+ const listener = (event) => {
28
+ const target = event.command[event.commandName];
29
+ commands.push({
30
+ name: event.commandName,
31
+ collection: typeof target === "string" ? target : void 0,
32
+ command: event.command
33
+ });
34
+ };
35
+ client.on("commandStarted", listener);
36
+ try {
37
+ return {
38
+ result: await fn(),
39
+ commands
40
+ };
41
+ } finally {
42
+ client.off("commandStarted", listener);
43
+ }
44
+ }
45
+ function collectPlanNodes(node, out) {
46
+ if (Array.isArray(node)) {
47
+ for (const n of node) collectPlanNodes(n, out);
48
+ return;
49
+ }
50
+ if (!node || typeof node !== "object") return;
51
+ const obj = node;
52
+ if (typeof obj.stage === "string") out.push(obj);
53
+ for (const [key, value] of Object.entries(obj)) {
54
+ if (key === "rejectedPlans" || key === "allPlansExecution") continue;
55
+ collectPlanNodes(value, out);
56
+ }
57
+ }
58
+ function explainable(command) {
59
+ return Object.fromEntries(Object.entries(command).filter(([k]) => !k.startsWith("$") && !STRIP.has(k)));
60
+ }
61
+ /**
62
+ * Run `run`, explain every read it issued and throw on a forbidden stage, a wrong leading index,
63
+ * an undersized fixture, or no plannable command at all. Returns what each command used.
64
+ */
65
+ async function assertQueryPlan(run, options) {
66
+ const forbid = options.forbid ?? ["COLLSCAN", "SORT"];
67
+ const { result, commands } = await recordCommands(options.connection, run);
68
+ const db = options.connection.db;
69
+ if (!db) throw new Error("[mongokit/testkit] connection has no db handle");
70
+ const plannable = commands.filter((c) => PLANNABLE.has(c.name) && c.collection !== void 0 && (!options.collections || options.collections.includes(c.collection)));
71
+ if (plannable.length === 0) throw new Error(`[mongokit/testkit] assertQueryPlan saw no plannable command (saw: ${commands.map((c) => c.name).join(", ") || "none"})`);
72
+ const plans = [];
73
+ const failures = [];
74
+ for (const c of plannable) {
75
+ const collection = c.collection;
76
+ const size = await db.collection(collection).countDocuments({});
77
+ if (size < options.minDocs) throw new Error(`[mongokit/testkit] '${collection}' holds ${size} documents, below minDocs ${options.minDocs}: the plan would not be representative`);
78
+ const explained = await db.command({
79
+ explain: explainable(c.command),
80
+ verbosity: "executionStats"
81
+ });
82
+ const nodes = [];
83
+ collectPlanNodes(explained.queryPlanner ?? explained.stages ?? explained, nodes);
84
+ const stages = nodes.map((n) => n.stage);
85
+ const indexes = nodes.filter((n) => n.keyPattern && typeof n.keyPattern === "object").map((n) => Object.keys(n.keyPattern));
86
+ plans.push({
87
+ name: c.name,
88
+ collection,
89
+ stages,
90
+ indexes
91
+ });
92
+ for (const stage of forbid) if (stages.includes(stage)) failures.push(`${c.name} on '${collection}': plan contains ${stage}`);
93
+ if (options.leadingKeys) {
94
+ const want = options.leadingKeys;
95
+ const leads = (key, at) => want.every((k, i) => key[at + i] === k);
96
+ if (indexes.length === 0) failures.push(`${c.name} on '${collection}': no index used, expected (${want.join(",")})`);
97
+ for (const key of indexes) if (!leads(key, 0) && !leads(key, 1)) failures.push(`${c.name} on '${collection}': index (${key.join(",")}) does not lead with (${want.join(",")})`);
98
+ }
99
+ }
100
+ if (failures.length > 0) {
101
+ const error = /* @__PURE__ */ new Error(`[mongokit/testkit] query plan gate failed:\n ${failures.join("\n ")}`);
102
+ Object.assign(error, {
103
+ code: "mongokit.testkit.query_plan",
104
+ plans
105
+ });
106
+ throw error;
107
+ }
108
+ return {
109
+ result,
110
+ plans
111
+ };
112
+ }
113
+ //#endregion
3
114
  //#region src/testkit/index.ts
4
115
  function idempotentStop(server) {
5
116
  let stopped = false;
@@ -136,4 +247,4 @@ function mongoMemoryBackend(options = {}) {
136
247
  } };
137
248
  }
138
249
  //#endregion
139
- export { clearCollections, createMongoMemory, createTestConnection, createTestRepository, mongoMemoryBackend, withMongoMemory };
250
+ export { assertQueryPlan, clearCollections, createMongoMemory, createTestConnection, createTestRepository, mongoMemoryBackend, recordCommands, withMongoMemory };