@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 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 };
@@ -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 };
@@ -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.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.33.0",
52
+ "@classytic/arc": ">=2.43.0",
37
53
  "mongoose": ">=9.4.1"
38
54
  },
39
55
  "keywords": [