@classytic/arc-testkit 0.5.0 → 0.7.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.
package/dist/index.d.mts CHANGED
@@ -1,10 +1,7 @@
1
1
  import { BootModuleAppOptions as BootModuleAppOptions$1, BootModulesInput, ModuleTestApp as ModuleTestApp$1, TestkitContext as TestkitContext$1 } from "@classytic/arc/testing";
2
2
  import { Connection } from "mongoose";
3
- //#region src/index.d.ts
4
- type TestkitContext = TestkitContext$1<Connection>;
5
- type ModulesInput = BootModulesInput<Connection>;
6
- type BootModuleAppOptions = BootModuleAppOptions$1<Connection>;
7
- type ModuleTestApp = ModuleTestApp$1<Connection>;
8
- declare function bootModuleApp(modules: ModulesInput, options?: BootModuleAppOptions): Promise<ModuleTestApp>;
9
- //#endregion
10
- export { BootModuleAppOptions, ModuleTestApp, ModulesInput, TestkitContext, bootModuleApp };
3
+ export type TestkitContext = TestkitContext$1<Connection>;
4
+ export type ModulesInput = BootModulesInput<Connection>;
5
+ export type BootModuleAppOptions = BootModuleAppOptions$1<Connection>;
6
+ export type ModuleTestApp = ModuleTestApp$1<Connection>;
7
+ export declare function bootModuleApp(modules: ModulesInput, options?: BootModuleAppOptions): Promise<ModuleTestApp>;
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { bootModuleApp as bootModuleApp$1 } from "@classytic/arc/testing";
2
- //#region src/index.ts
2
+ // src/index.ts
3
3
  /**
4
4
  * @classytic/arc-testkit — boots a REAL arc app around the module(s) under
5
5
  * test on in-memory MongoDB. The contributor contract: if your module boots
@@ -23,5 +23,4 @@ import { bootModuleApp as bootModuleApp$1 } from "@classytic/arc/testing";
23
23
  function bootModuleApp(modules, options = {}) {
24
24
  return bootModuleApp$1(modules, options);
25
25
  }
26
- //#endregion
27
26
  export { bootModuleApp };
@@ -1,7 +1,6 @@
1
1
  import { Connection } from "mongoose";
2
- //#region src/mongo-commands.d.ts
3
2
  type MongoClientLike = ReturnType<Connection["getClient"]>;
4
- interface CountCommandsOptions {
3
+ export interface CountCommandsOptions {
5
4
  /** Count only commands against these collections. */
6
5
  readonly collections?: readonly string[];
7
6
  /** Count only these command names (`find`, `aggregate`, `insert`, …). */
@@ -9,12 +8,12 @@ interface CountCommandsOptions {
9
8
  /** Never count these command names — e.g. `createIndexes` a model may run lazily. */
10
9
  readonly ignore?: readonly string[];
11
10
  }
12
- interface WireCommand {
11
+ export interface WireCommand {
13
12
  readonly command: string;
14
13
  /** The collection the command targets; `undefined` for session, transaction and admin commands. */
15
14
  readonly collection: string | undefined;
16
15
  }
17
- interface CommandCount<T> {
16
+ export interface CommandCount<T> {
18
17
  readonly result: T;
19
18
  readonly total: number;
20
19
  /** Counted commands, in the order the driver sent them. */
@@ -23,7 +22,6 @@ interface CommandCount<T> {
23
22
  readonly byCollection: Readonly<Record<string, number>>;
24
23
  readonly byCommand: Readonly<Record<string, number>>;
25
24
  }
26
- declare function countCommands<T>(source: Connection | MongoClientLike, fn: () => Promise<T>, options?: CountCommandsOptions): Promise<CommandCount<T>>;
27
- declare function assertConstantCommands<T>(source: Connection | MongoClientLike, run: (size: number) => Promise<T>, sizes: readonly number[], options?: CountCommandsOptions): Promise<readonly CommandCount<T>[]>;
28
- //#endregion
29
- export { CommandCount, CountCommandsOptions, WireCommand, assertConstantCommands, countCommands };
25
+ export declare function countCommands<T>(source: Connection | MongoClientLike, fn: () => Promise<T>, options?: CountCommandsOptions): Promise<CommandCount<T>>;
26
+ export declare function assertConstantCommands<T>(source: Connection | MongoClientLike, run: (size: number) => Promise<T>, sizes: readonly number[], options?: CountCommandsOptions): Promise<readonly CommandCount<T>[]>;
27
+ export {};
@@ -1,4 +1,4 @@
1
- //#region src/mongo-commands.ts
1
+ // src/mongo-commands.ts
2
2
  async function countCommands(source, fn, options = {}) {
3
3
  const client = "getClient" in source ? source.getClient() : source;
4
4
  if (client.options.monitorCommands !== true) throw new Error("[arc-testkit] countCommands: the client was created without `monitorCommands: true` — the driver emits no command events, so every count would read 0");
@@ -50,5 +50,4 @@ function tally(keys) {
50
50
  for (const k of keys) out[k] = (out[k] ?? 0) + 1;
51
51
  return out;
52
52
  }
53
- //#endregion
54
53
  export { assertConstantCommands, countCommands };
@@ -1,4 +1,3 @@
1
- //#region src/mongo-explain.d.ts
2
1
  /**
3
2
  * Read a MongoDB `explain()` result — find, aggregate, classic or SBE — as the WINNING plan's stage
4
3
  * names, the indexes it scans, and its execution stats (`executionStats` verbosity). Rejected plans
@@ -9,8 +8,17 @@
9
8
  * const explain = await Model.find({ organizationId, state: "posted" }).explain("executionStats");
10
9
  * assertIndexServed(explain, { index: "org_state_date", maxDocsExamined: 40 });
11
10
  * ```
11
+ *
12
+ * `explainCaptured` explains the query a piece of code ACTUALLY sends (a repository method, a
13
+ * plugin-built filter) instead of a copy of it: it runs the code with command monitoring, captures
14
+ * the matching command and explains exactly that.
15
+ *
16
+ * ```ts
17
+ * const { explain } = await explainCaptured(conn, () => timers.claim(now), { collection: "timers", commands: ["findAndModify"] });
18
+ * assertIndexServed(explain, { index: "state_due", maxDocsExamined: 6 });
19
+ * ```
12
20
  */
13
- interface ExplainSummary {
21
+ export interface ExplainSummary {
14
22
  /** Stage names of the winning plan(s), outermost first. */
15
23
  readonly stages: readonly string[];
16
24
  /** Index names the winning plan scans (`_id_` for an id lookup). */
@@ -20,7 +28,7 @@ interface ExplainSummary {
20
28
  readonly keysExamined: number | undefined;
21
29
  readonly returned: number | undefined;
22
30
  }
23
- interface IndexServedOptions {
31
+ export interface IndexServedOptions {
24
32
  /** Every one of these must be scanned. */
25
33
  readonly index?: string | readonly string[];
26
34
  /** Every one of these stages must appear — e.g. `OR` for a `$or` served arm by arm. */
@@ -28,7 +36,49 @@ interface IndexServedOptions {
28
36
  /** Ceiling on `totalDocsExamined`; requires `executionStats` verbosity. */
29
37
  readonly maxDocsExamined?: number;
30
38
  }
31
- declare function summarizeExplain(explain: unknown): ExplainSummary;
32
- declare function assertIndexServed(explain: unknown, options?: IndexServedOptions): ExplainSummary;
33
- //#endregion
34
- export { ExplainSummary, IndexServedOptions, assertIndexServed, summarizeExplain };
39
+ export declare function summarizeExplain(explain: unknown): ExplainSummary;
40
+ export declare function assertIndexServed(explain: unknown, options?: IndexServedOptions): ExplainSummary;
41
+ /** A `commandStarted` event, as far as `explainCaptured` reads it. */
42
+ export interface CommandStartedLike {
43
+ readonly commandName: string;
44
+ readonly databaseName: string;
45
+ readonly command: Readonly<Record<string, unknown>>;
46
+ }
47
+ /** A MongoDB driver client (`connection.getClient()` of mongoose, or a `MongoClient`). */
48
+ export interface MonitoredClientLike {
49
+ readonly options: {
50
+ readonly monitorCommands?: boolean;
51
+ };
52
+ on(event: "commandStarted", listener: (event: CommandStartedLike) => void): unknown;
53
+ off(event: "commandStarted", listener: (event: CommandStartedLike) => void): unknown;
54
+ db(name: string): {
55
+ command(command: Record<string, unknown>): Promise<unknown>;
56
+ };
57
+ }
58
+ export interface ExplainCapturedOptions {
59
+ /** The collection the command targets. */
60
+ readonly collection: string;
61
+ /** Command names to capture. Default: `find`, `aggregate`, `findAndModify`, `count`, `distinct`, `update`, `delete`. */
62
+ readonly commands?: readonly string[];
63
+ /** Which captured command to explain (0-based). Required when more than one matches. */
64
+ readonly index?: number;
65
+ /** Default: `executionStats` (what `maxDocsExamined` needs). */
66
+ readonly verbosity?: "queryPlanner" | "executionStats" | "allPlansExecution";
67
+ }
68
+ export interface ExplainCaptured<T> {
69
+ readonly result: T;
70
+ /** The explain of the chosen command, ready for `assertIndexServed`. */
71
+ readonly explain: unknown;
72
+ /** The chosen command as the driver sent it, minus session and transport fields. */
73
+ readonly command: Readonly<Record<string, unknown>>;
74
+ /** Every matching command, in the order sent (same fields removed). */
75
+ readonly captured: readonly Readonly<Record<string, unknown>>[];
76
+ }
77
+ /**
78
+ * Runs `run` with command monitoring, captures the commands it sends to `collection` and explains
79
+ * the chosen one (the explain never applies a write). Refuses a client without
80
+ * `monitorCommands: true`, a run that sent no matching command, and an ambiguous capture.
81
+ */
82
+ export declare function explainCaptured<T>(source: {
83
+ getClient(): MonitoredClientLike;
84
+ } | MonitoredClientLike, run: () => Promise<T>, options: ExplainCapturedOptions): Promise<ExplainCaptured<T>>;
@@ -1,4 +1,4 @@
1
- //#region src/mongo-explain.ts
1
+ // src/mongo-explain.ts
2
2
  /** Keys whose subtree is a rejected alternative or a query predicate — never plan structure. */
3
3
  const SKIPPED = /* @__PURE__ */ new Set([
4
4
  "rejectedPlans",
@@ -57,11 +57,80 @@ function assertIndexServed(explain, options = {}) {
57
57
  }
58
58
  return plan;
59
59
  }
60
+ const EXPLAINABLE = [
61
+ "find",
62
+ "aggregate",
63
+ "findAndModify",
64
+ "count",
65
+ "distinct",
66
+ "update",
67
+ "delete"
68
+ ];
69
+ /** Fields the driver adds to a command that an `explain` wrapper must not carry. */
70
+ const TRANSPORT = /* @__PURE__ */ new Set([
71
+ "lsid",
72
+ "$db",
73
+ "$clusterTime",
74
+ "$readPreference",
75
+ "txnNumber",
76
+ "autocommit",
77
+ "startTransaction",
78
+ "readConcern",
79
+ "writeConcern",
80
+ "apiVersion",
81
+ "apiStrict",
82
+ "apiDeprecationErrors"
83
+ ]);
84
+ /**
85
+ * Runs `run` with command monitoring, captures the commands it sends to `collection` and explains
86
+ * the chosen one (the explain never applies a write). Refuses a client without
87
+ * `monitorCommands: true`, a run that sent no matching command, and an ambiguous capture.
88
+ */
89
+ async function explainCaptured(source, run, options) {
90
+ const client = "getClient" in source ? source.getClient() : source;
91
+ const fail = (msg) => {
92
+ throw new Error(`[arc-testkit] explainCaptured: ${msg}`);
93
+ };
94
+ if (client.options.monitorCommands !== true) fail("the client was created without `monitorCommands: true` — the driver emits no command events, so nothing can be captured");
95
+ const names = options.commands ?? EXPLAINABLE;
96
+ const seen = [];
97
+ const onStarted = (e) => {
98
+ if (!names.includes(e.commandName) || e.command[e.commandName] !== options.collection) return;
99
+ const command = {};
100
+ for (const [key, value] of Object.entries(e.command)) if (!TRANSPORT.has(key)) command[key] = value;
101
+ seen.push({
102
+ db: e.databaseName,
103
+ command
104
+ });
105
+ };
106
+ client.on("commandStarted", onStarted);
107
+ let result;
108
+ try {
109
+ result = await run();
110
+ } finally {
111
+ client.off("commandStarted", onStarted);
112
+ }
113
+ const what = `${names.join("/")} on "${options.collection}"`;
114
+ if (seen.length === 0) fail(`run sent no ${what}`);
115
+ if (options.index === void 0 && seen.length > 1) fail(`run sent ${seen.length} ${what} (${seen.map((s) => Object.keys(s.command)[0]).join(", ")}): pass \`index\` to pick one`);
116
+ const chosen = seen[options.index ?? 0];
117
+ if (chosen === void 0) fail(`no captured command at index ${options.index} (${seen.length} captured)`);
118
+ const { db, command } = chosen;
119
+ const explain = await client.db(db).command({
120
+ explain: command,
121
+ verbosity: options.verbosity ?? "executionStats"
122
+ });
123
+ return {
124
+ result,
125
+ explain,
126
+ command,
127
+ captured: seen.map((s) => s.command)
128
+ };
129
+ }
60
130
  function isRecord(value) {
61
131
  return typeof value === "object" && value !== null;
62
132
  }
63
133
  function numberOr(value) {
64
134
  return typeof value === "number" ? value : void 0;
65
135
  }
66
- //#endregion
67
- export { assertIndexServed, summarizeExplain };
136
+ export { assertIndexServed, explainCaptured, summarizeExplain };
package/dist/mongo.d.mts CHANGED
@@ -1,10 +1,121 @@
1
1
  import { TestServerProvider } from "./pool.mjs";
2
2
  import { Connection } from "mongoose";
3
- //#region src/mongo.d.ts
3
+ /**
4
+ * mongodb-memory-server temp-dir hygiene: start, stop and sweep so that a test
5
+ * run that is killed, crashes or times out does not leave its data dir behind.
6
+ *
7
+ * ## Why this exists
8
+ *
9
+ * mongodb-memory-server creates `mongo-mem-XXXXXX` under `os.tmpdir()` for every
10
+ * server and removes it in `stop()` (`doCleanup: true` is the default since v9).
11
+ * That only covers the orderly path. A run killed from an IDE, a CI cancel, a
12
+ * worker terminated on timeout or a hard crash never reaches `stop()`, and the
13
+ * dir (a full WiredTiger data set, ~15 MB at minimum) stays forever. On one
14
+ * development machine that came to 3,698 dirs, about 57 GB, in `%TEMP%`.
15
+ * mongodb-memory-server also stops a server that FAILED to start with
16
+ * `doCleanup: false` ("for investigation"), so a failed boot leaks one too.
17
+ *
18
+ * Three measures, each one usable on its own:
19
+ *
20
+ * 1. `launchMongoMemory` always stops with cleanup, cleans up after a failed
21
+ * start, and has a synchronous `stopSync()` for a process `exit` handler,
22
+ * where nothing async completes.
23
+ * 2. `sweepStaleMongoMemoryDirs` removes `mongo-mem-*` dirs that are old AND
24
+ * whose `mongod.lock` names no live process. Both conditions, always: a live
25
+ * mongod rewrites its WiredTiger files on every checkpoint (60 s), so its dir
26
+ * is never old, and its lock names a live pid.
27
+ * 3. `TEST_MONGO_TMPDIR` puts the data dirs under a dedicated base (for example
28
+ * a project-local `.tmp`), so whatever does leak is contained and visible.
29
+ *
30
+ * Environment:
31
+ * - `TEST_MONGO_TMPDIR`: base dir for new data dirs. Unset: mongodb-memory-server's
32
+ * own default (`os.tmpdir()`).
33
+ * - `TEST_MONGO_SWEEP_HOURS`: minimum age of a dir the sweep may remove. Default 6.
34
+ * `0`, `off` or `false` turns the sweep off.
35
+ */
36
+ /** Prefix mongodb-memory-server gives its data dirs (and that ours use). */
37
+ export declare const MONGO_MEM_DIR_PREFIX = "mongo-mem-";
38
+ /** The dedicated base dir from `TEST_MONGO_TMPDIR`, resolved, or undefined. */
39
+ export declare function mongoMemoryTmpBase(): string | undefined;
40
+ /** Minimum age (ms) of a dir the sweep may remove; `null` when the sweep is off. */
41
+ export declare function staleMongoMemoryAgeMs(): number | null;
42
+ /** Whether a process with this pid exists. EPERM means it exists but is not ours. */
43
+ export declare function isPidAlive(pid: number): boolean;
44
+ interface SweepMongoMemoryOptions {
45
+ /** Dirs to scan. Default: `os.tmpdir()` plus `TEST_MONGO_TMPDIR` when set. */
46
+ readonly dirs?: readonly string[];
47
+ /** Minimum age in ms; `null` turns the sweep off. Default: `staleMongoMemoryAgeMs()`. */
48
+ readonly maxAgeMs?: number | null;
49
+ /** Clock, for tests. Default `Date.now()`. */
50
+ readonly now?: number;
51
+ /** Liveness probe, for tests. Default `isPidAlive`. */
52
+ readonly isAlive?: (pid: number) => boolean;
53
+ }
54
+ interface SweepMongoMemoryResult {
55
+ readonly removed: string[];
56
+ readonly kept: Array<{
57
+ path: string;
58
+ reason: "live-process" | "recent";
59
+ }>;
60
+ readonly failed: Array<{
61
+ path: string;
62
+ error: string;
63
+ }>;
64
+ }
65
+ /**
66
+ * Remove abandoned `mongo-mem-*` data dirs.
67
+ *
68
+ * A dir is removed only when BOTH hold: its `mongod.lock` names no live process
69
+ * (or is empty or missing), and nothing in it changed for `maxAgeMs`. Keyfile
70
+ * dirs and anything without the prefix are never touched. Errors are collected,
71
+ * never thrown: a sweep must not fail a test run.
72
+ */
73
+ export declare function sweepStaleMongoMemoryDirs(options?: SweepMongoMemoryOptions): Promise<SweepMongoMemoryResult>;
74
+ /** The parts of `MongoMemoryServer` / `MongoMemoryReplSet` this module uses. */
75
+ interface MmsServerLike {
76
+ getUri(dbName?: string): string;
77
+ start(): Promise<unknown>;
78
+ stop(cleanup?: {
79
+ doCleanup?: boolean;
80
+ force?: boolean;
81
+ }): Promise<unknown>;
82
+ }
83
+ type MongoMemoryKind = "standalone" | "replset";
84
+ interface LaunchedMongoMemory<S extends MmsServerLike> {
85
+ readonly server: S;
86
+ /** Data dirs this module created under `TEST_MONGO_TMPDIR` (empty when unset). */
87
+ readonly ownedDirs: readonly string[];
88
+ /** Stop and remove the data dirs. Idempotent. */
89
+ stop(): Promise<void>;
90
+ /**
91
+ * Best-effort SYNCHRONOUS stop for a process `exit` handler, where no async
92
+ * work completes: kill the mongod processes and remove their data dirs.
93
+ * Idempotent, never throws.
94
+ */
95
+ stopSync(): void;
96
+ }
97
+ /**
98
+ * Create and start a `MongoMemoryServer` (`standalone`) or `MongoMemoryReplSet`
99
+ * (`replset`) with the cleanup guarantees described at the top of this file.
100
+ *
101
+ * `Ctor` is the class itself, so a caller that loads mongodb-memory-server its
102
+ * own way (by resolved path, through an alias) keeps doing so.
103
+ */
104
+ export declare function launchMongoMemory<S extends MmsServerLike>(Ctor: new (opts?: never) => S, kind: MongoMemoryKind, createOptions?: Record<string, unknown>): Promise<LaunchedMongoMemory<S>>;
105
+ /**
106
+ * Stop a launched server when the process ends without its teardown: Ctrl-C /
107
+ * SIGTERM stop it properly, then re-raise so the exit code stays truthful; an
108
+ * `exit` (including an uncaught exception) kills mongod and removes the dir
109
+ * synchronously. Returns an untrack function for the orderly path.
110
+ *
111
+ * Uses the package's ONE shared process hook, so tracking many servers adds no
112
+ * listeners beyond one per event.
113
+ */
114
+ export declare function stopOnProcessExit(handle: LaunchedMongoMemory<MmsServerLike>): () => void;
4
115
  /** WiredTiger cache per server, in GB. 0.25 is mongod's own floor. */
5
- declare function cacheSizeGB(): number;
116
+ export declare function cacheSizeGB(): number;
6
117
  /** mongod CLI args that bound a test server's footprint. */
7
- declare function mongodMemoryArgs(): string[];
118
+ export declare function mongodMemoryArgs(): string[];
8
119
  /**
9
120
  * Worker count bounded by cores AND memory.
10
121
  *
@@ -12,11 +123,11 @@ declare function mongodMemoryArgs(): string[];
12
123
  * app plus every server it holds live. `envVar` lets a host keep its existing
13
124
  * override name rather than forcing a rename through every CI config.
14
125
  */
15
- declare function safeWorkerCount(envVar?: string): number;
126
+ export declare function safeWorkerCount(envVar?: string): number;
16
127
  /** How many servers the run may boot, bounded by FREE memory at call time. */
17
- declare function safePoolSize(requested: number): number;
128
+ export declare function safePoolSize(requested: number): number;
18
129
  /** Splice a database name into a mongodb:// URI, preserving its query string. */
19
- declare function withDbName(baseUri: string, dbName: string): string;
130
+ export declare function withDbName(baseUri: string, dbName: string): string;
20
131
  /**
21
132
  * Block until every registered model's index build has finished.
22
133
  *
@@ -37,14 +148,14 @@ declare function withDbName(baseUri: string, dbName: string): string;
37
148
  * that does not exist, and it would pass for the wrong reason — precisely the
38
149
  * failure this function was added to remove.
39
150
  */
40
- declare function awaitIndexBuilds(connection?: {
151
+ export declare function awaitIndexBuilds(connection?: {
41
152
  readyState: number;
42
153
  models: Record<string, {
43
154
  init(): Promise<unknown>;
44
155
  }>;
45
156
  }): Promise<void>;
46
157
  /** The subset of the mongoose module this adapter uses. */
47
- interface MongooseLike {
158
+ export interface MongooseLike {
48
159
  connection: {
49
160
  readyState: number;
50
161
  models: Record<string, {
@@ -56,7 +167,7 @@ interface MongooseLike {
56
167
  connect(uri: string): Promise<unknown>;
57
168
  disconnect(): Promise<unknown>;
58
169
  }
59
- interface MongoServerProviderOptions {
170
+ export interface MongoServerProviderOptions {
60
171
  /** Extra mongod args, appended after the memory bounds. */
61
172
  readonly args?: readonly string[];
62
173
  /** Await index builds after each lease connects. Default true — see above. */
@@ -82,6 +193,5 @@ interface MongoServerProviderOptions {
82
193
  * transactions, and a strict superset of a standalone. That is what lets the
83
194
  * pool serve both kinds from one process when sharing is on.
84
195
  */
85
- declare function mongoServerProvider(options?: MongoServerProviderOptions): TestServerProvider<Connection>;
86
- //#endregion
87
- export { MongoServerProviderOptions, MongooseLike, awaitIndexBuilds, cacheSizeGB, mongoServerProvider, mongodMemoryArgs, safePoolSize, safeWorkerCount, withDbName };
196
+ export declare function mongoServerProvider(options?: MongoServerProviderOptions): TestServerProvider<Connection>;
197
+ export type { LaunchedMongoMemory, MmsServerLike, MongoMemoryKind, SweepMongoMemoryOptions, SweepMongoMemoryResult };