slicetest 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/db.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import pg from "pg";
3
2
  /** Tables that record applied migrations. Truncating them would make tools re-run migrations. */
4
3
  const MIGRATION_TABLES = [
5
4
  "atlas_schema_revisions",
@@ -20,52 +19,34 @@ const MIGRATION_TABLES = [
20
19
  "ar_internal_metadata",
21
20
  "__EFMigrationsHistory",
22
21
  ];
23
- export function withDatabase(url, database) {
24
- const u = new URL(url);
25
- u.pathname = `/${database}`;
26
- return u.toString();
27
- }
28
- const INT8 = 20;
29
- /** int8 columns (bigserial ids, count(*)) come back as numbers when they fit, instead of strings. */
30
- const types = {
31
- getTypeParser(oid, format) {
32
- if (oid === INT8 && format !== "binary") {
33
- return (v) => {
34
- const n = Number(v);
35
- return Number.isSafeInteger(n) ? n : v;
36
- };
37
- }
38
- return pg.types.getTypeParser(oid, format);
39
- },
40
- };
41
22
  /** Test-side handle to the database the app under test is using. */
42
23
  export class Db {
43
24
  url;
44
25
  opts;
45
- #client;
26
+ #driver;
46
27
  #tables;
47
28
  #seed;
48
- constructor(client, url, opts) {
29
+ /** Contents right after the reset (and seed); undefined means every table was empty. */
30
+ #start;
31
+ #checkpoint;
32
+ constructor(driver, url, opts) {
49
33
  this.url = url;
50
34
  this.opts = opts;
51
- this.#client = client;
52
- }
53
- static async connect(url, opts) {
54
- const client = new pg.Client({ connectionString: url, types });
55
- // Without a listener, a dropped connection would crash the worker; the next query reports it instead.
56
- client.on("error", () => { });
57
- await client.connect();
58
- const db = new Db(client, url, opts);
35
+ this.#driver = driver;
36
+ }
37
+ static async connect(driver, url, opts) {
38
+ const db = new Db(driver, url, opts);
59
39
  if (opts.seedFile)
60
40
  db.#seed = await readFile(opts.seedFile, "utf8");
61
41
  return db;
62
42
  }
63
43
  async query(sql, params = []) {
64
- return (await this.#client.query(sql, params)).rows;
44
+ return this.#driver.query(sql, params);
65
45
  }
66
46
  /** Rows of a table, optionally filtered, ordered by the first column unless `orderBy` is given. */
67
47
  async rows(table, where = {}, opts = {}) {
68
- const { clause, params } = whereClause(where);
48
+ const { clause, params } = this.#where(where);
49
+ const ident = (c) => this.#driver.ident(c);
69
50
  const order = [opts.orderBy ?? []]
70
51
  .flat()
71
52
  .map((c) => (c.startsWith("-") ? `${ident(c.slice(1))} DESC` : ident(c)))
@@ -83,37 +64,63 @@ export class Db {
83
64
  }
84
65
  /** Tagged-template query: values become bind parameters. */
85
66
  sql(strings, ...values) {
86
- const text = strings.reduce((acc, s, i) => acc + `$${i}` + s);
67
+ const text = strings.reduce((acc, s, i) => acc + this.#driver.param(i) + s);
87
68
  return this.query(text, values);
88
69
  }
89
70
  async count(table, where = {}) {
90
- const { clause, params } = whereClause(where);
91
- const [row] = await this.query(`SELECT count(*) AS n FROM ${ident(table)}${clause}`, params);
71
+ const { clause, params } = this.#where(where);
72
+ const [row] = await this.query(`SELECT count(*) AS n FROM ${this.#driver.ident(table)}${clause}`, params);
92
73
  return Number(row.n);
93
74
  }
94
75
  /** Insert rows and return them as stored (with defaults and generated ids). */
95
76
  async insert(table, rows) {
96
77
  const list = Array.isArray(rows) ? rows : [rows];
97
78
  const out = [];
98
- for (const row of list) {
99
- const cols = Object.keys(row);
100
- const sql = cols.length === 0
101
- ? `INSERT INTO ${ident(table)} DEFAULT VALUES RETURNING *`
102
- : `INSERT INTO ${ident(table)} (${cols.map(ident).join(", ")}) VALUES (${cols
103
- .map((_, i) => `$${i + 1}`)
104
- .join(", ")}) RETURNING *`;
105
- out.push(...(await this.query(sql, Object.values(row))));
106
- }
79
+ for (const row of list)
80
+ out.push(...(await this.#driver.insert(table, row)));
107
81
  return out;
108
82
  }
109
83
  /** Empty every data table without dropping the app's connections, then re-apply the seed. */
110
84
  async reset() {
111
85
  this.#tables ??= await this.#listTables();
112
- if (this.#tables.length > 0) {
113
- await this.#client.query(`TRUNCATE ${this.#tables.join(", ")} RESTART IDENTITY CASCADE`);
86
+ await this.#driver.truncate(this.#tables);
87
+ this.#start = undefined;
88
+ this.#checkpoint = undefined;
89
+ if (this.#seed) {
90
+ await this.#driver.exec(this.#seed);
91
+ this.#start = await this.#snapshot();
114
92
  }
115
- if (this.#seed)
116
- await this.#client.query(this.#seed);
93
+ }
94
+ /**
95
+ * What changed in the database since the scenario started (after the seed),
96
+ * or since the last `checkpoint()`: inserted, updated and deleted rows per table.
97
+ *
98
+ * ```ts
99
+ * await db.checkpoint(); // ignore the rows the test arranged
100
+ * await http.post("/polls", { ... });
101
+ * expect(await db.changes()).toEqual({ polls: { inserted: [expect.objectContaining({ title: "x" })], updated: [], deleted: [] } });
102
+ * ```
103
+ */
104
+ async changes() {
105
+ const base = this.#checkpoint === undefined || this.#checkpoint === "start" ? this.#start : this.#checkpoint;
106
+ return diff(this.#tables ?? [], base, await this.#snapshot());
107
+ }
108
+ /** Make `changes()` report only what happens from now on. */
109
+ async checkpoint() {
110
+ this.#checkpoint = await this.#snapshot();
111
+ }
112
+ /** Changes since the scenario started, regardless of checkpoints. Used for failure output. */
113
+ async changesSinceStart() {
114
+ return diff(this.#tables ?? [], this.#start, await this.#snapshot());
115
+ }
116
+ /** Every tracked table's rows, in one round trip. */
117
+ async #snapshot() {
118
+ const tables = (this.#tables ??= await this.#listTables());
119
+ const snap = new Map();
120
+ const order = (t) => (t.key.length ? ` ORDER BY ${t.key.map((c) => this.#driver.column(c)).join(", ")}` : "");
121
+ const results = await this.#driver.queryAll(tables.map((t) => `SELECT * FROM ${t.quoted}${order(t)}`));
122
+ tables.forEach((t, i) => snap.set(t.name, results[i]));
123
+ return snap;
117
124
  }
118
125
  /**
119
126
  * Tables to empty: ordinary tables in the configured schemas, minus migration
@@ -121,46 +128,107 @@ export class Db {
121
128
  * such as PostGIS's `spatial_ref_sys`.
122
129
  */
123
130
  async #listTables() {
124
- const keep = [...MIGRATION_TABLES, ...this.opts.keep];
125
- const rows = await this.query(`SELECT n.nspname AS schema, c.relname AS name
126
- FROM pg_class c
127
- JOIN pg_namespace n ON n.oid = c.relnamespace
128
- WHERE c.relkind IN ('r', 'p')
129
- AND NOT c.relispartition
130
- AND n.nspname = ANY($1)
131
- AND NOT (c.relname = ANY($2) OR n.nspname || '.' || c.relname = ANY($2))
132
- AND NOT EXISTS (
133
- SELECT 1 FROM pg_depend d
134
- WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.deptype = 'e')
135
- ORDER BY 1, 2`, [this.opts.schemas, keep]);
136
- return rows.map((r) => `${ident(r.schema)}.${ident(r.name)}`);
131
+ return this.#driver.listTables(this.opts.schemas, [...MIGRATION_TABLES, ...this.opts.keep]);
132
+ }
133
+ /** `WHERE ...` for a filter: `null` is IS NULL, an array is IN (...), anything else `=`. */
134
+ #where(where) {
135
+ const d = this.#driver;
136
+ const conds = [];
137
+ const params = [];
138
+ for (const [col, value] of Object.entries(where)) {
139
+ if (value === null || value === undefined)
140
+ conds.push(`${d.ident(col)} IS NULL`);
141
+ else if (Array.isArray(value))
142
+ conds.push(d.inList(col, value, params));
143
+ else {
144
+ params.push(value);
145
+ conds.push(`${d.ident(col)} = ${d.param(params.length)}`);
146
+ }
147
+ }
148
+ return { clause: conds.length ? ` WHERE ${conds.join(" AND ")}` : "", params };
137
149
  }
138
150
  async close() {
139
- await this.#client.end();
151
+ await this.#driver.close();
140
152
  }
141
153
  }
142
- function whereClause(where) {
143
- const conds = [];
144
- const params = [];
145
- for (const [col, value] of Object.entries(where)) {
146
- if (value === null || value === undefined) {
147
- conds.push(`${ident(col)} IS NULL`);
148
- }
149
- else if (Array.isArray(value)) {
150
- params.push(value);
151
- conds.push(`${ident(col)} = ANY($${params.length})`);
154
+ function diff(tables, before, after) {
155
+ const out = {};
156
+ for (const table of tables) {
157
+ const a = before?.get(table.name) ?? [];
158
+ const b = after.get(table.name) ?? [];
159
+ const changes = table.key.length > 0 ? diffByKey(a, b, table.key) : diffAsBags(a, b);
160
+ if (changes.inserted.length || changes.updated.length || changes.deleted.length)
161
+ out[table.name] = changes;
162
+ }
163
+ return out;
164
+ }
165
+ function diffByKey(before, after, key) {
166
+ const keyOf = (r) => serialize(key.map((k) => r[k]));
167
+ const old = new Map(before.map((r) => [keyOf(r), r]));
168
+ const changes = { inserted: [], updated: [], deleted: [] };
169
+ for (const row of after) {
170
+ const k = keyOf(row);
171
+ const prev = old.get(k);
172
+ old.delete(k);
173
+ if (!prev) {
174
+ changes.inserted.push(row);
175
+ continue;
152
176
  }
153
- else {
154
- params.push(value);
155
- conds.push(`${ident(col)} = $${params.length}`);
177
+ const changed = [...new Set([...Object.keys(prev), ...Object.keys(row)])].filter((c) => serialize(prev[c]) !== serialize(row[c]));
178
+ if (changed.length)
179
+ changes.updated.push({ key: Object.fromEntries(key.map((k) => [k, row[k]])), before: prev, after: row, changed });
180
+ }
181
+ changes.deleted.push(...old.values());
182
+ return changes;
183
+ }
184
+ /** Without a primary key, rows are compared as a multiset: an update shows up as a delete plus an insert. */
185
+ function diffAsBags(before, after) {
186
+ const left = new Map();
187
+ for (const r of before) {
188
+ const k = serialize(r);
189
+ left.set(k, [...(left.get(k) ?? []), r]);
190
+ }
191
+ const inserted = [];
192
+ for (const r of after) {
193
+ const same = left.get(serialize(r));
194
+ if (same?.length)
195
+ same.pop();
196
+ else
197
+ inserted.push(r);
198
+ }
199
+ return { inserted, updated: [], deleted: [...left.values()].flat() };
200
+ }
201
+ function serialize(v) {
202
+ return JSON.stringify(v, (_, x) => (typeof x === "bigint" ? x.toString() : x));
203
+ }
204
+ /** Short summary of `changes()` for failure output. */
205
+ export function formatChanges(changes, maxRows = 3) {
206
+ const lines = [];
207
+ const show = (sign, rows) => {
208
+ for (const r of rows.slice(0, maxRows))
209
+ lines.push(` ${sign} ${truncate(serialize(r))}`);
210
+ if (rows.length > maxRows)
211
+ lines.push(` ${sign} …and ${rows.length - maxRows} more`);
212
+ };
213
+ for (const [table, c] of Object.entries(changes)) {
214
+ const counts = [
215
+ c.inserted.length && `${c.inserted.length} inserted`,
216
+ c.updated.length && `${c.updated.length} updated`,
217
+ c.deleted.length && `${c.deleted.length} deleted`,
218
+ ].filter(Boolean);
219
+ lines.push(` ${table}: ${counts.join(", ")}`);
220
+ show("+", c.inserted);
221
+ for (const u of c.updated.slice(0, maxRows)) {
222
+ const key = Object.entries(u.key).map(([k, v]) => `${k}=${serialize(v)}`).join(" ");
223
+ const cols = u.changed.map((col) => `${col}: ${serialize(u.before[col])} → ${serialize(u.after[col])}`).join(", ");
224
+ lines.push(` ~ ${key} ${truncate(cols)}`);
156
225
  }
226
+ if (c.updated.length > maxRows)
227
+ lines.push(` ~ …and ${c.updated.length - maxRows} more`);
228
+ show("-", c.deleted);
157
229
  }
158
- return { clause: conds.length ? ` WHERE ${conds.join(" AND ")}` : "", params };
230
+ return lines.join("\n");
159
231
  }
160
- /** Quote an identifier; `schema.table` is split on the dot. */
161
- function ident(name) {
162
- return name
163
- .split(".")
164
- .map((part) => `"${part.replace(/"/g, '""')}"`)
165
- .join(".");
232
+ function truncate(s, max = 200) {
233
+ return s.length > max ? `${s.slice(0, max)}…` : s;
166
234
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * What `Db` needs from a database engine. Everything dialect-specific
3
+ * (quoting, placeholders, catalog queries, how to empty tables) lives behind
4
+ * this interface so the rest of slicetest stays engine-agnostic.
5
+ */
6
+ export interface Row {
7
+ [column: string]: unknown;
8
+ }
9
+ export interface Table {
10
+ /** Display name: `name` in the default schema, else `schema.name`. */
11
+ name: string;
12
+ quoted: string;
13
+ /** Primary-key columns, in order; empty when there is none. */
14
+ key: string[];
15
+ }
16
+ export interface Driver {
17
+ query<T extends Row = Row>(sql: string, params?: unknown[]): Promise<T[]>;
18
+ /** Several parameterless SELECTs in one round trip. */
19
+ queryAll(sqls: string[]): Promise<Row[][]>;
20
+ /** Run a script of several statements, e.g. a seed file. */
21
+ exec(script: string): Promise<void>;
22
+ /** Quote an identifier; `schema.table` is split on the dot. */
23
+ ident(name: string): string;
24
+ /** Quote a single column name, dots and all. */
25
+ column(name: string): string;
26
+ /** Bind placeholder for the n-th (1-based) parameter. */
27
+ param(n: number): string;
28
+ /** A `column IN (...)` condition for `values`, appending its parameters to `params`. */
29
+ inList(column: string, values: unknown[], params: unknown[]): string;
30
+ /** Data tables to reset, minus `keep` (bare or `schema.table`) and engine-owned tables. */
31
+ listTables(schemas: string[], keep: string[]): Promise<Table[]>;
32
+ /** Empty `tables` and restart their id sequences, without dropping the app's connections. */
33
+ truncate(tables: Table[]): Promise<void>;
34
+ /** Insert one row and return it as stored (defaults and generated ids filled in). */
35
+ insert(table: string, row: Row): Promise<Row[]>;
36
+ close(): Promise<void>;
37
+ }
38
+ /** Server-level operations: the databases slicetest creates for templates and workers. */
39
+ export interface Admin {
40
+ /** Names of databases starting with `prefix`. */
41
+ databases(prefix: string): Promise<string[]>;
42
+ create(name: string): Promise<void>;
43
+ /** Create `name` as a copy of `template` (schema and data). */
44
+ clone(template: string, name: string): Promise<void>;
45
+ /** Drop `name`, disconnecting anyone still using it. */
46
+ drop(name: string): Promise<void>;
47
+ /** Run `fn` while holding a server-wide lock, so concurrent runs build a shared template once. */
48
+ withLock<T>(key: string, fn: () => Promise<T>): Promise<T>;
49
+ /** Connection URL for database `name` on this server. */
50
+ urlFor(name: string): string;
51
+ close(): Promise<void>;
52
+ }
53
+ export interface Engine {
54
+ name: string;
55
+ /** Container image used when no `db.url` is given. */
56
+ defaultImage: string;
57
+ startContainer(image: string, reuse: boolean): Promise<{
58
+ url: string;
59
+ stop(): Promise<unknown>;
60
+ }>;
61
+ admin(url: string): Promise<Admin>;
62
+ driver(url: string): Promise<Driver>;
63
+ /** The URL to hand Atlas for database `url`. */
64
+ atlasUrl(url: string): string;
65
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * What `Db` needs from a database engine. Everything dialect-specific
3
+ * (quoting, placeholders, catalog queries, how to empty tables) lives behind
4
+ * this interface so the rest of slicetest stays engine-agnostic.
5
+ */
6
+ export {};
@@ -0,0 +1,5 @@
1
+ import type { ResolvedOptions } from "../config.js";
2
+ import type { Engine } from "./driver.js";
3
+ export type { Admin, Driver, Engine, Row, Table } from "./driver.js";
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>;
@@ -0,0 +1,14 @@
1
+ import { postgres } from "./postgres.js";
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
+ return postgres;
14
+ }
@@ -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;