@classytic/arc-testkit 0.3.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/dist/mongo.d.mts +87 -0
- package/dist/mongo.mjs +229 -0
- package/dist/pool.d.mts +190 -0
- package/dist/pool.mjs +410 -0
- package/package.json +18 -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/dist/mongo.d.mts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { TestServerProvider } from "./pool.mjs";
|
|
2
|
+
import { Connection } from "mongoose";
|
|
3
|
+
//#region src/mongo.d.ts
|
|
4
|
+
/** WiredTiger cache per server, in GB. 0.25 is mongod's own floor. */
|
|
5
|
+
declare function cacheSizeGB(): number;
|
|
6
|
+
/** mongod CLI args that bound a test server's footprint. */
|
|
7
|
+
declare function mongodMemoryArgs(): string[];
|
|
8
|
+
/**
|
|
9
|
+
* Worker count bounded by cores AND memory.
|
|
10
|
+
*
|
|
11
|
+
* Cores alone is the trap: a 28-core box still has 32 GB, and a worker costs an
|
|
12
|
+
* app plus every server it holds live. `envVar` lets a host keep its existing
|
|
13
|
+
* override name rather than forcing a rename through every CI config.
|
|
14
|
+
*/
|
|
15
|
+
declare function safeWorkerCount(envVar?: string): number;
|
|
16
|
+
/** How many servers the run may boot, bounded by FREE memory at call time. */
|
|
17
|
+
declare function safePoolSize(requested: number): number;
|
|
18
|
+
/** Splice a database name into a mongodb:// URI, preserving its query string. */
|
|
19
|
+
declare function withDbName(baseUri: string, dbName: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Block until every registered model's index build has finished.
|
|
22
|
+
*
|
|
23
|
+
* Mongoose's `autoIndex` fires `createIndex` in the BACKGROUND and does not
|
|
24
|
+
* await it. On a private, otherwise-idle mongod those builds always won the
|
|
25
|
+
* race against the first request, so suites asserting DB-ENFORCED constraints
|
|
26
|
+
* ("a second active shift on one register → 409", "exactly one winner of the
|
|
27
|
+
* allocation race") passed on luck. Pool the server under N parallel workers
|
|
28
|
+
* and the builds queue — and those same suites flake.
|
|
29
|
+
*
|
|
30
|
+
* The connection is injectable because a suite may hold one that is not
|
|
31
|
+
* mongoose's default. That parameter existed in one of the two forks of this
|
|
32
|
+
* function and not the other; the fail-loud note below existed in the other and
|
|
33
|
+
* not the one. This is the union.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately NOT `.catch(() => undefined)`. Swallowing defeats the point: a
|
|
36
|
+
* unique index that failed to build leaves the suite asserting a constraint
|
|
37
|
+
* that does not exist, and it would pass for the wrong reason — precisely the
|
|
38
|
+
* failure this function was added to remove.
|
|
39
|
+
*/
|
|
40
|
+
declare function awaitIndexBuilds(connection?: {
|
|
41
|
+
readyState: number;
|
|
42
|
+
models: Record<string, {
|
|
43
|
+
init(): Promise<unknown>;
|
|
44
|
+
}>;
|
|
45
|
+
}): Promise<void>;
|
|
46
|
+
/** The subset of the mongoose module this adapter uses. */
|
|
47
|
+
interface MongooseLike {
|
|
48
|
+
connection: {
|
|
49
|
+
readyState: number;
|
|
50
|
+
models: Record<string, {
|
|
51
|
+
init(): Promise<unknown>;
|
|
52
|
+
}>;
|
|
53
|
+
dropDatabase(): Promise<unknown>;
|
|
54
|
+
deleteModel(filter: RegExp): unknown;
|
|
55
|
+
};
|
|
56
|
+
connect(uri: string): Promise<unknown>;
|
|
57
|
+
disconnect(): Promise<unknown>;
|
|
58
|
+
}
|
|
59
|
+
interface MongoServerProviderOptions {
|
|
60
|
+
/** Extra mongod args, appended after the memory bounds. */
|
|
61
|
+
readonly args?: readonly string[];
|
|
62
|
+
/** Await index builds after each lease connects. Default true — see above. */
|
|
63
|
+
readonly awaitIndexes?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* The mongoose instance to bind. Default: `await import("mongoose")`.
|
|
66
|
+
*
|
|
67
|
+
* INJECT IT when the caller might resolve a different copy than this package
|
|
68
|
+
* does. Models are registered on a mongoose instance, so a lease that
|
|
69
|
+
* connects instance A while the code under test registers on instance B
|
|
70
|
+
* yields a connection with no models on it — and the failure surfaces later,
|
|
71
|
+
* as an empty collection or a missing index, never as "wrong mongoose".
|
|
72
|
+
*
|
|
73
|
+
* A workspace that hoists exactly one copy is fine on the default; one that
|
|
74
|
+
* lets packages resolve their own is not, and cannot tell from the outside.
|
|
75
|
+
*/
|
|
76
|
+
readonly mongoose?: MongooseLike;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The Mongo `TestServerProvider`.
|
|
80
|
+
*
|
|
81
|
+
* `transactional` is a 1-node replica set: the smallest shape that supports
|
|
82
|
+
* transactions, and a strict superset of a standalone. That is what lets the
|
|
83
|
+
* pool serve both kinds from one process when sharing is on.
|
|
84
|
+
*/
|
|
85
|
+
declare function mongoServerProvider(options?: MongoServerProviderOptions): TestServerProvider<Connection>;
|
|
86
|
+
//#endregion
|
|
87
|
+
export { MongoServerProviderOptions, MongooseLike, awaitIndexBuilds, cacheSizeGB, mongoServerProvider, mongodMemoryArgs, safePoolSize, safeWorkerCount, withDbName };
|
package/dist/mongo.mjs
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { cpus, freemem, totalmem } from "node:os";
|
|
2
|
+
//#region src/mongo.ts
|
|
3
|
+
/**
|
|
4
|
+
* `@classytic/arc-testkit/mongo` — the Mongo adapter for the driver-agnostic pool.
|
|
5
|
+
*
|
|
6
|
+
* Implements `TestServerProvider` in two methods and owns the one thing that is
|
|
7
|
+
* genuinely Mongo-specific: how a mongod is bounded, started, and how an
|
|
8
|
+
* isolated database is minted and dropped on it.
|
|
9
|
+
*
|
|
10
|
+
* ## The memory numbers are not arbitrary
|
|
11
|
+
*
|
|
12
|
+
* mongod sizes its WiredTiger cache from HOST RAM — `max(256MB, 50% of
|
|
13
|
+
* (RAM - 1GB))` — so EVERY server believes it may take half the machine. It
|
|
14
|
+
* does not reserve that up front (capping changes idle footprint by ~3%), but
|
|
15
|
+
* it grows into it under load, which is exactly what a test run is. With N
|
|
16
|
+
* workers each holding servers live, the run is bounded by memory long before
|
|
17
|
+
* it is bounded by cores.
|
|
18
|
+
*
|
|
19
|
+
* Measured on one fleet's heaviest project: 10 workers → 32 mongods, box at
|
|
20
|
+
* 29.6/31.7 GB. 4 workers → 16 mongods, 22.6 GB, same tests passing.
|
|
21
|
+
*
|
|
22
|
+
* That measurement lived in two hand-synced copies in two repos, with a comment
|
|
23
|
+
* asking future readers to keep them in step. They diverged by 118 lines. It
|
|
24
|
+
* lives here now because a number nobody can re-derive is exactly the kind of
|
|
25
|
+
* thing that must have ONE home.
|
|
26
|
+
*/
|
|
27
|
+
/** WiredTiger cache per server, in GB. 0.25 is mongod's own floor. */
|
|
28
|
+
function cacheSizeGB() {
|
|
29
|
+
const raw = Number(process.env.TEST_MONGO_CACHE_GB);
|
|
30
|
+
return Number.isFinite(raw) && raw >= .25 ? raw : .25;
|
|
31
|
+
}
|
|
32
|
+
/** mongod CLI args that bound a test server's footprint. */
|
|
33
|
+
function mongodMemoryArgs() {
|
|
34
|
+
return [
|
|
35
|
+
"--wiredTigerCacheSizeGB",
|
|
36
|
+
String(cacheSizeGB()),
|
|
37
|
+
"--setParameter",
|
|
38
|
+
"diagnosticDataCollectionEnabled=false"
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Worker count bounded by cores AND memory.
|
|
43
|
+
*
|
|
44
|
+
* Cores alone is the trap: a 28-core box still has 32 GB, and a worker costs an
|
|
45
|
+
* app plus every server it holds live. `envVar` lets a host keep its existing
|
|
46
|
+
* override name rather than forcing a rename through every CI config.
|
|
47
|
+
*/
|
|
48
|
+
function safeWorkerCount(envVar = "ARC_TEST_WORKERS") {
|
|
49
|
+
const explicit = Number(process.env[envVar]);
|
|
50
|
+
if (Number.isFinite(explicit) && explicit >= 1) return Math.trunc(explicit);
|
|
51
|
+
const byCores = Math.min(10, Math.max(2, cpus().length - 2));
|
|
52
|
+
const byMemory = Math.floor(totalmem() / 1024 ** 3 * .25 / 1.5);
|
|
53
|
+
return Math.max(2, Math.min(byCores, byMemory || byCores));
|
|
54
|
+
}
|
|
55
|
+
/** How many servers the run may boot, bounded by FREE memory at call time. */
|
|
56
|
+
function safePoolSize(requested) {
|
|
57
|
+
const perServerGB = cacheSizeGB() + .15;
|
|
58
|
+
const budgetGB = freemem() / 1024 ** 3 * .5;
|
|
59
|
+
const affordable = Math.floor(budgetGB / perServerGB);
|
|
60
|
+
return Math.max(1, Math.min(requested, affordable || 1));
|
|
61
|
+
}
|
|
62
|
+
/** Splice a database name into a mongodb:// URI, preserving its query string. */
|
|
63
|
+
function withDbName(baseUri, dbName) {
|
|
64
|
+
const q = baseUri.indexOf("?");
|
|
65
|
+
const query = q === -1 ? "" : baseUri.slice(q);
|
|
66
|
+
let head = q === -1 ? baseUri : baseUri.slice(0, q);
|
|
67
|
+
const schemeEnd = head.indexOf("://") + 3;
|
|
68
|
+
const slash = head.indexOf("/", schemeEnd);
|
|
69
|
+
if (slash !== -1) head = head.slice(0, slash);
|
|
70
|
+
return `${head}/${dbName}${query}`;
|
|
71
|
+
}
|
|
72
|
+
let mmsCache;
|
|
73
|
+
/**
|
|
74
|
+
* Load `mongodb-memory-server` by RESOLVED PATH, not by specifier.
|
|
75
|
+
*
|
|
76
|
+
* Consumers commonly alias the bare specifier to a shim (to keep a binary
|
|
77
|
+
* download out of a lane that never starts a server). Importing it by name
|
|
78
|
+
* from here would resolve back through that alias and recurse.
|
|
79
|
+
*/
|
|
80
|
+
function loadMms() {
|
|
81
|
+
mmsCache ??= (async () => {
|
|
82
|
+
const { createRequire } = await import("node:module");
|
|
83
|
+
const { pathToFileURL } = await import("node:url");
|
|
84
|
+
const mod = await import(pathToFileURL(createRequire(import.meta.url).resolve("mongodb-memory-server")).href);
|
|
85
|
+
return mod.MongoMemoryServer ? mod : mod.default;
|
|
86
|
+
})();
|
|
87
|
+
return mmsCache;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Block until every registered model's index build has finished.
|
|
91
|
+
*
|
|
92
|
+
* Mongoose's `autoIndex` fires `createIndex` in the BACKGROUND and does not
|
|
93
|
+
* await it. On a private, otherwise-idle mongod those builds always won the
|
|
94
|
+
* race against the first request, so suites asserting DB-ENFORCED constraints
|
|
95
|
+
* ("a second active shift on one register → 409", "exactly one winner of the
|
|
96
|
+
* allocation race") passed on luck. Pool the server under N parallel workers
|
|
97
|
+
* and the builds queue — and those same suites flake.
|
|
98
|
+
*
|
|
99
|
+
* The connection is injectable because a suite may hold one that is not
|
|
100
|
+
* mongoose's default. That parameter existed in one of the two forks of this
|
|
101
|
+
* function and not the other; the fail-loud note below existed in the other and
|
|
102
|
+
* not the one. This is the union.
|
|
103
|
+
*
|
|
104
|
+
* Deliberately NOT `.catch(() => undefined)`. Swallowing defeats the point: a
|
|
105
|
+
* unique index that failed to build leaves the suite asserting a constraint
|
|
106
|
+
* that does not exist, and it would pass for the wrong reason — precisely the
|
|
107
|
+
* failure this function was added to remove.
|
|
108
|
+
*/
|
|
109
|
+
async function awaitIndexBuilds(connection) {
|
|
110
|
+
const conn = connection ?? (await import("mongoose")).default.connection;
|
|
111
|
+
if (conn.readyState !== 1) return;
|
|
112
|
+
const models = Object.values(conn.models);
|
|
113
|
+
await Promise.all(models.map((model) => model.init()));
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The Mongo `TestServerProvider`.
|
|
117
|
+
*
|
|
118
|
+
* `transactional` is a 1-node replica set: the smallest shape that supports
|
|
119
|
+
* transactions, and a strict superset of a standalone. That is what lets the
|
|
120
|
+
* pool serve both kinds from one process when sharing is on.
|
|
121
|
+
*/
|
|
122
|
+
function mongoServerProvider(options = {}) {
|
|
123
|
+
const args = [...mongodMemoryArgs(), ...options.args ?? []];
|
|
124
|
+
const awaitIndexes = options.awaitIndexes ?? true;
|
|
125
|
+
return {
|
|
126
|
+
name: "mongodb-memory-server",
|
|
127
|
+
async start(kind, opts) {
|
|
128
|
+
const mms = await loadMms();
|
|
129
|
+
/**
|
|
130
|
+
* A caller-supplied `createOptions` REPLACES the defaults rather than
|
|
131
|
+
* merging with them, because the suites that pass one are flipping fail
|
|
132
|
+
* points and know exactly what mongod they need — merging would silently
|
|
133
|
+
* re-impose args they deliberately omitted.
|
|
134
|
+
*
|
|
135
|
+
* The memory cap is on the default path, and that matters MORE here, not
|
|
136
|
+
* less: this is what a bare `vitest <file>` and every `forcePrivate` suite
|
|
137
|
+
* gets, so leaving it uncapped reintroduces mongod's
|
|
138
|
+
* half-the-machine cache default exactly where nobody is watching.
|
|
139
|
+
*/
|
|
140
|
+
const create = opts?.createOptions;
|
|
141
|
+
const server = kind === "transactional" ? await mms.MongoMemoryReplSet.create(create ?? { replSet: {
|
|
142
|
+
count: 1,
|
|
143
|
+
storageEngine: "wiredTiger",
|
|
144
|
+
args
|
|
145
|
+
} }) : await mms.MongoMemoryServer.create(create ?? { instance: { args } });
|
|
146
|
+
return {
|
|
147
|
+
uri: server.getUri(),
|
|
148
|
+
stop: async () => {
|
|
149
|
+
await server.stop();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
/**
|
|
154
|
+
* Wait for index builds — AFTER the app has booted and registered its models.
|
|
155
|
+
*
|
|
156
|
+
* Doing this inside `lease()` waits for an EMPTY model registry and returns
|
|
157
|
+
* instantly. That is indistinguishable from working right up until a suite
|
|
158
|
+
* asserting a DB-enforced constraint flakes: a unique index missing so a
|
|
159
|
+
* duplicate returns 201 instead of 409, an allocation race returning
|
|
160
|
+
* [200, 200] instead of [200, 409], a `$geoNear` query with no index.
|
|
161
|
+
* All three happened.
|
|
162
|
+
*/
|
|
163
|
+
async afterBoot() {
|
|
164
|
+
if (!awaitIndexes) return;
|
|
165
|
+
await awaitIndexBuilds((options.mongoose ?? (await import("mongoose")).default).connection);
|
|
166
|
+
},
|
|
167
|
+
/** The lease's URI, without connecting anything. */
|
|
168
|
+
namespaceUri(server, namespace) {
|
|
169
|
+
return withDbName(server.uri, namespace);
|
|
170
|
+
},
|
|
171
|
+
/**
|
|
172
|
+
* Drop a namespace leased via `namespaceUri`.
|
|
173
|
+
*
|
|
174
|
+
* Opens a throwaway connection to do it, because by definition nothing here
|
|
175
|
+
* holds one — the caller owned the connection and has already closed it.
|
|
176
|
+
* A failed drop leaks ONE database on an ephemeral server and is never a
|
|
177
|
+
* test failure: isolation is guaranteed by the unique name, not by the drop.
|
|
178
|
+
*/
|
|
179
|
+
async dropNamespace(server, namespace) {
|
|
180
|
+
const mod = (await import("mongoose")).default;
|
|
181
|
+
try {
|
|
182
|
+
const conn = await mod.createConnection(withDbName(server.uri, namespace)).asPromise();
|
|
183
|
+
await conn.dropDatabase().catch(() => void 0);
|
|
184
|
+
await conn.close().catch(() => void 0);
|
|
185
|
+
} catch {}
|
|
186
|
+
},
|
|
187
|
+
/**
|
|
188
|
+
* Binds mongoose's DEFAULT connection, deliberately — NOT `createConnection`.
|
|
189
|
+
*
|
|
190
|
+
* Kernels register their models against the default connection, so a lease
|
|
191
|
+
* that opened a side connection would hand arc a handle with no models on
|
|
192
|
+
* it. Nothing would throw; the suite would simply query empty collections.
|
|
193
|
+
* This mirrors arc's own built-in `mongoMemoryDatabase` where it matters.
|
|
194
|
+
*/
|
|
195
|
+
async lease(server, namespace) {
|
|
196
|
+
const mongoose = options.mongoose ?? (await import("mongoose")).default;
|
|
197
|
+
const uri = withDbName(server.uri, namespace);
|
|
198
|
+
if (mongoose.connection.readyState !== 0) await mongoose.disconnect();
|
|
199
|
+
await mongoose.connect(uri);
|
|
200
|
+
/**
|
|
201
|
+
* Clear the model registry for this boot.
|
|
202
|
+
*
|
|
203
|
+
* mongoose LOCKS a schema on first `model()`, and the registry outlives a
|
|
204
|
+
* single boot within one file — so a second boot in the same test file
|
|
205
|
+
* dies on "model X already exists", or worse, silently reuses the first
|
|
206
|
+
* boot's schema.
|
|
207
|
+
*/
|
|
208
|
+
mongoose.connection.deleteModel(/.*/);
|
|
209
|
+
return {
|
|
210
|
+
uri,
|
|
211
|
+
connection: mongoose.connection,
|
|
212
|
+
/**
|
|
213
|
+
* DROP the database, then disconnect. Dropping is what preserves the
|
|
214
|
+
* isolation contract a private server gave for free: a suite asserting
|
|
215
|
+
* on empty collections must still see an empty world. The SERVER keeps
|
|
216
|
+
* running — stopping it here would break every other lease on it.
|
|
217
|
+
*/
|
|
218
|
+
teardown: async () => {
|
|
219
|
+
if (mongoose.connection.readyState !== 0) {
|
|
220
|
+
await mongoose.connection.dropDatabase().catch(() => void 0);
|
|
221
|
+
await mongoose.disconnect().catch(() => void 0);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
export { awaitIndexBuilds, cacheSizeGB, mongoServerProvider, mongodMemoryArgs, safePoolSize, safeWorkerCount, withDbName };
|
package/dist/pool.d.mts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { TestDatabase, TestDatabaseFactory } from "@classytic/arc/testing";
|
|
2
|
+
//#region src/pool.d.ts
|
|
3
|
+
/** Which server shape a suite needs. */
|
|
4
|
+
type ServerKind = "standalone" | "transactional";
|
|
5
|
+
/** A running server the pool may hand out namespaces on. */
|
|
6
|
+
interface TestServerHandle {
|
|
7
|
+
/** Base connection string, WITHOUT a namespace selected. */
|
|
8
|
+
readonly uri: string;
|
|
9
|
+
stop(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* What a driver must supply. Two methods, deliberately.
|
|
13
|
+
*
|
|
14
|
+
* Anything a third method would carry (naming, isolation, refcounts, handshake)
|
|
15
|
+
* is the same in every driver, so putting it here would duplicate it per driver
|
|
16
|
+
* — which is precisely the failure this module exists to end.
|
|
17
|
+
*/
|
|
18
|
+
interface TestServerProvider<TConn> {
|
|
19
|
+
/** Human name, used only in diagnostics. */
|
|
20
|
+
readonly name: string;
|
|
21
|
+
/**
|
|
22
|
+
* Boot the expensive server. Called AT MOST ONCE per kind per process, and
|
|
23
|
+
* only when a suite actually leases — never eagerly.
|
|
24
|
+
*/
|
|
25
|
+
start(kind: ServerKind, opts?: {
|
|
26
|
+
createOptions?: unknown;
|
|
27
|
+
}): Promise<TestServerHandle>;
|
|
28
|
+
/**
|
|
29
|
+
* Hand out an isolated namespace on an already-running server, CONNECTED.
|
|
30
|
+
*
|
|
31
|
+
* MUST be cheap — a name mint, not a process spawn. The returned
|
|
32
|
+
* `teardown()` MUST drop only that namespace and MUST NOT stop the server.
|
|
33
|
+
*/
|
|
34
|
+
lease(server: TestServerHandle, namespace: string): Promise<TestDatabase<TConn>>;
|
|
35
|
+
/**
|
|
36
|
+
* Optional: the same isolated namespace as a URI, WITHOUT connecting.
|
|
37
|
+
*
|
|
38
|
+
* For callers that own their own connection — a suite handed a URI to connect
|
|
39
|
+
* itself, or a shim standing in for the driver's own server class. Routing
|
|
40
|
+
* those through `lease()` would bind a connection they never asked for and
|
|
41
|
+
* then have them connect a second time over it.
|
|
42
|
+
*
|
|
43
|
+
* URI composition is genuinely driver-specific (a database path segment in
|
|
44
|
+
* Mongo, a `search_path` in Postgres, a file path in SQLite), which is why it
|
|
45
|
+
* is here and not in the pool. Optional because a driver with no URI concept
|
|
46
|
+
* can legitimately not offer it.
|
|
47
|
+
*/
|
|
48
|
+
namespaceUri?(server: TestServerHandle, namespace: string): string;
|
|
49
|
+
/** Optional: drop a namespace leased via `namespaceUri`. */
|
|
50
|
+
dropNamespace?(server: TestServerHandle, namespace: string): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Optional: settle driver work that can only happen AFTER the app has booted.
|
|
53
|
+
*
|
|
54
|
+
* `lease()` runs before arc constructs anything, so at that point the app has
|
|
55
|
+
* registered no models, no schemas and no indexes — any "wait for the schema
|
|
56
|
+
* to be ready" work done there waits for an empty set and returns instantly.
|
|
57
|
+
*
|
|
58
|
+
* That is not a hypothetical ordering nicety. Doing Mongo's index-build wait
|
|
59
|
+
* inside `lease()` looked right and silently did nothing: three suites that
|
|
60
|
+
* assert DB-ENFORCED constraints went flaky — a unique index missing so a
|
|
61
|
+
* duplicate returned 201 instead of 409, an allocation race returning
|
|
62
|
+
* [200, 200] instead of [200, 409], and a `$geoNear` query failing outright
|
|
63
|
+
* for want of its index. On a private idle server those builds always won the
|
|
64
|
+
* race; pooled under N workers they queue.
|
|
65
|
+
*
|
|
66
|
+
* Called by `withPool` after the boot function resolves.
|
|
67
|
+
*/
|
|
68
|
+
afterBoot?(): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
interface TestDatabasePoolOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Env-var prefix for the cross-worker handshake. Two runs with different
|
|
73
|
+
* prefixes (a spine run and a host run) can share a machine without either
|
|
74
|
+
* adopting the other's servers.
|
|
75
|
+
*/
|
|
76
|
+
readonly envPrefix?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Which kinds to serve from ONE server.
|
|
79
|
+
*
|
|
80
|
+
* Default `true`: a single transactional server (e.g. a 1-node replica set)
|
|
81
|
+
* answers both kinds, because it is a strict superset of a standalone. This
|
|
82
|
+
* halves resident memory, and memory — not CPU — is what bounds a large test
|
|
83
|
+
* run: a worker costs an app plus every server it keeps live.
|
|
84
|
+
*
|
|
85
|
+
* Set `false` to boot a genuinely separate standalone. The ONLY reason to is
|
|
86
|
+
* enforcement: on a real standalone, a suite that quietly requires
|
|
87
|
+
* transactions FAILS instead of passing on a capability it never declared.
|
|
88
|
+
* That is a real property — but it costs an entire server process, and once
|
|
89
|
+
* lanes are enforced the same fact is better stated by a declaration than
|
|
90
|
+
* bought with RAM.
|
|
91
|
+
*/
|
|
92
|
+
readonly shareTransactionalServer?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Stop servers once the last lease is released. Off by default: a batch run
|
|
95
|
+
* ends anyway and `globalSetup`'s teardown handles it, while an early stop
|
|
96
|
+
* between two suites would pay the boot cost again. Worth enabling for watch
|
|
97
|
+
* mode, where idle periods are long and frequent.
|
|
98
|
+
*/
|
|
99
|
+
readonly stopWhenIdle?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* How many servers to run PER KIND. Default 1.
|
|
102
|
+
*
|
|
103
|
+
* One shared server is a bottleneck once enough workers hammer it: every
|
|
104
|
+
* lease's writes, index builds and drops queue behind every other worker's.
|
|
105
|
+
* N servers spread that load, and `slotOf` pins each worker to one of them so
|
|
106
|
+
* a given worker's sequential files never contend with each other either.
|
|
107
|
+
*
|
|
108
|
+
* Size it against MEMORY, not cores — see `safePoolSize` in the mongo adapter.
|
|
109
|
+
* A worker costs an app plus every server it holds live, and mongod sizes its
|
|
110
|
+
* cache from host RAM, so the run hits a memory wall long before a CPU one.
|
|
111
|
+
*/
|
|
112
|
+
readonly poolSize?: number;
|
|
113
|
+
/**
|
|
114
|
+
* Which pool slot this process owns. Default: the vitest worker id.
|
|
115
|
+
*
|
|
116
|
+
* `VITEST_POOL_ID` is 1-based and stable for a worker's lifetime, so every
|
|
117
|
+
* file that worker runs lands on the same server. Outside a worker (a script,
|
|
118
|
+
* a single-process run) it falls back to slot 0, which is correct: one
|
|
119
|
+
* process, one server.
|
|
120
|
+
*/
|
|
121
|
+
readonly slotOf?: (poolSize: number) => number;
|
|
122
|
+
/**
|
|
123
|
+
* Server kinds `globalSetup.setup()` starts EAGERLY and publishes.
|
|
124
|
+
*
|
|
125
|
+
* This is not an optimisation — it is what makes pooling work at all across
|
|
126
|
+
* more than one process, and getting it wrong is expensive in a way that
|
|
127
|
+
* still passes.
|
|
128
|
+
*
|
|
129
|
+
* The memo inside this module is per-PROCESS. vitest runs N worker processes,
|
|
130
|
+
* so with nothing pre-booted every worker finds no published URI, boots its
|
|
131
|
+
* own server, and the run ends up with N of them instead of one. Measured
|
|
132
|
+
* when this defaulted to empty: **173 mongod processes at peak**, the run
|
|
133
|
+
* ~2x slower, and one pass going red from resource exhaustion. Every test
|
|
134
|
+
* still passed on the run that completed — the only symptom was the machine.
|
|
135
|
+
*
|
|
136
|
+
* `setup()` runs in the vitest MAIN process before any worker starts, so a
|
|
137
|
+
* server started there can be published on the environment that every worker
|
|
138
|
+
* inherits. That is the only point at which one server can be guaranteed.
|
|
139
|
+
*
|
|
140
|
+
* Default `["transactional"]`: one server, a strict superset of a standalone,
|
|
141
|
+
* serving both kinds. Pass `[]` for a genuinely lazy pool — correct only when
|
|
142
|
+
* the run is single-process, or when nothing will lease.
|
|
143
|
+
*/
|
|
144
|
+
readonly preboot?: readonly ServerKind[];
|
|
145
|
+
/** Escape hatch: force per-suite private servers (the unpooled behaviour). */
|
|
146
|
+
readonly disabled?: boolean;
|
|
147
|
+
}
|
|
148
|
+
interface TestDatabasePool<TConn> {
|
|
149
|
+
/** Hand to `bootModuleApp({ database })`. */
|
|
150
|
+
readonly factory: TestDatabaseFactory<TConn>;
|
|
151
|
+
/** Wrap a boot function so it defaults to the pool. */
|
|
152
|
+
withPool<TResult, TModules, TOptions extends {
|
|
153
|
+
database?: unknown;
|
|
154
|
+
}>(boot: (modules: TModules, options?: TOptions) => TResult | Promise<TResult>): (modules: TModules, options?: TOptions) => Promise<TResult>;
|
|
155
|
+
/**
|
|
156
|
+
* An isolated namespace as a URI, for a caller that owns its own connection.
|
|
157
|
+
* Throws if the driver does not implement `namespaceUri`.
|
|
158
|
+
*/
|
|
159
|
+
leaseNamespace(kind?: ServerKind, opts?: {
|
|
160
|
+
exact?: boolean;
|
|
161
|
+
forcePrivate?: boolean;
|
|
162
|
+
createOptions?: unknown;
|
|
163
|
+
}): Promise<{
|
|
164
|
+
uri: string;
|
|
165
|
+
namespace: string;
|
|
166
|
+
release(): Promise<void>;
|
|
167
|
+
}>;
|
|
168
|
+
/** vitest `globalSetup` pair. Starts NOTHING; it only publishes the handshake. */
|
|
169
|
+
readonly globalSetup: {
|
|
170
|
+
setup(): Promise<void>;
|
|
171
|
+
teardown(): Promise<void>;
|
|
172
|
+
};
|
|
173
|
+
/** Diagnostics — servers actually started in THIS process. */
|
|
174
|
+
stats(): {
|
|
175
|
+
started: ServerKind[];
|
|
176
|
+
servers: number;
|
|
177
|
+
leases: number;
|
|
178
|
+
live: number;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
declare function createTestDatabasePool<TConn>(provider: TestServerProvider<TConn>, options?: TestDatabasePoolOptions): TestDatabasePool<TConn>;
|
|
182
|
+
/**
|
|
183
|
+
* Publish a started server so other worker PROCESSES reuse it.
|
|
184
|
+
*
|
|
185
|
+
* Only a process that owns servers calls this — typically an eager
|
|
186
|
+
* `globalSetup` in a run that prefers boot-cost-up-front over laziness.
|
|
187
|
+
*/
|
|
188
|
+
declare function publishPoolServers(servers: Partial<Record<ServerKind, readonly string[]>>, envPrefix?: string): void;
|
|
189
|
+
//#endregion
|
|
190
|
+
export { ServerKind, TestDatabasePool, TestDatabasePoolOptions, TestServerHandle, TestServerProvider, createTestDatabasePool, publishPoolServers };
|
package/dist/pool.mjs
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
//#region src/pool.ts
|
|
6
|
+
/**
|
|
7
|
+
* `@classytic/arc-testkit/pool` — a POOLED `TestDatabaseFactory`, driver-agnostic.
|
|
8
|
+
*
|
|
9
|
+
* ## The gap this fills
|
|
10
|
+
*
|
|
11
|
+
* `@classytic/arc/testing` defines the seam and says who should fill it:
|
|
12
|
+
*
|
|
13
|
+
* > Arc core never names a driver: kits ship factories (`mongoMemoryDatabase`
|
|
14
|
+
* > is the built-in default; a sqlitekit/pgkit testkit ships its own).
|
|
15
|
+
*
|
|
16
|
+
* Arc's built-in default provisions a SERVER PER SUITE. That is correct and
|
|
17
|
+
* unsurprising for one package; across a workspace it is the dominant cost —
|
|
18
|
+
* measured in one fleet at ~72 server spin-ups per run, ~0.9s to boot and up to
|
|
19
|
+
* ~1.5s to stop each, plus the resident memory of every one held live at once.
|
|
20
|
+
*
|
|
21
|
+
* So two consumers independently built the same missing thing: boot the
|
|
22
|
+
* expensive server ONCE per run, hand each suite an isolated logical namespace
|
|
23
|
+
* on it. Both then drifted — measured across their five shared filenames,
|
|
24
|
+
* 18 to 285 differing lines each, in BOTH directions (one grew an injectable
|
|
25
|
+
* connection, the other grew a fail-loud index guard; neither got both). One of
|
|
26
|
+
* them even carried the instruction "keep the two in sync". Hand-sync is not a
|
|
27
|
+
* mechanism, and it did not hold.
|
|
28
|
+
*
|
|
29
|
+
* ## Why pooling belongs HERE and not in a driver package
|
|
30
|
+
*
|
|
31
|
+
* "One expensive server, many cheap isolated namespaces" is not a Mongo idea.
|
|
32
|
+
* It is a schema in Postgres, a database in MySQL or Mongo, a file in SQLite.
|
|
33
|
+
* Everything that is genuinely hard — laziness, the cross-worker handshake,
|
|
34
|
+
* refcounted shutdown, teardown ordering, name minting that cannot collide
|
|
35
|
+
* across parallel worker processes — is driver-agnostic. Only two operations
|
|
36
|
+
* are not, and those are the port.
|
|
37
|
+
*
|
|
38
|
+
* ## The port
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* const pool = createTestDatabasePool(mongoServerProvider());
|
|
42
|
+
* // vitest globalSetup
|
|
43
|
+
* export const { setup, teardown } = pool.globalSetup;
|
|
44
|
+
* // a suite
|
|
45
|
+
* const boot = pool.withPool(bootModuleApp);
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* A driver implements two methods. Everything else is this module's problem.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* A namespace name nothing else in the run can produce.
|
|
52
|
+
*
|
|
53
|
+
* pid + monotonic counter + randomness, because all three failure modes are
|
|
54
|
+
* real: two worker PROCESSES minting concurrently, two suites in ONE process
|
|
55
|
+
* minting in the same millisecond, and a pid reused by the OS across runs.
|
|
56
|
+
*/
|
|
57
|
+
let counter = 0;
|
|
58
|
+
function mintNamespace() {
|
|
59
|
+
counter += 1;
|
|
60
|
+
return `arct${process.pid}x${counter}${randomBytes(3).toString("hex")}`;
|
|
61
|
+
}
|
|
62
|
+
function createTestDatabasePool(provider, options = {}) {
|
|
63
|
+
const prefix = options.envPrefix ?? "ARC_TESTKIT";
|
|
64
|
+
const shareTransactional = options.shareTransactionalServer ?? true;
|
|
65
|
+
const uriEnv = (kind) => `${prefix}_POOL_${kind.toUpperCase()}_URI`;
|
|
66
|
+
const handshakeFile = join(tmpdir(), `${prefix.toLowerCase()}-pool.json`);
|
|
67
|
+
/**
|
|
68
|
+
* Which server actually serves a kind.
|
|
69
|
+
*
|
|
70
|
+
* With sharing on, a standalone request is answered by the transactional
|
|
71
|
+
* server — a superset, so nothing a standalone can DO is lost.
|
|
72
|
+
*
|
|
73
|
+
* `exact` is for when what matters is what a standalone CANNOT do. A suite
|
|
74
|
+
* proving a path fails closed without transactions is the motivating case:
|
|
75
|
+
* hand it the shared replica set and the path succeeds, the assertion
|
|
76
|
+
* inverts, and the test now proves the opposite of its own name while still
|
|
77
|
+
* looking meaningful. (Found exactly that way — one real suite, named
|
|
78
|
+
* "REQUIRES a transaction BY DEFAULT — fails closed on standalone Mongo".)
|
|
79
|
+
*
|
|
80
|
+
* A driver shim is the other case: calling `MongoMemoryServer.create()`
|
|
81
|
+
* rather than `MongoMemoryReplSet.create()` IS the declaration.
|
|
82
|
+
*
|
|
83
|
+
* Note the asymmetry — sharing is safe for capability and unsafe for the
|
|
84
|
+
* absence of it, so `exact` defaults to false and is opted into.
|
|
85
|
+
*/
|
|
86
|
+
const resolveKind = (kind, exact = false) => exact || !shareTransactional ? kind : "transactional";
|
|
87
|
+
const poolSize = Math.max(1, Math.trunc(options.poolSize ?? 1));
|
|
88
|
+
/**
|
|
89
|
+
* Which of the `poolSize` servers this process uses.
|
|
90
|
+
*
|
|
91
|
+
* `VITEST_POOL_ID` is 1-based and stable for a worker's lifetime, so every
|
|
92
|
+
* file that worker runs lands on the SAME server: N workers spread over N
|
|
93
|
+
* servers, and one worker's sequential files never contend with each other.
|
|
94
|
+
* Anything unparseable means "not a worker" and takes slot 0 — one process,
|
|
95
|
+
* one server, which is the correct reading and not a silent default.
|
|
96
|
+
*/
|
|
97
|
+
const slotOf = options.slotOf ?? ((size) => {
|
|
98
|
+
if (size <= 1) return 0;
|
|
99
|
+
const raw = process.env.VITEST_POOL_ID ?? process.env.VITEST_WORKER_ID;
|
|
100
|
+
const n = Number(raw);
|
|
101
|
+
if (!Number.isFinite(n) || n < 1) return 0;
|
|
102
|
+
return (Math.trunc(n) - 1) % size;
|
|
103
|
+
});
|
|
104
|
+
/**
|
|
105
|
+
* In-process memo, keyed by `${resolvedKind}#${slot}`.
|
|
106
|
+
*
|
|
107
|
+
* The SLOT is part of the key, not just the kind: with a pool of N, one
|
|
108
|
+
* process may legitimately hold handles to different servers (a standalone in
|
|
109
|
+
* slot 2, a transactional in slot 2), and keying by kind alone would hand the
|
|
110
|
+
* second request the first one's server.
|
|
111
|
+
*/
|
|
112
|
+
const starting = /* @__PURE__ */ new Map();
|
|
113
|
+
const started = /* @__PURE__ */ new Map();
|
|
114
|
+
/**
|
|
115
|
+
* Private (`forcePrivate`) servers, which live outside `started` because they
|
|
116
|
+
* are deliberately not shared and not memoised.
|
|
117
|
+
*
|
|
118
|
+
* Tracked anyway so an interrupted process still stops them. They are the
|
|
119
|
+
* MOST likely to be stranded: a suite asking for one is usually a suite doing
|
|
120
|
+
* something violent enough to crash, and its `release()` then never runs.
|
|
121
|
+
* Entries are removed on release so this cannot grow into a leak of its own.
|
|
122
|
+
*/
|
|
123
|
+
const privateHandles = /* @__PURE__ */ new Set();
|
|
124
|
+
let leases = 0;
|
|
125
|
+
let live = 0;
|
|
126
|
+
function readHandshake() {
|
|
127
|
+
try {
|
|
128
|
+
return JSON.parse(readFileSync(handshakeFile, "utf8"));
|
|
129
|
+
} catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* A URI published by whoever booted the server, or undefined.
|
|
135
|
+
*
|
|
136
|
+
* Env vars are the primary channel — vitest workers inherit them from the
|
|
137
|
+
* main process. The file is the fallback for pools that respawn workers with
|
|
138
|
+
* a pristine environment, which is not hypothetical: it is the difference
|
|
139
|
+
* between reusing one server and every worker booting its own.
|
|
140
|
+
*/
|
|
141
|
+
function publishedUris(kind) {
|
|
142
|
+
const fromEnv = process.env[uriEnv(kind)];
|
|
143
|
+
if (fromEnv && fromEnv.length > 0) return fromEnv.split(",").filter((u) => u.length > 0);
|
|
144
|
+
return readHandshake()[kind]?.filter((u) => u.length > 0) ?? [];
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Get a running server for `kind`, booting at most one per (kind, slot).
|
|
148
|
+
*
|
|
149
|
+
* `slot` is explicit for the pre-boot path, which must start ALL of them; it
|
|
150
|
+
* is omitted everywhere else, where this process's own slot is the answer.
|
|
151
|
+
*
|
|
152
|
+
* The memo is stored BEFORE the await so two suites racing in the same worker
|
|
153
|
+
* share one boot. Storing it after would let both observe an empty map and
|
|
154
|
+
* start a server each — the exact cost this module exists to remove, and
|
|
155
|
+
* invisible because both would work.
|
|
156
|
+
*/
|
|
157
|
+
async function serverFor(kind, exact = false, slot) {
|
|
158
|
+
const resolved = resolveKind(kind, exact);
|
|
159
|
+
const published = publishedUris(resolved);
|
|
160
|
+
const width = published.length > 0 ? published.length : poolSize;
|
|
161
|
+
const index = slot ?? slotOf(width);
|
|
162
|
+
const key = `${resolved}#${index}`;
|
|
163
|
+
const existing = started.get(key);
|
|
164
|
+
if (existing) return existing;
|
|
165
|
+
const uri = published[index];
|
|
166
|
+
if (uri) {
|
|
167
|
+
const borrowed = {
|
|
168
|
+
uri,
|
|
169
|
+
stop: async () => {}
|
|
170
|
+
};
|
|
171
|
+
started.set(key, borrowed);
|
|
172
|
+
return borrowed;
|
|
173
|
+
}
|
|
174
|
+
const inflight = starting.get(key);
|
|
175
|
+
if (inflight) return inflight;
|
|
176
|
+
const boot = (async () => {
|
|
177
|
+
const handle = await provider.start(resolved);
|
|
178
|
+
started.set(key, handle);
|
|
179
|
+
hookProcessExit();
|
|
180
|
+
return handle;
|
|
181
|
+
})();
|
|
182
|
+
starting.set(key, boot);
|
|
183
|
+
try {
|
|
184
|
+
return await boot;
|
|
185
|
+
} finally {
|
|
186
|
+
starting.delete(key);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** A private server nobody else can reach, stopped with its lease. */
|
|
190
|
+
async function privateServer(kind, exact, createOptions) {
|
|
191
|
+
const handle = await provider.start(resolveKind(kind, exact), { createOptions });
|
|
192
|
+
privateHandles.add(handle);
|
|
193
|
+
hookProcessExit();
|
|
194
|
+
return handle;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Stop this process's servers when it dies WITHOUT a teardown.
|
|
198
|
+
*
|
|
199
|
+
* `globalSetup.teardown()` covers the orderly path and nothing else. Ctrl-C, a
|
|
200
|
+
* CI job cancelling, an IDE stopping a run, a watchdog timeout — none of them
|
|
201
|
+
* reach it, and every one strands the whole pool. Servers are child processes
|
|
202
|
+
* with no parent-death linkage on Windows, so they simply keep running.
|
|
203
|
+
*
|
|
204
|
+
* That is not hypothetical: interrupting runs during this package's own
|
|
205
|
+
* development stranded 71, then 165, then 75 mongods, each holding its
|
|
206
|
+
* WiredTiger cache, on a 32 GB box. Nothing reported it — the next run just
|
|
207
|
+
* started on a machine with less memory than the one before.
|
|
208
|
+
*
|
|
209
|
+
* `exit` handlers must be SYNCHRONOUS, so the async `stop()` is not awaited
|
|
210
|
+
* there; it is a best-effort kill on the way out. The signal handlers get to
|
|
211
|
+
* await properly, which is the path that actually matters for Ctrl-C.
|
|
212
|
+
*/
|
|
213
|
+
let exitHooked = false;
|
|
214
|
+
function hookProcessExit() {
|
|
215
|
+
if (exitHooked) return;
|
|
216
|
+
exitHooked = true;
|
|
217
|
+
const onSignal = (signal) => {
|
|
218
|
+
stopAll().finally(() => {
|
|
219
|
+
process.removeListener(signal, onSignal);
|
|
220
|
+
process.kill(process.pid, signal);
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
for (const signal of [
|
|
224
|
+
"SIGINT",
|
|
225
|
+
"SIGTERM",
|
|
226
|
+
"SIGHUP",
|
|
227
|
+
"SIGBREAK"
|
|
228
|
+
]) process.once(signal, onSignal);
|
|
229
|
+
process.once("exit", () => {
|
|
230
|
+
for (const handle of started.values()) handle.stop();
|
|
231
|
+
for (const handle of privateHandles) handle.stop();
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
async function stopAll() {
|
|
235
|
+
const handles = [...started.values(), ...privateHandles];
|
|
236
|
+
started.clear();
|
|
237
|
+
starting.clear();
|
|
238
|
+
privateHandles.clear();
|
|
239
|
+
await Promise.all(handles.map((h) => h.stop().catch(() => void 0)));
|
|
240
|
+
}
|
|
241
|
+
const factory = async (opts) => {
|
|
242
|
+
const req = opts;
|
|
243
|
+
const kind = req?.replset === true ? "transactional" : "standalone";
|
|
244
|
+
/**
|
|
245
|
+
* `exact` rides on the options bag rather than a separate factory, because
|
|
246
|
+
* arc owns the `database` seam's call site: a suite passes a factory and
|
|
247
|
+
* arc invokes it. A second factory could not be selected from there.
|
|
248
|
+
*/
|
|
249
|
+
const exact = req?.exact === true;
|
|
250
|
+
/**
|
|
251
|
+
* `forcePrivate` — a server nobody else can reach, for THIS lease.
|
|
252
|
+
*
|
|
253
|
+
* Not a performance escape hatch: some suites mutate SERVER-GLOBAL state
|
|
254
|
+
* (fail points, `setParameter`, a forced stepdown) to prove behaviour under
|
|
255
|
+
* failure. On a shared server that corrupts every other worker's run, and
|
|
256
|
+
* the damage surfaces as unrelated suites failing for reasons that make no
|
|
257
|
+
* sense where they land.
|
|
258
|
+
*
|
|
259
|
+
* Distinct from `disabled`, which turns pooling off for the WHOLE pool.
|
|
260
|
+
* This is per-lease, so one hostile suite does not deoptimise the run.
|
|
261
|
+
*/
|
|
262
|
+
const forcePrivate = req?.forcePrivate === true;
|
|
263
|
+
/**
|
|
264
|
+
* Driver-specific server options for a PRIVATE lease — often the whole
|
|
265
|
+
* reason a suite asks for one. A fail-point suite needs its own
|
|
266
|
+
* `setParameter`, and that is exactly the kind of setting that must never
|
|
267
|
+
* reach a shared server. Ignored on the pooled path, deliberately: honouring
|
|
268
|
+
* it there would let one suite silently reconfigure every other worker's.
|
|
269
|
+
*/
|
|
270
|
+
const createOptions = req?.createOptions;
|
|
271
|
+
if (options.disabled || forcePrivate) {
|
|
272
|
+
const server = await privateServer(kind, exact, createOptions);
|
|
273
|
+
const db = await provider.lease(server, mintNamespace());
|
|
274
|
+
return {
|
|
275
|
+
...db,
|
|
276
|
+
teardown: async () => {
|
|
277
|
+
await db.teardown().catch(() => void 0);
|
|
278
|
+
privateHandles.delete(server);
|
|
279
|
+
await server.stop().catch(() => void 0);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const server = await serverFor(kind, exact);
|
|
284
|
+
const db = await provider.lease(server, mintNamespace());
|
|
285
|
+
leases += 1;
|
|
286
|
+
live += 1;
|
|
287
|
+
let released = false;
|
|
288
|
+
return {
|
|
289
|
+
...db,
|
|
290
|
+
/**
|
|
291
|
+
* Idempotent: arc calls teardown on the boot-failure path AND on close,
|
|
292
|
+
* and a double-drop would surface as a confusing driver error on a run
|
|
293
|
+
* that had already gone red for an unrelated reason.
|
|
294
|
+
*/
|
|
295
|
+
teardown: async () => {
|
|
296
|
+
if (released) return;
|
|
297
|
+
released = true;
|
|
298
|
+
live -= 1;
|
|
299
|
+
await db.teardown();
|
|
300
|
+
if (options.stopWhenIdle && live === 0) await stopAll();
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
return {
|
|
305
|
+
factory,
|
|
306
|
+
async leaseNamespace(kind = "standalone", opts) {
|
|
307
|
+
if (!provider.namespaceUri) throw new Error(`[arc-testkit] provider "${provider.name}" does not implement \`namespaceUri\`, so it cannot hand out an unconnected namespace. Use \`factory\` (which connects) instead.`);
|
|
308
|
+
/**
|
|
309
|
+
* `forcePrivate` applies here too, and for the same reason as on
|
|
310
|
+
* `factory`: this is the path a driver SHIM takes, and a suite reaching
|
|
311
|
+
* for the driver's own server class is exactly the kind that flips
|
|
312
|
+
* server-global state.
|
|
313
|
+
*/
|
|
314
|
+
const isPrivate = options.disabled === true || opts?.forcePrivate === true;
|
|
315
|
+
const server = isPrivate ? await privateServer(kind, opts?.exact ?? false, opts?.createOptions) : await serverFor(kind, opts?.exact);
|
|
316
|
+
const namespace = mintNamespace();
|
|
317
|
+
leases += 1;
|
|
318
|
+
live += 1;
|
|
319
|
+
let released = false;
|
|
320
|
+
return {
|
|
321
|
+
uri: provider.namespaceUri(server, namespace),
|
|
322
|
+
namespace,
|
|
323
|
+
release: async () => {
|
|
324
|
+
if (released) return;
|
|
325
|
+
released = true;
|
|
326
|
+
live -= 1;
|
|
327
|
+
if (isPrivate) {
|
|
328
|
+
privateHandles.delete(server);
|
|
329
|
+
await server.stop().catch(() => void 0);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
await provider.dropNamespace?.(server, namespace);
|
|
333
|
+
if (options.stopWhenIdle && live === 0) await stopAll();
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
},
|
|
337
|
+
withPool(boot) {
|
|
338
|
+
return async (modules, options_) => {
|
|
339
|
+
const app = await boot(modules, {
|
|
340
|
+
database: factory,
|
|
341
|
+
...options_ ?? {}
|
|
342
|
+
});
|
|
343
|
+
await provider.afterBoot?.();
|
|
344
|
+
return app;
|
|
345
|
+
};
|
|
346
|
+
},
|
|
347
|
+
globalSetup: {
|
|
348
|
+
/**
|
|
349
|
+
* Clear stale state, then pre-boot and PUBLISH the shared servers.
|
|
350
|
+
*
|
|
351
|
+
* The clear comes first: a handshake file left by a killed run points at
|
|
352
|
+
* a dead server, and the connection error that produces surfaces deep
|
|
353
|
+
* inside a suite as a broken test rather than as stale state.
|
|
354
|
+
*
|
|
355
|
+
* The pre-boot is the load-bearing part. See `preboot` — worker processes
|
|
356
|
+
* cannot share an in-process memo, so a server not started HERE, in the
|
|
357
|
+
* main process, becomes one server PER WORKER.
|
|
358
|
+
*/
|
|
359
|
+
async setup() {
|
|
360
|
+
if (existsSync(handshakeFile)) rmSync(handshakeFile, { force: true });
|
|
361
|
+
if (options.disabled) return;
|
|
362
|
+
const kinds = options.preboot ?? ["transactional"];
|
|
363
|
+
if (kinds.length === 0) return;
|
|
364
|
+
const plan = kinds.flatMap((kind) => Array.from({ length: poolSize }, (_, slot) => ({
|
|
365
|
+
kind,
|
|
366
|
+
slot
|
|
367
|
+
})));
|
|
368
|
+
const handles = await Promise.all(plan.map((p) => serverFor(p.kind, true, p.slot)));
|
|
369
|
+
const published = {};
|
|
370
|
+
plan.forEach((p, i) => {
|
|
371
|
+
const uri = handles[i]?.uri;
|
|
372
|
+
if (!uri) return;
|
|
373
|
+
if (!published[p.kind]) published[p.kind] = [];
|
|
374
|
+
published[p.kind][p.slot] = uri;
|
|
375
|
+
if (p.kind === "transactional" && shareTransactional) {
|
|
376
|
+
if (!published.standalone) published.standalone = [];
|
|
377
|
+
published.standalone[p.slot] = uri;
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
publishPoolServers(published, prefix);
|
|
381
|
+
},
|
|
382
|
+
async teardown() {
|
|
383
|
+
rmSync(handshakeFile, { force: true });
|
|
384
|
+
await stopAll();
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
stats: () => ({
|
|
388
|
+
started: [...new Set([...started.keys()].map((k) => k.split("#")[0]))],
|
|
389
|
+
servers: started.size,
|
|
390
|
+
leases,
|
|
391
|
+
live
|
|
392
|
+
})
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Publish a started server so other worker PROCESSES reuse it.
|
|
397
|
+
*
|
|
398
|
+
* Only a process that owns servers calls this — typically an eager
|
|
399
|
+
* `globalSetup` in a run that prefers boot-cost-up-front over laziness.
|
|
400
|
+
*/
|
|
401
|
+
function publishPoolServers(servers, envPrefix = "ARC_TESTKIT") {
|
|
402
|
+
const file = join(tmpdir(), `${envPrefix.toLowerCase()}-pool.json`);
|
|
403
|
+
for (const [kind, uris] of Object.entries(servers)) {
|
|
404
|
+
const list = (uris ?? []).filter(Boolean);
|
|
405
|
+
if (list.length > 0) process.env[`${envPrefix}_POOL_${kind.toUpperCase()}_URI`] = list.join(",");
|
|
406
|
+
}
|
|
407
|
+
writeFileSync(file, JSON.stringify(servers));
|
|
408
|
+
}
|
|
409
|
+
//#endregion
|
|
410
|
+
export { createTestDatabasePool, publishPoolServers };
|
package/package.json
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
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": {
|
|
7
7
|
".": {
|
|
8
8
|
"types": "./dist/index.d.mts",
|
|
9
9
|
"default": "./dist/index.mjs"
|
|
10
|
+
},
|
|
11
|
+
"./pool": {
|
|
12
|
+
"types": "./dist/pool.d.mts",
|
|
13
|
+
"default": "./dist/pool.mjs"
|
|
14
|
+
},
|
|
15
|
+
"./mongo": {
|
|
16
|
+
"types": "./dist/mongo.d.mts",
|
|
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"
|
|
10
26
|
}
|
|
11
27
|
},
|
|
12
28
|
"main": "./dist/index.mjs",
|
|
@@ -33,7 +49,7 @@
|
|
|
33
49
|
"mongodb-memory-server": ">=10.0.0"
|
|
34
50
|
},
|
|
35
51
|
"peerDependencies": {
|
|
36
|
-
"@classytic/arc": ">=2.
|
|
52
|
+
"@classytic/arc": ">=2.43.0",
|
|
37
53
|
"mongoose": ">=9.4.1"
|
|
38
54
|
},
|
|
39
55
|
"keywords": [
|