@classytic/arc-testkit 0.4.0 → 0.5.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/README.md +14 -0
- package/dist/mongo-commands.d.mts +29 -0
- package/dist/mongo-commands.mjs +54 -0
- package/dist/mongo-explain.d.mts +34 -0
- package/dist/mongo-explain.mjs +67 -0
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -60,6 +60,20 @@ const t = await bootModuleApp(mods, {
|
|
|
60
60
|
| `TestkitContext` | what a module factory receives: `{ connection, mongoUri }` |
|
|
61
61
|
| `BootModuleAppOptions` | `{ replset?, app?, backend? }` |
|
|
62
62
|
|
|
63
|
+
## Cost and plan assertions (MongoDB)
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { assertConstantCommands, countCommands } from '@classytic/arc-testkit/mongo-commands';
|
|
67
|
+
import { assertIndexServed } from '@classytic/arc-testkit/mongo-explain';
|
|
68
|
+
|
|
69
|
+
// What the driver sent (the connection needs `monitorCommands: true` — it refuses otherwise).
|
|
70
|
+
const { total, byCollection } = await countCommands(conn, () => placeOrder(), { collections: ['orders'] });
|
|
71
|
+
// Same commands at every size, and at least one: a per-item read fails here.
|
|
72
|
+
await assertConstantCommands(conn, (n) => reader.page(n), [5, 50]);
|
|
73
|
+
// No COLLSCAN, an index scanned, optionally a named one and a docs-examined ceiling.
|
|
74
|
+
assertIndexServed(await Model.find(filter).explain('executionStats'), { index: 'org_state', maxDocsExamined: 40 });
|
|
75
|
+
```
|
|
76
|
+
|
|
63
77
|
## License
|
|
64
78
|
|
|
65
79
|
MIT © Classytic
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Connection } from "mongoose";
|
|
2
|
+
//#region src/mongo-commands.d.ts
|
|
3
|
+
type MongoClientLike = ReturnType<Connection["getClient"]>;
|
|
4
|
+
interface CountCommandsOptions {
|
|
5
|
+
/** Count only commands against these collections. */
|
|
6
|
+
readonly collections?: readonly string[];
|
|
7
|
+
/** Count only these command names (`find`, `aggregate`, `insert`, …). */
|
|
8
|
+
readonly commands?: readonly string[];
|
|
9
|
+
/** Never count these command names — e.g. `createIndexes` a model may run lazily. */
|
|
10
|
+
readonly ignore?: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
interface WireCommand {
|
|
13
|
+
readonly command: string;
|
|
14
|
+
/** The collection the command targets; `undefined` for session, transaction and admin commands. */
|
|
15
|
+
readonly collection: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
interface CommandCount<T> {
|
|
18
|
+
readonly result: T;
|
|
19
|
+
readonly total: number;
|
|
20
|
+
/** Counted commands, in the order the driver sent them. */
|
|
21
|
+
readonly commands: readonly WireCommand[];
|
|
22
|
+
/** Counts keyed by collection (`(none)` for commands without one). */
|
|
23
|
+
readonly byCollection: Readonly<Record<string, number>>;
|
|
24
|
+
readonly byCommand: Readonly<Record<string, number>>;
|
|
25
|
+
}
|
|
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 };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//#region src/mongo-commands.ts
|
|
2
|
+
async function countCommands(source, fn, options = {}) {
|
|
3
|
+
const client = "getClient" in source ? source.getClient() : source;
|
|
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");
|
|
5
|
+
const commands = [];
|
|
6
|
+
const onStarted = (e) => {
|
|
7
|
+
const collection = collectionOf(e.commandName, e.command);
|
|
8
|
+
if (options.ignore?.includes(e.commandName)) return;
|
|
9
|
+
if (options.commands && !options.commands.includes(e.commandName)) return;
|
|
10
|
+
if (options.collections && (collection === void 0 || !options.collections.includes(collection))) return;
|
|
11
|
+
commands.push({
|
|
12
|
+
command: e.commandName,
|
|
13
|
+
collection
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
client.on("commandStarted", onStarted);
|
|
17
|
+
let result;
|
|
18
|
+
try {
|
|
19
|
+
result = await fn();
|
|
20
|
+
} finally {
|
|
21
|
+
client.off("commandStarted", onStarted);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
result,
|
|
25
|
+
total: commands.length,
|
|
26
|
+
commands,
|
|
27
|
+
byCollection: tally(commands.map((c) => c.collection ?? "(none)")),
|
|
28
|
+
byCommand: tally(commands.map((c) => c.command))
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
async function assertConstantCommands(source, run, sizes, options = {}) {
|
|
32
|
+
if (new Set(sizes).size < 2) throw new Error("[arc-testkit] assertConstantCommands: needs at least two distinct sizes");
|
|
33
|
+
const counts = [];
|
|
34
|
+
for (const size of sizes) counts.push(await countCommands(source, () => run(size), options));
|
|
35
|
+
const shape = (c) => c.commands.map((w) => `${w.collection ?? "(none)"}.${w.command}`).join(", ");
|
|
36
|
+
const [first] = counts;
|
|
37
|
+
if (!first || first.total === 0) throw new Error(`[arc-testkit] assertConstantCommands: size ${sizes[0]} sent no counted command — the measurement saw nothing`);
|
|
38
|
+
if (counts.some((c) => shape(c) !== shape(first))) {
|
|
39
|
+
const lines = counts.map((c, i) => ` at ${sizes[i]}: ${c.total} [${shape(c)}]`).join("\n");
|
|
40
|
+
throw new Error(`[arc-testkit] assertConstantCommands: the commands grow with the size\n${lines}`);
|
|
41
|
+
}
|
|
42
|
+
return counts;
|
|
43
|
+
}
|
|
44
|
+
function collectionOf(name, command) {
|
|
45
|
+
const target = name === "getMore" ? command.collection : command[name];
|
|
46
|
+
return typeof target === "string" ? target : void 0;
|
|
47
|
+
}
|
|
48
|
+
function tally(keys) {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const k of keys) out[k] = (out[k] ?? 0) + 1;
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
export { assertConstantCommands, countCommands };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//#region src/mongo-explain.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Read a MongoDB `explain()` result — find, aggregate, classic or SBE — as the WINNING plan's stage
|
|
4
|
+
* names, the indexes it scans, and its execution stats (`executionStats` verbosity). Rejected plans
|
|
5
|
+
* and query predicates are never read. `assertIndexServed` fails on a COLLSCAN, on a plan scanning
|
|
6
|
+
* no index (an explain of an empty or missing collection), and on every option not met.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const explain = await Model.find({ organizationId, state: "posted" }).explain("executionStats");
|
|
10
|
+
* assertIndexServed(explain, { index: "org_state_date", maxDocsExamined: 40 });
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
interface ExplainSummary {
|
|
14
|
+
/** Stage names of the winning plan(s), outermost first. */
|
|
15
|
+
readonly stages: readonly string[];
|
|
16
|
+
/** Index names the winning plan scans (`_id_` for an id lookup). */
|
|
17
|
+
readonly indexes: readonly string[];
|
|
18
|
+
/** From the first `executionStats` block; `undefined` under `queryPlanner` verbosity. */
|
|
19
|
+
readonly docsExamined: number | undefined;
|
|
20
|
+
readonly keysExamined: number | undefined;
|
|
21
|
+
readonly returned: number | undefined;
|
|
22
|
+
}
|
|
23
|
+
interface IndexServedOptions {
|
|
24
|
+
/** Every one of these must be scanned. */
|
|
25
|
+
readonly index?: string | readonly string[];
|
|
26
|
+
/** Every one of these stages must appear — e.g. `OR` for a `$or` served arm by arm. */
|
|
27
|
+
readonly stages?: readonly string[];
|
|
28
|
+
/** Ceiling on `totalDocsExamined`; requires `executionStats` verbosity. */
|
|
29
|
+
readonly maxDocsExamined?: number;
|
|
30
|
+
}
|
|
31
|
+
declare function summarizeExplain(explain: unknown): ExplainSummary;
|
|
32
|
+
declare function assertIndexServed(explain: unknown, options?: IndexServedOptions): ExplainSummary;
|
|
33
|
+
//#endregion
|
|
34
|
+
export { ExplainSummary, IndexServedOptions, assertIndexServed, summarizeExplain };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//#region src/mongo-explain.ts
|
|
2
|
+
/** Keys whose subtree is a rejected alternative or a query predicate — never plan structure. */
|
|
3
|
+
const SKIPPED = /* @__PURE__ */ new Set([
|
|
4
|
+
"rejectedPlans",
|
|
5
|
+
"allPlansExecution",
|
|
6
|
+
"command",
|
|
7
|
+
"parsedQuery",
|
|
8
|
+
"filter",
|
|
9
|
+
"slotBasedPlan"
|
|
10
|
+
]);
|
|
11
|
+
const ID_LOOKUPS = /* @__PURE__ */ new Set(["IDHACK", "EXPRESS_IDHACK"]);
|
|
12
|
+
function summarizeExplain(explain) {
|
|
13
|
+
const stages = [];
|
|
14
|
+
const indexes = [];
|
|
15
|
+
let stats;
|
|
16
|
+
const walk = (node, inPlan) => {
|
|
17
|
+
if (Array.isArray(node)) {
|
|
18
|
+
for (const item of node) walk(item, inPlan);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (!isRecord(node)) return;
|
|
22
|
+
if (inPlan && typeof node.stage === "string") {
|
|
23
|
+
stages.push(node.stage);
|
|
24
|
+
if (typeof node.indexName === "string") indexes.push(node.indexName);
|
|
25
|
+
else if (ID_LOOKUPS.has(node.stage)) indexes.push("_id_");
|
|
26
|
+
}
|
|
27
|
+
for (const [key, value] of Object.entries(node)) {
|
|
28
|
+
if (SKIPPED.has(key)) continue;
|
|
29
|
+
if (key === "executionStats" && isRecord(value) && typeof value.totalDocsExamined === "number") stats ??= value;
|
|
30
|
+
walk(value, inPlan || key === "winningPlan");
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
walk(explain, false);
|
|
34
|
+
return {
|
|
35
|
+
stages,
|
|
36
|
+
indexes: [...new Set(indexes)],
|
|
37
|
+
docsExamined: numberOr(stats?.totalDocsExamined),
|
|
38
|
+
keysExamined: numberOr(stats?.totalKeysExamined),
|
|
39
|
+
returned: numberOr(stats?.nReturned)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function assertIndexServed(explain, options = {}) {
|
|
43
|
+
const plan = summarizeExplain(explain);
|
|
44
|
+
const fail = (msg) => {
|
|
45
|
+
throw new Error(`[arc-testkit] assertIndexServed: ${msg} — plan [${plan.stages.join(" > ")}], indexes [${plan.indexes.join(", ")}]`);
|
|
46
|
+
};
|
|
47
|
+
if (plan.stages.length === 0) fail("no winning plan found — pass the result of explain()");
|
|
48
|
+
if (plan.stages.includes("COLLSCAN")) fail("the plan scans the collection");
|
|
49
|
+
if (plan.indexes.length === 0) fail("the plan scans no index — is the collection empty or missing?");
|
|
50
|
+
const unused = (typeof options.index === "string" ? [options.index] : options.index ?? []).filter((i) => !plan.indexes.includes(i));
|
|
51
|
+
if (unused.length > 0) fail(`index ${unused.map((i) => `"${i}"`).join(", ")} is not scanned`);
|
|
52
|
+
const absent = (options.stages ?? []).filter((s) => !plan.stages.includes(s));
|
|
53
|
+
if (absent.length > 0) fail(`stage ${absent.join(", ")} is not in the plan`);
|
|
54
|
+
if (options.maxDocsExamined !== void 0) {
|
|
55
|
+
if (plan.docsExamined === void 0) fail("maxDocsExamined needs explain('executionStats')");
|
|
56
|
+
else if (plan.docsExamined > options.maxDocsExamined) fail(`examined ${plan.docsExamined} documents, ceiling ${options.maxDocsExamined}`);
|
|
57
|
+
}
|
|
58
|
+
return plan;
|
|
59
|
+
}
|
|
60
|
+
function isRecord(value) {
|
|
61
|
+
return typeof value === "object" && value !== null;
|
|
62
|
+
}
|
|
63
|
+
function numberOr(value) {
|
|
64
|
+
return typeof value === "number" ? value : void 0;
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { assertIndexServed, summarizeExplain };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/arc-testkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Boot a real @classytic/arc app around the module(s) under test on in-memory MongoDB — the ecosystem test harness. Thin Mongo-typed wrapper over @classytic/arc/testing (arc 2.22+). If your arc module boots green here, it composes into any arc host.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
"./mongo": {
|
|
16
16
|
"types": "./dist/mongo.d.mts",
|
|
17
17
|
"default": "./dist/mongo.mjs"
|
|
18
|
+
},
|
|
19
|
+
"./mongo-commands": {
|
|
20
|
+
"types": "./dist/mongo-commands.d.mts",
|
|
21
|
+
"default": "./dist/mongo-commands.mjs"
|
|
22
|
+
},
|
|
23
|
+
"./mongo-explain": {
|
|
24
|
+
"types": "./dist/mongo-explain.d.mts",
|
|
25
|
+
"default": "./dist/mongo-explain.mjs"
|
|
18
26
|
}
|
|
19
27
|
},
|
|
20
28
|
"main": "./dist/index.mjs",
|
|
@@ -41,7 +49,7 @@
|
|
|
41
49
|
"mongodb-memory-server": ">=10.0.0"
|
|
42
50
|
},
|
|
43
51
|
"peerDependencies": {
|
|
44
|
-
"@classytic/arc": ">=2.
|
|
52
|
+
"@classytic/arc": ">=2.43.0",
|
|
45
53
|
"mongoose": ">=9.4.1"
|
|
46
54
|
},
|
|
47
55
|
"keywords": [
|