slicetest 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +188 -9
  2. package/dist/ci.d.ts +29 -0
  3. package/dist/ci.js +57 -0
  4. package/dist/cli.js +52 -3
  5. package/dist/config.d.ts +61 -8
  6. package/dist/config.js +46 -2
  7. package/dist/containers.d.ts +22 -0
  8. package/dist/containers.js +59 -0
  9. package/dist/doctor.d.ts +22 -0
  10. package/dist/doctor.js +190 -0
  11. package/dist/drivers/driver.d.ts +2 -0
  12. package/dist/drivers/index.d.ts +2 -2
  13. package/dist/drivers/index.js +13 -2
  14. package/dist/drivers/mysql.d.ts +29 -0
  15. package/dist/drivers/mysql.js +270 -0
  16. package/dist/drivers/sqlite.d.ts +23 -0
  17. package/dist/drivers/sqlite.js +203 -0
  18. package/dist/gen.d.ts +24 -0
  19. package/dist/gen.js +116 -0
  20. package/dist/global-setup.js +53 -5
  21. package/dist/http.d.ts +6 -0
  22. package/dist/http.js +14 -0
  23. package/dist/index.d.ts +5 -1
  24. package/dist/index.js +1 -0
  25. package/dist/init.d.ts +2 -0
  26. package/dist/init.js +180 -3
  27. package/dist/mail.d.ts +58 -0
  28. package/dist/mail.js +299 -0
  29. package/dist/matchers.d.ts +4 -0
  30. package/dist/matchers.js +33 -0
  31. package/dist/openapi.d.ts +19 -0
  32. package/dist/openapi.js +43 -1
  33. package/dist/provided.d.ts +2 -0
  34. package/dist/record-cli.d.ts +6 -0
  35. package/dist/record-cli.js +93 -0
  36. package/dist/record-session.d.ts +1 -0
  37. package/dist/record-session.js +10 -0
  38. package/dist/record.d.ts +46 -0
  39. package/dist/record.js +201 -0
  40. package/dist/recording.d.ts +43 -0
  41. package/dist/recording.js +148 -0
  42. package/dist/runtime.d.ts +17 -0
  43. package/dist/runtime.js +71 -6
  44. package/dist/stub.d.ts +1 -1
  45. package/dist/stub.js +14 -3
  46. package/dist/trace.d.ts +43 -0
  47. package/dist/trace.js +59 -0
  48. package/dist/vitest.js +5 -2
  49. package/dist/yaml-runtime.d.ts +1 -0
  50. package/dist/yaml-runtime.js +58 -13
  51. package/dist/yaml.d.ts +30 -1
  52. package/dist/yaml.js +41 -5
  53. package/package.json +18 -4
  54. package/schema/scenario.schema.json +173 -1
package/dist/config.js CHANGED
@@ -4,6 +4,8 @@ export function resolveOptions(opts, root) {
4
4
  root,
5
5
  app: { ...opts.app, ready: resolveReady(opts.app.ready ?? { path: "/" }) },
6
6
  services: Object.fromEntries(Object.entries(opts.services ?? {}).map(([name, s]) => [name, { ...s, ready: s.ready && resolveReady(s.ready) }])),
7
+ containers: opts.containers ?? {},
8
+ mail: opts.mail ?? false,
7
9
  db: resolveDb(opts.db ?? {}),
8
10
  stubs: (opts.stubs ?? []).map(stubName),
9
11
  openapi: {
@@ -12,21 +14,39 @@ export function resolveOptions(opts, root) {
12
14
  stubs: Object.fromEntries((opts.stubs ?? []).flatMap((s) => (typeof s === "object" && s.openapi ? [[s.name, s.openapi]] : []))),
13
15
  autoReply: (opts.stubs ?? []).flatMap((s) => (typeof s === "object" && s.autoReply ? [s.name] : [])),
14
16
  },
17
+ recordings: resolveRecordings(opts.stubs ?? []),
15
18
  http: opts.http,
16
19
  };
17
20
  }
21
+ function resolveRecordings(stubs) {
22
+ const withUpstream = stubs.filter((s) => typeof s === "object" && !!s.upstream);
23
+ const env = (process.env.SLICETEST_RECORD ?? "").trim();
24
+ const all = ["1", "true", "all", "*"].includes(env.toLowerCase());
25
+ const names = all || !env ? [] : env.split(",").map((n) => n.trim()).filter(Boolean);
26
+ for (const n of names) {
27
+ if (!withUpstream.some((s) => s.name === n)) {
28
+ throw new Error(`slicetest: SLICETEST_RECORD names "${n}", but no stub of that name has an upstream. Stubs with one: ${withUpstream.map((s) => s.name).join(", ") || "(none)"}`);
29
+ }
30
+ }
31
+ return Object.fromEntries(withUpstream.map((s) => [s.name, { file: s.recordings ?? `recordings/${s.name}.yaml`, upstream: s.upstream, record: all || names.includes(s.name) }]));
32
+ }
18
33
  function resolveReady(ready) {
19
34
  if (!("log" in ready))
20
35
  return ready;
21
36
  return typeof ready.log === "string" ? { log: escapeRegExp(ready.log), flags: "" } : { log: ready.log.source, flags: ready.log.flags };
22
37
  }
23
38
  function resolveDb(db) {
24
- const url = db.url ?? (process.env.SLICETEST_DATABASE_URL || undefined);
39
+ const isMysql = (u) => /^mysql:/i.test(u);
40
+ const env = process.env.SLICETEST_DATABASE_URL || undefined;
41
+ const engine = db.engine ?? (isMysql(db.url ?? env ?? "") ? "mysql" : "postgres");
42
+ // The environment variable names one server for the whole CI job; a project on the other engine starts its own.
43
+ const url = engine === "sqlite" ? undefined : (db.url ?? (env && isMysql(env) === (engine === "mysql") ? env : undefined));
25
44
  return {
26
- image: "postgres:17-alpine",
45
+ image: engine === "mysql" ? "mysql:8.4" : engine === "sqlite" ? "" : "postgres:17-alpine",
27
46
  schemas: ["public"],
28
47
  keep: [],
29
48
  ...db,
49
+ engine,
30
50
  url,
31
51
  reuse: db.reuse ?? (!url && !process.env.CI),
32
52
  };
@@ -52,6 +72,26 @@ function validate(opts) {
52
72
  fail(`services.${name}.command is required`);
53
73
  checkReady(s.ready, `services.${name}`);
54
74
  }
75
+ for (const [name, c] of Object.entries(opts.containers ?? {})) {
76
+ if (!/^[\w-]+$/.test(name))
77
+ fail(`container name "${name}" may only contain letters, digits, "_" and "-"`);
78
+ if (!c || typeof c.image !== "string" || !c.image)
79
+ fail(`containers.${name}.image is required, e.g. "redis:7-alpine"`);
80
+ if (!Number.isInteger(c.port) || c.port <= 0)
81
+ fail(`containers.${name}.port must be the port the service listens on inside the container, e.g. 6379`);
82
+ for (const key of ["command", "reset"]) {
83
+ const v = c[key];
84
+ if (v !== undefined && !(Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === "string")))
85
+ fail(`containers.${name}.${key} must be a list of strings, e.g. ["redis-cli", "FLUSHALL"]`);
86
+ }
87
+ }
88
+ if (opts.mail !== undefined && typeof opts.mail !== "boolean")
89
+ fail(`mail must be true or false, got ${JSON.stringify(opts.mail)}`);
90
+ const engine = opts.db?.engine;
91
+ if (engine !== undefined && engine !== "postgres" && engine !== "mysql" && engine !== "sqlite")
92
+ fail(`db.engine must be "postgres", "mysql" or "sqlite", got ${JSON.stringify(engine)}`);
93
+ if (engine === "sqlite" && opts.db?.url)
94
+ fail("db.url doesn't apply to sqlite: slicetest creates the database files itself and passes them to the app as {{db.url}} / {{db.path}}");
55
95
  const migrate = opts.db?.migrate;
56
96
  if (migrate) {
57
97
  const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
@@ -72,6 +112,10 @@ function validate(opts) {
72
112
  fail(`each stub must be a name or { name, openapi }, got ${JSON.stringify(s)}`);
73
113
  if (typeof s === "object" && s.autoReply && !s.openapi)
74
114
  fail(`stub "${s.name}": autoReply needs an openapi spec to answer from`);
115
+ if (typeof s === "object" && s.upstream !== undefined && !/^https?:\/\/[^/]/.test(s.upstream))
116
+ fail(`stub "${s.name}": upstream must be an http(s) URL, got ${JSON.stringify(s.upstream)}`);
117
+ if (typeof s === "object" && s.recordings !== undefined && !s.upstream)
118
+ fail(`stub "${s.name}": recordings needs an upstream to record from`);
75
119
  }
76
120
  const stubs = (opts.stubs ?? []).map(stubName);
77
121
  for (const name of stubs) {
@@ -0,0 +1,22 @@
1
+ import type { ContainerOptions } from "./config.js";
2
+ /**
3
+ * A container the app depends on (Redis, a search engine, an S3 emulator),
4
+ * started for one test file and emptied between scenarios with `reset`.
5
+ */
6
+ export declare class Dependency {
7
+ readonly name: string;
8
+ private readonly container;
9
+ private readonly opts;
10
+ private constructor();
11
+ static start(name: string, opts: ContainerOptions): Promise<Dependency>;
12
+ get host(): string;
13
+ /** The host port mapped to the container's `port`. */
14
+ get port(): number;
15
+ /** `host:port`, to put after a scheme: `redis://{{container.cache}}`. */
16
+ get address(): string;
17
+ /** Run a command inside the container; throws with its output unless it exits with 0. */
18
+ exec(command: string[]): Promise<string>;
19
+ /** Start of a scenario: run the `reset` command, if any. */
20
+ reset(): Promise<void>;
21
+ stop(): Promise<void>;
22
+ }
@@ -0,0 +1,59 @@
1
+ import { configureContainerRuntime } from "./container-runtime.js";
2
+ /**
3
+ * A container the app depends on (Redis, a search engine, an S3 emulator),
4
+ * started for one test file and emptied between scenarios with `reset`.
5
+ */
6
+ export class Dependency {
7
+ name;
8
+ container;
9
+ opts;
10
+ constructor(name, container, opts) {
11
+ this.name = name;
12
+ this.container = container;
13
+ this.opts = opts;
14
+ }
15
+ static async start(name, opts) {
16
+ configureContainerRuntime();
17
+ const { GenericContainer, Wait } = await import("testcontainers");
18
+ let definition = new GenericContainer(opts.image).withExposedPorts(opts.port);
19
+ if (opts.env)
20
+ definition = definition.withEnvironment(opts.env);
21
+ if (opts.command)
22
+ definition = definition.withCommand(opts.command);
23
+ if (opts.ready)
24
+ definition = definition.withWaitStrategy(Wait.forLogMessage(opts.ready.log));
25
+ try {
26
+ return new Dependency(name, await definition.start(), opts);
27
+ }
28
+ catch (e) {
29
+ throw new Error(`slicetest: container "${name}" (${opts.image}) didn't start: ${e.message}`);
30
+ }
31
+ }
32
+ get host() {
33
+ return this.container.getHost();
34
+ }
35
+ /** The host port mapped to the container's `port`. */
36
+ get port() {
37
+ return this.container.getMappedPort(this.opts.port);
38
+ }
39
+ /** `host:port`, to put after a scheme: `redis://{{container.cache}}`. */
40
+ get address() {
41
+ return `${this.host}:${this.port}`;
42
+ }
43
+ /** Run a command inside the container; throws with its output unless it exits with 0. */
44
+ async exec(command) {
45
+ const res = await this.container.exec(command);
46
+ if (res.exitCode !== 0) {
47
+ throw new Error(`slicetest: \`${command.join(" ")}\` in container "${this.name}" exited with ${res.exitCode}:\n${res.output.trim()}`);
48
+ }
49
+ return res.stdout;
50
+ }
51
+ /** Start of a scenario: run the `reset` command, if any. */
52
+ async reset() {
53
+ if (this.opts.reset)
54
+ await this.exec(this.opts.reset);
55
+ }
56
+ async stop() {
57
+ await this.container.stop();
58
+ }
59
+ }
@@ -0,0 +1,22 @@
1
+ import { type ResolvedOptions } from "./config.js";
2
+ /**
3
+ * `slicetest doctor`: everything a run needs, checked up front, each problem
4
+ * with what to do about it. Meant for a first setup and for CI logs, where a
5
+ * container runtime that isn't there otherwise shows up as a timeout.
6
+ */
7
+ export interface Check {
8
+ status: "ok" | "warn" | "fail";
9
+ label: string;
10
+ /** What was found, or what to do. */
11
+ detail?: string;
12
+ }
13
+ /** Things that touch the machine, replaceable in tests. */
14
+ export interface Probes {
15
+ containerRuntime(): Promise<string>;
16
+ database(opts: ResolvedOptions, url: string): Promise<void>;
17
+ command(name: string, args: string[]): Promise<string>;
18
+ resolvePackage(name: string, from: string): boolean;
19
+ }
20
+ export declare const machine: Probes;
21
+ export declare function doctor(configPath: string | undefined, probes?: Probes, env?: NodeJS.ProcessEnv): Promise<Check[]>;
22
+ export declare function formatChecks(checks: Check[]): string;
package/dist/doctor.js ADDED
@@ -0,0 +1,190 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { createRequire } from "node:module";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { parse } from "yaml";
8
+ import { resolveOptions } from "./config.js";
9
+ import { configureContainerRuntime } from "./container-runtime.js";
10
+ import { engineFor } from "./drivers/index.js";
11
+ import { OpenApiSpec } from "./openapi.js";
12
+ const exec = promisify(execFile);
13
+ export const machine = {
14
+ async containerRuntime() {
15
+ configureContainerRuntime();
16
+ const { getContainerRuntimeClient } = await import("testcontainers");
17
+ const client = await withTimeout(getContainerRuntimeClient(), 15_000, "no answer from the container runtime");
18
+ const { containerRuntime: rt } = client.info;
19
+ return `${rt.operatingSystem} ${rt.serverVersion} at ${rt.host}`;
20
+ },
21
+ async database(opts, url) {
22
+ const admin = await withTimeout((await engineFor(opts)).admin(url), 10_000, "connection timed out");
23
+ await admin.close();
24
+ },
25
+ async command(name, args) {
26
+ const { stdout, stderr } = await exec(name, args, { windowsHide: true, timeout: 10_000, shell: process.platform === "win32" });
27
+ return (stdout || stderr).trim().split(/\r?\n/)[0];
28
+ },
29
+ resolvePackage(name, from) {
30
+ try {
31
+ createRequire(path.join(from, "noop.js")).resolve(`${name}/package.json`);
32
+ return true;
33
+ }
34
+ catch {
35
+ try {
36
+ createRequire(import.meta.url).resolve(name);
37
+ return true;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ },
44
+ };
45
+ export async function doctor(configPath, probes = machine, env = process.env) {
46
+ const checks = [];
47
+ const add = (status, label, detail) => checks.push({ status, label, ...(detail ? { detail } : {}) });
48
+ const major = Number(process.versions.node.split(".")[0]);
49
+ add(major >= 20 ? "ok" : "fail", `Node.js ${process.versions.node}`, major >= 20 ? undefined : "slicetest needs Node.js 20 or later");
50
+ if (!configPath || !existsSync(configPath)) {
51
+ add("warn", "no slicetest.config.yaml", "checked the machine only. `npx slicetest init` writes a config; with the Vitest plugin, pass the same options to --config as YAML to check them");
52
+ await checkContainerRuntime(add, probes);
53
+ return checks;
54
+ }
55
+ const root = path.dirname(configPath);
56
+ const rel = (p) => path.relative(process.cwd(), path.resolve(root, p)) || ".";
57
+ let opts;
58
+ try {
59
+ const raw = (parse(await readFile(configPath, "utf8")) ?? {});
60
+ delete raw.include;
61
+ opts = resolveOptions(raw, root);
62
+ add("ok", `config ${rel(configPath)}`);
63
+ }
64
+ catch (e) {
65
+ add("fail", `config ${rel(configPath)}`, e.message.replace(/^slicetest: /, ""));
66
+ return checks;
67
+ }
68
+ // Database: a server that's there already, or a container runtime to start one in.
69
+ const url = opts.db.engine === "sqlite" ? undefined : (opts.db.url ?? env.SLICETEST_DATABASE_URL);
70
+ if (opts.db.engine === "sqlite") {
71
+ const [maj = 0, min = 0] = process.versions.node.split(".").map(Number);
72
+ if (maj > 22 || (maj === 22 && min >= 5))
73
+ add("ok", "sqlite (node:sqlite, no server needed)");
74
+ else
75
+ add("fail", "sqlite needs Node.js 22.5 or later", `this is ${process.versions.node}; slicetest uses the built-in node:sqlite`);
76
+ if (Object.keys(opts.containers).length)
77
+ await checkContainerRuntime(add, probes, `runs ${Object.values(opts.containers).map((c) => c.image).join(", ")}`);
78
+ }
79
+ else if (url) {
80
+ try {
81
+ await probes.database(opts, url);
82
+ add("ok", `${opts.db.engine} at ${redact(url)}`);
83
+ }
84
+ catch (e) {
85
+ add("fail", `${opts.db.engine} at ${redact(url)}`, `can't connect: ${e.message}. It must be a superuser (root) connection that can create databases`);
86
+ }
87
+ }
88
+ else {
89
+ await checkContainerRuntime(add, probes, `runs ${[opts.db.image, ...Object.values(opts.containers).map((c) => c.image)].join(", ")}`);
90
+ }
91
+ if (opts.db.engine === "mysql") {
92
+ for (const pkg of ["mysql2", ...(url ? [] : ["@testcontainers/mysql"])]) {
93
+ if (probes.resolvePackage(pkg, root))
94
+ add("ok", `${pkg} installed`);
95
+ else
96
+ add("fail", `${pkg} not installed`, `db.engine "mysql" needs it: npm i -D ${pkg}`);
97
+ }
98
+ }
99
+ const m = opts.db.migrate;
100
+ if (!m)
101
+ add("warn", "db.migrate not set", "scenarios run against an empty database unless the app creates its own tables");
102
+ else if ("atlas" in m) {
103
+ await checkPath(add, "migrations", m.atlas.dir.replace(/^file:\/\//, ""), root, rel);
104
+ try {
105
+ add("ok", `atlas CLI (${await probes.command("atlas", ["version"])})`);
106
+ }
107
+ catch {
108
+ add("fail", "atlas CLI not found", "db.migrate.atlas runs `atlas migrate apply`. Install it: https://atlasgo.io/getting-started");
109
+ }
110
+ }
111
+ else if ("sql" in m)
112
+ await checkPath(add, "migrations", m.sql, root, rel);
113
+ else
114
+ for (const input of m.inputs ?? [])
115
+ await checkPath(add, "migration input", input, root, rel);
116
+ if (opts.db.seed)
117
+ await checkPath(add, "seed", opts.db.seed, root, rel);
118
+ for (const [label, p] of [["app", opts.app], ...Object.entries(opts.services).map(([n, s]) => [`service ${n}`, s])]) {
119
+ if (p.cwd)
120
+ await checkPath(add, `${label} cwd`, p.cwd, root, rel);
121
+ const program = firstWord(p.command);
122
+ if (program && !/^[.\\/]|\{\{|\$/.test(program)) {
123
+ if (await onPath(program, env))
124
+ add("ok", `${label}: ${program} found`);
125
+ else
126
+ add("warn", `${label}: ${program} not found on PATH`, `\`${p.command}\` may fail to start. Fine if a shell alias or a relative script provides it`);
127
+ }
128
+ }
129
+ const specs = [...(opts.openapi.app ? [["app OpenAPI", opts.openapi.app]] : []), ...Object.entries(opts.openapi.stubs).map(([n, f]) => [`stub ${n} OpenAPI`, f])];
130
+ for (const [label, file] of specs) {
131
+ try {
132
+ const spec = await OpenApiSpec.load(path.resolve(root, file), file);
133
+ add("ok", `${label} ${rel(file)}`, `${spec.operations().length} operation(s)`);
134
+ }
135
+ catch (e) {
136
+ add("fail", `${label} ${rel(file)}`, e.message.replace(/^slicetest: /, ""));
137
+ }
138
+ }
139
+ for (const [name, r] of Object.entries(opts.recordings)) {
140
+ if (existsSync(r.file))
141
+ add("ok", `stub ${name}: recordings ${rel(r.file)}`);
142
+ else
143
+ add("warn", `stub ${name}: no recordings yet (${rel(r.file)})`, `run once with SLICETEST_RECORD=${name} to record ${r.upstream}`);
144
+ }
145
+ return checks;
146
+ }
147
+ async function checkContainerRuntime(add, probes, forWhat) {
148
+ try {
149
+ add("ok", `container runtime: ${await probes.containerRuntime()}`, forWhat);
150
+ }
151
+ catch (e) {
152
+ add("fail", "no container runtime", `${e.message.split("\n")[0]}. Start Docker or a Podman machine (\`podman machine start\`), or set SLICETEST_DATABASE_URL / db.url to an existing database server`);
153
+ }
154
+ }
155
+ async function checkPath(add, label, p, root, rel) {
156
+ try {
157
+ await stat(path.resolve(root, p));
158
+ add("ok", `${label} ${rel(p)}`);
159
+ }
160
+ catch {
161
+ add("fail", `${label} ${rel(p)} not found`, `paths in the config are relative to the config file's directory (${rel(".")})`);
162
+ }
163
+ }
164
+ function firstWord(command) {
165
+ return /^\s*(?:"([^"]+)"|(\S+))/.exec(command)?.slice(1).find(Boolean);
166
+ }
167
+ async function onPath(program, env) {
168
+ const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
169
+ for (const dir of (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean)) {
170
+ for (const ext of exts) {
171
+ if (existsSync(path.join(dir, program + ext)) || existsSync(path.join(dir, program)))
172
+ return true;
173
+ }
174
+ }
175
+ return false;
176
+ }
177
+ function redact(url) {
178
+ return url.replace(/\/\/([^:/@]+):[^@]*@/, "//$1:***@");
179
+ }
180
+ function withTimeout(p, ms, message) {
181
+ return Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms).unref())]);
182
+ }
183
+ export function formatChecks(checks) {
184
+ const icon = { ok: "✓", warn: "!", fail: "✗" };
185
+ const lines = checks.map((c) => ` ${icon[c.status]} ${c.label}${c.detail ? `\n ${c.detail}` : ""}`);
186
+ const fails = checks.filter((c) => c.status === "fail").length;
187
+ const warns = checks.filter((c) => c.status === "warn").length;
188
+ const summary = fails ? `${fails} problem(s) to fix before running.` : warns ? `Ready to run, with ${warns} warning(s).` : "Ready to run.";
189
+ return `${lines.join("\n")}\n\n${summary}\n`;
190
+ }
@@ -54,6 +54,8 @@ export interface Engine {
54
54
  name: string;
55
55
  /** Container image used when no `db.url` is given. */
56
56
  defaultImage: string;
57
+ /** Runs in-process (SQLite): no container runtime needed. */
58
+ local?: boolean;
57
59
  startContainer(image: string, reuse: boolean): Promise<{
58
60
  url: string;
59
61
  stop(): Promise<unknown>;
@@ -1,5 +1,5 @@
1
1
  import type { ResolvedOptions } from "../config.js";
2
2
  import type { Engine } from "./driver.js";
3
3
  export type { Admin, Driver, Engine, Row, Table } from "./driver.js";
4
- /** The database engine a run uses. */
5
- export declare function engineFor(_opts: Pick<ResolvedOptions, "db">): Engine;
4
+ /** The database engine a run uses. MySQL's driver is an optional dependency, loaded only when asked for. */
5
+ export declare function engineFor(opts: Pick<ResolvedOptions, "db">): Promise<Engine>;
@@ -1,5 +1,16 @@
1
1
  import { postgres } from "./postgres.js";
2
- /** The database engine a run uses. */
3
- export function engineFor(_opts) {
2
+ /** The database engine a run uses. MySQL's driver is an optional dependency, loaded only when asked for. */
3
+ export async function engineFor(opts) {
4
+ if (opts.db.engine === "mysql") {
5
+ const mod = await import("./mysql.js").catch((e) => {
6
+ if (e.code === "ERR_MODULE_NOT_FOUND") {
7
+ throw new Error('slicetest: db.engine "mysql" needs the mysql2 package: npm i -D mysql2');
8
+ }
9
+ throw e;
10
+ });
11
+ return mod.mysqlEngine;
12
+ }
13
+ if (opts.db.engine === "sqlite")
14
+ return (await import("./sqlite.js")).sqliteEngine;
4
15
  return postgres;
5
16
  }
@@ -0,0 +1,29 @@
1
+ import type { Driver, Engine, Row, Table } from "./driver.js";
2
+ export declare class MysqlDriver implements Driver {
3
+ #private;
4
+ private readonly conn;
5
+ private constructor();
6
+ static connect(url: string): Promise<MysqlDriver>;
7
+ query<T extends Row = Row>(sql: string, params?: unknown[]): Promise<T[]>;
8
+ queryAll(sqls: string[]): Promise<Row[][]>;
9
+ exec(script: string): Promise<void>;
10
+ ident(name: string): string;
11
+ column(name: string): string;
12
+ param(_n: number): string;
13
+ inList(column: string, values: unknown[], params: unknown[]): string;
14
+ /**
15
+ * Base tables in the configured schemas. In MySQL a schema is a database;
16
+ * `public` (the default) stands for the database the URL points at.
17
+ */
18
+ listTables(schemas: string[], keep: string[]): Promise<Table[]>;
19
+ /**
20
+ * TRUNCATE also restarts AUTO_INCREMENT, but it is DDL and costs a few
21
+ * milliseconds per table, so only tables that have rows or a used counter are
22
+ * truncated. Foreign key checks are off for this session only.
23
+ */
24
+ truncate(tables: Table[]): Promise<void>;
25
+ /** MySQL has no RETURNING: insert, then read the row back by its primary key. */
26
+ insert(table: string, row: Row): Promise<Row[]>;
27
+ close(): Promise<void>;
28
+ }
29
+ export declare const mysqlEngine: Engine;