@classytic/mongokit 3.43.0 → 3.44.0

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 (92) hide show
  1. package/README.md +14 -0
  2. package/dist/{PaginationEngine-DZnkbSrr.mjs → PaginationEngine-C9iS0U5l.mjs} +13 -27
  3. package/dist/{PaginationEngine-CRHyuUtw.d.mts → PaginationEngine-D17AthKw.d.mts} +1 -3
  4. package/dist/{Repository-DnL4xk5w.d.mts → Repository-B6oL6OMv.d.mts} +33 -11
  5. package/dist/{Repository-Dj6xqPxJ.mjs → Repository-eMRYZ_2Y.mjs} +204 -92
  6. package/dist/actions/index.d.mts +1 -1
  7. package/dist/actions/index.mjs +2 -12
  8. package/dist/actions-DExyXMTy.mjs +11 -0
  9. package/dist/adapter/index.d.mts +6 -10
  10. package/dist/adapter/index.mjs +1 -2
  11. package/dist/{adapter-CkMoMF83.mjs → adapter-Qzgc3ZD_.mjs} +4 -6
  12. package/dist/ai/index.d.mts +21 -8
  13. package/dist/ai/index.mjs +15 -9
  14. package/dist/better-auth/index.d.mts +18 -32
  15. package/dist/better-auth/index.mjs +3 -4
  16. package/dist/{coercion-BjdPX3YF.d.mts → coercion-COp9DRNw.d.mts} +0 -2
  17. package/dist/{create-repository-CJtVdwig.d.mts → create-repository-BOFUYj1l.d.mts} +5 -7
  18. package/dist/{create-repository-UjwPWR1z.mjs → create-repository-D7MQpIZo.mjs} +4 -5
  19. package/dist/{derive-DRBiegRH.mjs → derive-Dxsc31fV.mjs} +3 -6
  20. package/dist/{error-BuByZwKU.mjs → error-DjdmPLWO.mjs} +1 -2
  21. package/dist/{field-selection-FSQFLpsU.mjs → field-selection-DPFaFw9Y.mjs} +1 -2
  22. package/dist/{id-chunks-BmAwsqNk.mjs → id-chunks-DH-RAZWG.mjs} +13 -8
  23. package/dist/{id-resolution-BqDrk0S2.mjs → id-resolution-Cqh49d9J.mjs} +1 -2
  24. package/dist/idempotency/index.d.mts +4 -7
  25. package/dist/idempotency/index.mjs +1 -2
  26. package/dist/{index-SYF8N28D.d.mts → index-8QUEsOFI.d.mts} +3 -4
  27. package/dist/{index-C0AjVqX3.d.mts → index-ehGZLbrd.d.mts} +2 -8
  28. package/dist/index.d.mts +63 -96
  29. package/dist/index.mjs +36 -52
  30. package/dist/indexes/verify-indexes.d.mts +8 -11
  31. package/dist/indexes/verify-indexes.mjs +1 -2
  32. package/dist/{indexes-DIjMpYES.d.mts → indexes-FfyOuhDK.d.mts} +0 -2
  33. package/dist/{join-scope-DSAznE8E.mjs → join-scope-C1gOtZ59.mjs} +2 -3
  34. package/dist/kernel-conformance/index.d.mts +39 -13
  35. package/dist/kernel-conformance/index.mjs +4 -7
  36. package/dist/lock/index.d.mts +134 -7
  37. package/dist/lock/index.mjs +349 -14
  38. package/dist/{logger-K7dPmu2U.mjs → logger-hbWOlj_X.mjs} +1 -2
  39. package/dist/{method-registry.plugin-C63pGn1d.mjs → method-registry.plugin-CGAepsBU.mjs} +3 -5
  40. package/dist/{model-registry-ClKtWiHa.mjs → model-registry-DrVfJ3Nt.mjs} +1 -2
  41. package/dist/{mongooseToJsonSchema-C3k4ide0.mjs → mongooseToJsonSchema-DMDotz4_.mjs} +2 -3
  42. package/dist/{multi-tenant.plugin-B_q9NUHc.d.mts → multi-tenant.plugin-DfeDMiI8.d.mts} +143 -11
  43. package/dist/{operations-XiFgG5US.d.mts → operations-Dw9OFV0V.d.mts} +14 -5
  44. package/dist/outbox/index.d.mts +24 -9
  45. package/dist/outbox/index.mjs +17 -6
  46. package/dist/pagination/PaginationEngine.d.mts +1 -1
  47. package/dist/pagination/PaginationEngine.mjs +1 -2
  48. package/dist/pagination/utils/limits.d.mts +6 -9
  49. package/dist/pagination/utils/limits.mjs +2 -3
  50. package/dist/{pagination-D6psses0.d.mts → pagination-DAUg5zOY.d.mts} +2 -10
  51. package/dist/{plugin-options-Cirmb4Kp.d.mts → plugin-options-Bpcuz9kS.d.mts} +2 -4
  52. package/dist/plugins/index.d.mts +6 -7
  53. package/dist/plugins/index.mjs +6 -7
  54. package/dist/query/primitives/coercion.d.mts +1 -1
  55. package/dist/query/primitives/coercion.mjs +2 -3
  56. package/dist/query/primitives/geo.d.mts +11 -14
  57. package/dist/query/primitives/geo.mjs +1 -2
  58. package/dist/query/primitives/indexes.d.mts +1 -1
  59. package/dist/query/primitives/indexes.mjs +1 -2
  60. package/dist/{query-defaults-BxUcSOoF.mjs → query-defaults-aUoKlEbh.mjs} +2 -3
  61. package/dist/read-model/index.d.mts +18 -21
  62. package/dist/read-model/index.mjs +3 -4
  63. package/dist/{repository-MqAP409e.d.mts → repository-DzVIwhU2.d.mts} +25 -29
  64. package/dist/retention-purge-DqcYvRnG.d.mts +35 -0
  65. package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-D8jQpDG7.mjs} +1 -2
  66. package/dist/search/index.d.mts +1 -1
  67. package/dist/search/index.mjs +1 -2
  68. package/dist/sync/index.d.mts +95 -15
  69. package/dist/sync/index.mjs +308 -62
  70. package/dist/tenant/index.d.mts +8 -11
  71. package/dist/tenant/index.mjs +1 -2
  72. package/dist/testkit/index.d.mts +179 -23
  73. package/dist/testkit/index.mjs +421 -23
  74. package/dist/{timestamp.plugin-DpOWavVI.mjs → timestamp.plugin-Bz_zL9PX.mjs} +192 -49
  75. package/dist/{to-plain-vksRrLtc.mjs → to-plain-CEKv_ylg.mjs} +2 -4
  76. package/dist/{to-plain-o5tUY2nV.d.mts → to-plain-PO5r1Qoy.d.mts} +2 -16
  77. package/dist/{transaction-DmiB4k2P.mjs → transaction-C2PFxfIv.mjs} +5 -8
  78. package/dist/{type-utils-CLzRFtIA.d.mts → type-utils-CKvuewAL.d.mts} +0 -2
  79. package/dist/{update-WgeAUJ_I.mjs → update-DcfmNbJe.mjs} +12 -20
  80. package/dist/usage/index.d.mts +2 -5
  81. package/dist/usage/index.mjs +8 -6
  82. package/dist/utils/index.d.mts +8 -10
  83. package/dist/utils/index.mjs +7 -7
  84. package/dist/{validation-chain.plugin-BAeC5eVT.d.mts → validation-chain.plugin-B3gZCZj4.d.mts} +135 -48
  85. package/dist/{validation-chain.plugin-DRApTmkN.mjs → validation-chain.plugin-DNY6MIF6.mjs} +207 -55
  86. package/llms.txt +33 -0
  87. package/package.json +29 -25
  88. package/skills/mongokit/SKILL.md +112 -0
  89. package/skills/mongokit/references/pagination-search.md +87 -0
  90. package/skills/mongokit/references/plugins-tenancy.md +102 -0
  91. package/skills/mongokit/references/repository-api.md +83 -0
  92. package/skills/mongokit/references/transactions-outbox.md +101 -0
@@ -1,12 +1,343 @@
1
- import { t as createRepository } from "../create-repository-UjwPWR1z.mjs";
1
+ import { t as createRepository } from "../create-repository-D7MQpIZo.mjs";
2
2
  import { createConnection } from "mongoose";
3
- //#region src/testkit/query-plan.ts
3
+ import { rmSync } from "node:fs";
4
+ import { mkdir, mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ // src/testkit/mongo-tmp.ts
8
+ /** Prefix mongodb-memory-server gives its data dirs (and that ours use). */
9
+ const MONGO_MEM_DIR_PREFIX = "mongo-mem-";
10
+ /** Replica-set keyfile dirs: no lock file to prove them dead, so never swept. */
11
+ const KEYFILE_DIR_PREFIX = "mongo-mem-keyfile-";
12
+ const HOUR_MS = 36e5;
13
+ const DEFAULT_MAX_AGE_HOURS = 6;
14
+ /** The dedicated base dir from `TEST_MONGO_TMPDIR`, resolved, or undefined. */
15
+ function mongoMemoryTmpBase() {
16
+ const raw = process.env.TEST_MONGO_TMPDIR?.trim();
17
+ return raw ? resolve(raw) : void 0;
18
+ }
19
+ /** Minimum age (ms) of a dir the sweep may remove; `null` when the sweep is off. */
20
+ function staleMongoMemoryAgeMs() {
21
+ const raw = process.env.TEST_MONGO_SWEEP_HOURS?.trim().toLowerCase();
22
+ if (raw === "0" || raw === "off" || raw === "false") return null;
23
+ const hours = Number(raw);
24
+ return raw && Number.isFinite(hours) && hours > 0 ? hours * HOUR_MS : DEFAULT_MAX_AGE_HOURS * HOUR_MS;
25
+ }
26
+ /** Whether a process with this pid exists. EPERM means it exists but is not ours. */
27
+ function isPidAlive(pid) {
28
+ if (!Number.isInteger(pid) || pid <= 0) return false;
29
+ try {
30
+ process.kill(pid, 0);
31
+ return true;
32
+ } catch (err) {
33
+ return err.code === "EPERM";
34
+ }
35
+ }
36
+ /** The pid in a data dir's `mongod.lock`. mongod empties it on a clean shutdown. */
37
+ async function lockPid(dir) {
38
+ try {
39
+ const pid = Number.parseInt((await readFile(join(dir, "mongod.lock"), "utf8")).trim(), 10);
40
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
41
+ } catch {
42
+ return;
43
+ }
44
+ }
45
+ /**
46
+ * Newest mtime of the dir and its direct entries. The dir's own mtime changes
47
+ * only when an entry is added or removed; a live mongod's activity shows in its
48
+ * files (WiredTiger.wt, sizeStorer.wt, the journal), so those count too.
49
+ */
50
+ async function newestMtimeMs(dir) {
51
+ let newest = (await stat(dir)).mtimeMs;
52
+ let names = [];
53
+ try {
54
+ names = await readdir(dir);
55
+ } catch {}
56
+ for (const name of names) try {
57
+ const { mtimeMs } = await stat(join(dir, name));
58
+ if (mtimeMs > newest) newest = mtimeMs;
59
+ } catch {}
60
+ return newest;
61
+ }
62
+ function defaultSweepDirs() {
63
+ const base = mongoMemoryTmpBase();
64
+ return base ? [tmpdir(), base] : [tmpdir()];
65
+ }
66
+ /**
67
+ * Remove abandoned `mongo-mem-*` data dirs.
68
+ *
69
+ * A dir is removed only when BOTH hold: its `mongod.lock` names no live process
70
+ * (or is empty or missing), and nothing in it changed for `maxAgeMs`. Keyfile
71
+ * dirs and anything without the prefix are never touched. Errors are collected,
72
+ * never thrown: a sweep must not fail a test run.
73
+ */
74
+ async function sweepStaleMongoMemoryDirs(options = {}) {
75
+ const result = {
76
+ removed: [],
77
+ kept: [],
78
+ failed: []
79
+ };
80
+ const maxAgeMs = options.maxAgeMs === void 0 ? staleMongoMemoryAgeMs() : options.maxAgeMs;
81
+ if (maxAgeMs === null) return result;
82
+ const now = options.now ?? Date.now();
83
+ const alive = options.isAlive ?? isPidAlive;
84
+ const bases = new Set((options.dirs ?? defaultSweepDirs()).map((d) => resolve(d)));
85
+ for (const base of bases) {
86
+ let entries;
87
+ try {
88
+ entries = await readdir(base, { withFileTypes: true });
89
+ } catch {
90
+ continue;
91
+ }
92
+ for (const entry of entries) {
93
+ if (!entry.isDirectory()) continue;
94
+ if (!entry.name.startsWith("mongo-mem-")) continue;
95
+ if (entry.name.startsWith(KEYFILE_DIR_PREFIX)) continue;
96
+ const path = join(base, entry.name);
97
+ try {
98
+ const pid = await lockPid(path);
99
+ if (pid !== void 0 && alive(pid)) {
100
+ result.kept.push({
101
+ path,
102
+ reason: "live-process"
103
+ });
104
+ continue;
105
+ }
106
+ if (now - await newestMtimeMs(path) < maxAgeMs) {
107
+ result.kept.push({
108
+ path,
109
+ reason: "recent"
110
+ });
111
+ continue;
112
+ }
113
+ await rm(path, {
114
+ recursive: true,
115
+ force: true,
116
+ maxRetries: 2,
117
+ retryDelay: 100
118
+ });
119
+ result.removed.push(path);
120
+ } catch (err) {
121
+ result.failed.push({
122
+ path,
123
+ error: err.message
124
+ });
125
+ }
126
+ }
127
+ }
128
+ return result;
129
+ }
130
+ let processSweep;
131
+ /**
132
+ * Run the sweep at most once per process, in the background. Started before a
133
+ * process boots its first server; never awaited there, because a first sweep
134
+ * after a long leak can have gigabytes to delete. Logs one line when it removed
135
+ * anything, so a machine that keeps leaking is noticed.
136
+ */
137
+ function sweepStaleMongoMemoryDirsOnce(label = "mongokit/testkit") {
138
+ processSweep ??= sweepStaleMongoMemoryDirs().then((r) => {
139
+ if (r.removed.length > 0) console.warn(`[${label}] removed ${r.removed.length} abandoned mongo-mem-* temp dir(s) left by killed or crashed test runs`);
140
+ return r;
141
+ }, () => ({
142
+ removed: [],
143
+ kept: [],
144
+ failed: []
145
+ }));
146
+ return processSweep;
147
+ }
148
+ async function newDataDir(base) {
149
+ await mkdir(base, { recursive: true });
150
+ return mkdtemp(join(base, MONGO_MEM_DIR_PREFIX));
151
+ }
152
+ /**
153
+ * Point every member at a fresh dir under `base`, unless the caller set its own
154
+ * `dbPath` / `instanceOpts` (then the caller owns that location).
155
+ */
156
+ async function withDedicatedDataDirs(kind, opts, base, owned) {
157
+ if (kind === "standalone") {
158
+ const instance = opts.instance ?? {};
159
+ if (instance.dbPath !== void 0) return opts;
160
+ const dbPath = await newDataDir(base);
161
+ owned.push(dbPath);
162
+ return {
163
+ ...opts,
164
+ instance: {
165
+ ...instance,
166
+ dbPath
167
+ }
168
+ };
169
+ }
170
+ const existing = opts.instanceOpts;
171
+ if (existing && existing.length > 0) return opts;
172
+ const count = Number(opts.replSet?.count ?? 1);
173
+ const instanceOpts = [];
174
+ for (let i = 0; i < Math.max(1, count); i += 1) {
175
+ const dbPath = await newDataDir(base);
176
+ owned.push(dbPath);
177
+ instanceOpts.push({ dbPath });
178
+ }
179
+ return {
180
+ ...opts,
181
+ instanceOpts
182
+ };
183
+ }
184
+ function instancesOf(server) {
185
+ const members = server.servers;
186
+ return Array.isArray(members) ? members : [server];
187
+ }
188
+ const sleepCell = new Int32Array(new SharedArrayBuffer(4));
189
+ /** Synchronous sleep, for the `exit` path where no timer fires. */
190
+ function sleepSync(ms) {
191
+ Atomics.wait(sleepCell, 0, 0, ms);
192
+ }
193
+ /**
194
+ * Remove a dir synchronously, retrying for up to `timeoutMs`. A killed mongod
195
+ * is reported gone before Windows releases its file handles, so the first
196
+ * attempts can fail with EPERM / EBUSY.
197
+ */
198
+ function removeDirSync(dir, timeoutMs = 0) {
199
+ const deadline = Date.now() + timeoutMs;
200
+ for (;;) try {
201
+ rmSync(dir, {
202
+ recursive: true,
203
+ force: true
204
+ });
205
+ return;
206
+ } catch {
207
+ if (Date.now() >= deadline) return;
208
+ sleepSync(50);
209
+ }
210
+ }
211
+ /** Block until none of `pids` is alive, at most `timeoutMs`. */
212
+ function waitForExitSync(pids, timeoutMs) {
213
+ const deadline = Date.now() + timeoutMs;
214
+ while (pids.some(isPidAlive) && Date.now() < deadline) sleepSync(25);
215
+ }
216
+ /**
217
+ * Create and start a `MongoMemoryServer` (`standalone`) or `MongoMemoryReplSet`
218
+ * (`replset`) with the cleanup guarantees described at the top of this file.
219
+ *
220
+ * `Ctor` is the class itself, so a caller that loads mongodb-memory-server its
221
+ * own way (by resolved path, through an alias) keeps doing so.
222
+ */
223
+ async function launchMongoMemory(Ctor, kind, createOptions = {}) {
224
+ const base = mongoMemoryTmpBase();
225
+ const ownedDirs = [];
226
+ const opts = base ? await withDedicatedDataDirs(kind, createOptions, base, ownedDirs) : createOptions;
227
+ const cleanup = {
228
+ doCleanup: true,
229
+ force: ownedDirs.length > 0
230
+ };
231
+ const server = new Ctor(opts);
232
+ try {
233
+ await server.start();
234
+ } catch (err) {
235
+ await server.stop(cleanup).catch(() => void 0);
236
+ for (const dir of ownedDirs) removeDirSync(dir, 2e3);
237
+ throw err;
238
+ }
239
+ let stopped = false;
240
+ let stopping;
241
+ return {
242
+ server,
243
+ ownedDirs,
244
+ stop() {
245
+ if (stopped) return Promise.resolve();
246
+ stopping ??= (async () => {
247
+ try {
248
+ if (await server.stop(cleanup) === false && kind === "replset") throw new Error("[mongokit/testkit] mongodb-memory-server could not stop every replica-set member; stop() can be retried, and the process-exit fallback still covers it");
249
+ for (const dir of ownedDirs) removeDirSync(dir);
250
+ stopped = true;
251
+ } finally {
252
+ stopping = void 0;
253
+ }
254
+ })();
255
+ return stopping;
256
+ },
257
+ stopSync() {
258
+ if (stopped) return;
259
+ stopped = true;
260
+ const dirs = new Set(ownedDirs);
261
+ const pids = [];
262
+ for (const member of instancesOf(server)) {
263
+ const info = member.instanceInfo;
264
+ const pid = info?.instance?.mongodProcess?.pid;
265
+ if (pid) {
266
+ pids.push(pid);
267
+ try {
268
+ process.kill(pid, "SIGKILL");
269
+ } catch {}
270
+ }
271
+ if (info?.tmpDir) dirs.add(info.tmpDir);
272
+ }
273
+ waitForExitSync(pids, 5e3);
274
+ for (const dir of dirs) removeDirSync(dir, 5e3);
275
+ }
276
+ };
277
+ }
278
+ const tracked = /* @__PURE__ */ new Set();
279
+ let exitHooked = false;
280
+ /**
281
+ * Stop a launched server when the process ends without its teardown: Ctrl-C /
282
+ * SIGTERM stop it properly, then re-raise so the exit code stays truthful; an
283
+ * `exit` (including an uncaught exception) kills mongod and removes the dir
284
+ * synchronously. Returns an untrack function for the orderly path.
285
+ */
286
+ function stopOnProcessExit(handle) {
287
+ tracked.add(handle);
288
+ if (!exitHooked) {
289
+ exitHooked = true;
290
+ const onSignal = (signal) => {
291
+ const all = [...tracked];
292
+ Promise.all(all.map((h) => h.stop().then(() => {
293
+ tracked.delete(h);
294
+ }, () => void 0))).finally(() => {
295
+ for (const h of all) {
296
+ if (!tracked.has(h)) continue;
297
+ h.stopSync();
298
+ tracked.delete(h);
299
+ }
300
+ process.removeListener(signal, onSignal);
301
+ process.kill(process.pid, signal);
302
+ });
303
+ };
304
+ for (const signal of [
305
+ "SIGINT",
306
+ "SIGTERM",
307
+ "SIGHUP",
308
+ "SIGBREAK"
309
+ ]) process.once(signal, onSignal);
310
+ process.once("exit", () => {
311
+ for (const h of tracked) h.stopSync();
312
+ tracked.clear();
313
+ });
314
+ }
315
+ return () => {
316
+ tracked.delete(handle);
317
+ };
318
+ }
319
+ // src/testkit/query-plan.ts
4
320
  const PLANNABLE = /* @__PURE__ */ new Set([
5
321
  "find",
6
322
  "aggregate",
7
323
  "count",
8
324
  "distinct"
9
325
  ]);
326
+ /**
327
+ * Every command the server can explain. A write command is explained, never run again (an
328
+ * explain does not write), so a captured `findOneAndUpdate` / `updateOne` / `deleteMany` can be
329
+ * planned after the fact.
330
+ */
331
+ const EXPLAINABLE = /* @__PURE__ */ new Set([
332
+ ...PLANNABLE,
333
+ "findAndModify",
334
+ "update",
335
+ "delete"
336
+ ]);
337
+ function commandSet(names) {
338
+ for (const name of names) if (!EXPLAINABLE.has(name)) throw new Error(`[mongokit/testkit] '${name}' cannot be explained (explainable: ${[...EXPLAINABLE].join(", ")})`);
339
+ return new Set(names);
340
+ }
10
341
  /** Session/transport fields the server refuses inside an `explain`. */
11
342
  const STRIP = /* @__PURE__ */ new Set([
12
343
  "lsid",
@@ -59,24 +390,80 @@ function explainable(command) {
59
390
  return Object.fromEntries(Object.entries(command).filter(([k]) => !k.startsWith("$") && !STRIP.has(k)));
60
391
  }
61
392
  /**
393
+ * What to send to `explain` for one recorded command. The server explains an `update` / `delete`
394
+ * of ONE statement only, so a bulk command becomes one explain per statement.
395
+ */
396
+ function explainTargets(c) {
397
+ const base = explainable(c.command);
398
+ const list = c.name === "update" ? "updates" : c.name === "delete" ? "deletes" : void 0;
399
+ if (list === void 0) return [base];
400
+ return (Array.isArray(base[list]) ? base[list] : []).map((statement) => ({
401
+ ...base,
402
+ [list]: [statement]
403
+ }));
404
+ }
405
+ /**
406
+ * Run `run` and explain the commands it ACTUALLY sent, so a test plans the filter, sort and
407
+ * update a repository method or plugin built (a `leasePlugin` claim, a tenant-scoped read)
408
+ * rather than a copy of it. The explains run after `run` returns and never write. Throws when no
409
+ * command matched, so a renamed collection cannot pass vacuously. The connection must be opened
410
+ * with `{ monitorCommands: true }`.
411
+ *
412
+ * ```ts
413
+ * const { explains } = await explainCommands(() => repo.lease({ leaseFor, leasedBy }), {
414
+ * connection, commands: ['findAndModify'], collections: ['jobs'],
415
+ * });
416
+ * explains[0].explain.executionStats; // what the claim examined
417
+ * ```
418
+ */
419
+ async function explainCommands(run, options) {
420
+ const names = commandSet(options.commands ?? [...EXPLAINABLE]);
421
+ const { result, commands } = await recordCommands(options.connection, run);
422
+ const db = options.connection.db;
423
+ if (!db) throw new Error("[mongokit/testkit] connection has no db handle");
424
+ const matched = commands.filter((c) => names.has(c.name) && c.collection !== void 0 && (!options.collections || options.collections.includes(c.collection)));
425
+ if (matched.length === 0) throw new Error(`[mongokit/testkit] explainCommands saw no matching command (saw: ${commands.map((c) => `${c.name}${c.collection ? `:${c.collection}` : ""}`).join(", ") || "none"})`);
426
+ const explains = [];
427
+ for (const c of matched) for (const target of explainTargets(c)) {
428
+ const explain = await db.command({
429
+ explain: target,
430
+ verbosity: options.verbosity ?? "executionStats"
431
+ });
432
+ explains.push({
433
+ ...c,
434
+ collection: c.collection,
435
+ explained: target,
436
+ explain
437
+ });
438
+ }
439
+ return {
440
+ result,
441
+ explains
442
+ };
443
+ }
444
+ /**
62
445
  * Run `run`, explain every read it issued and throw on a forbidden stage, a wrong leading index,
63
446
  * an undersized fixture, or no plannable command at all. Returns what each command used.
64
447
  */
65
448
  async function assertQueryPlan(run, options) {
66
449
  const forbid = options.forbid ?? ["COLLSCAN", "SORT"];
450
+ const gated = commandSet(options.commands ?? [...PLANNABLE]);
67
451
  const { result, commands } = await recordCommands(options.connection, run);
68
452
  const db = options.connection.db;
69
453
  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)));
454
+ const plannable = commands.filter((c) => gated.has(c.name) && c.collection !== void 0 && (!options.collections || options.collections.includes(c.collection)));
71
455
  if (plannable.length === 0) throw new Error(`[mongokit/testkit] assertQueryPlan saw no plannable command (saw: ${commands.map((c) => c.name).join(", ") || "none"})`);
72
456
  const plans = [];
73
457
  const failures = [];
74
- for (const c of plannable) {
458
+ for (const { c, target } of plannable.flatMap((c) => explainTargets(c).map((target) => ({
459
+ c,
460
+ target
461
+ })))) {
75
462
  const collection = c.collection;
76
463
  const size = await db.collection(collection).countDocuments({});
77
464
  if (size < options.minDocs) throw new Error(`[mongokit/testkit] '${collection}' holds ${size} documents, below minDocs ${options.minDocs}: the plan would not be representative`);
78
465
  const explained = await db.command({
79
- explain: explainable(c.command),
466
+ explain: target,
80
467
  verbosity: "executionStats"
81
468
  });
82
469
  const nodes = [];
@@ -110,14 +497,23 @@ async function assertQueryPlan(run, options) {
110
497
  plans
111
498
  };
112
499
  }
113
- //#endregion
114
- //#region src/testkit/index.ts
115
- function idempotentStop(server) {
500
+ // src/testkit/index.ts
501
+ /**
502
+ * Stop with cleanup; leave the process-exit list only once that SUCCEEDED. A failed
503
+ * stop rejects, keeps the exit tracking, and a later call retries.
504
+ */
505
+ function idempotentStop(launched, untrack) {
116
506
  let stopped = false;
117
- return async () => {
118
- if (stopped) return;
119
- stopped = true;
120
- await server.stop().catch(() => void 0);
507
+ let stopping;
508
+ return () => {
509
+ if (stopped) return Promise.resolve();
510
+ stopping ??= launched.stop().then(() => {
511
+ stopped = true;
512
+ untrack();
513
+ }).finally(() => {
514
+ stopping = void 0;
515
+ });
516
+ return stopping;
121
517
  };
122
518
  }
123
519
  /**
@@ -133,19 +529,22 @@ async function createMongoMemory(options = {}) {
133
529
  };
134
530
  const { MongoMemoryServer, MongoMemoryReplSet } = await import("mongodb-memory-server");
135
531
  const dbName = options.dbName ?? "mongokit-test";
532
+ sweepStaleMongoMemoryDirsOnce();
136
533
  if (options.replset) {
137
- const rs = await MongoMemoryReplSet.create({ replSet: { count: 1 } });
534
+ const launched = await launchMongoMemory(MongoMemoryReplSet, "replset", { replSet: { count: 1 } });
535
+ const untrack = stopOnProcessExit(launched);
138
536
  return {
139
- uri: rs.getUri(dbName),
140
- server: rs,
141
- stop: idempotentStop(rs)
537
+ uri: launched.server.getUri(dbName),
538
+ server: launched.server,
539
+ stop: idempotentStop(launched, untrack)
142
540
  };
143
541
  }
144
- const server = await MongoMemoryServer.create();
542
+ const launched = await launchMongoMemory(MongoMemoryServer, "standalone");
543
+ const untrack = stopOnProcessExit(launched);
145
544
  return {
146
- uri: server.getUri(dbName),
147
- server,
148
- stop: idempotentStop(server)
545
+ uri: launched.server.getUri(dbName),
546
+ server: launched.server,
547
+ stop: idempotentStop(launched, untrack)
149
548
  };
150
549
  }
151
550
  /** Drop every document in every collection on a connection (models preserved). */
@@ -169,9 +568,9 @@ async function createTestConnection(options = {}) {
169
568
  clear: () => clearCollections(connection),
170
569
  close: async () => {
171
570
  if (closed) return;
172
- closed = true;
173
571
  await connection.close().catch(() => void 0);
174
572
  await handle.stop();
573
+ closed = true;
175
574
  }
176
575
  };
177
576
  }
@@ -246,5 +645,4 @@ function mongoMemoryBackend(options = {}) {
246
645
  };
247
646
  } };
248
647
  }
249
- //#endregion
250
- export { assertQueryPlan, clearCollections, createMongoMemory, createTestConnection, createTestRepository, mongoMemoryBackend, recordCommands, withMongoMemory };
648
+ export { MONGO_MEM_DIR_PREFIX, assertQueryPlan, clearCollections, createMongoMemory, createTestConnection, createTestRepository, explainCommands, isPidAlive, launchMongoMemory, mongoMemoryBackend, mongoMemoryTmpBase, recordCommands, staleMongoMemoryAgeMs, stopOnProcessExit, sweepStaleMongoMemoryDirs, withMongoMemory };