@galadrim/galadmin 0.1.236 → 0.2.7

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 (66) hide show
  1. package/.github/workflows/main.yml +34 -3
  2. package/README.md +65 -0
  3. package/dist/api.d.ts +16 -0
  4. package/dist/api.js +240 -189
  5. package/dist/api.js.map +1 -1
  6. package/dist/index.js +25 -3
  7. package/dist/index.js.map +1 -1
  8. package/dist/tsconfig.tsbuildinfo +1 -1
  9. package/dist/types/config.d.ts +6 -1
  10. package/dist/utils/dbManager.d.ts +5 -0
  11. package/dist/utils/dbManager.js +146 -11
  12. package/dist/utils/dbManager.js.map +1 -1
  13. package/dist/utils/imageDimensions.d.ts +5 -0
  14. package/dist/utils/imageDimensions.js +98 -0
  15. package/dist/utils/imageDimensions.js.map +1 -0
  16. package/jest.config.js +43 -0
  17. package/jest.integration.config.js +16 -0
  18. package/package.json +30 -10
  19. package/src/api.ts +284 -217
  20. package/src/front/src/App.test.js +28 -0
  21. package/src/front/src/components/OutsideClickHandler.test.js +53 -0
  22. package/src/front/src/components/RedirectToFirstView.test.js +38 -0
  23. package/src/front/src/utils/checkRequiredColumns.test.js +46 -0
  24. package/src/front/src/utils/evalExpression.test.js +29 -0
  25. package/src/index.ts +26 -3
  26. package/src/types/config.ts +13 -1
  27. package/src/utils/dbManager.ts +243 -102
  28. package/src/utils/imageDimensions.ts +112 -0
  29. package/tests/__mocks__/@aws-sdk/client-s3.js +6 -0
  30. package/tests/__mocks__/@aws-sdk/s3-request-presigner.js +3 -0
  31. package/tests/__mocks__/@google-cloud/storage.js +7 -0
  32. package/tests/__mocks__/argon2.js +3 -0
  33. package/tests/__mocks__/axios.js +7 -0
  34. package/tests/__mocks__/connect-pg-simple.js +24 -0
  35. package/tests/__mocks__/express-mysql-session.js +24 -0
  36. package/tests/__mocks__/mysql2/promise.js +3 -0
  37. package/tests/__mocks__/pg.js +3 -0
  38. package/tests/__mocks__/rollup.js +5 -0
  39. package/tests/__mocks__/ssh2-sftp-client.js +10 -0
  40. package/tests/api-helpers.test.ts +376 -0
  41. package/tests/dbManager.init.test.ts +129 -0
  42. package/tests/dbManager.test.ts +265 -0
  43. package/tests/edge-cases.test.ts +167 -0
  44. package/tests/helpers/app.ts +69 -0
  45. package/tests/helpers/fakeDb.ts +66 -0
  46. package/tests/helpers/fakePool.ts +76 -0
  47. package/tests/helpers/fixtures.ts +68 -0
  48. package/tests/imageDimensions.test.ts +111 -0
  49. package/tests/index.sessionStore.test.ts +19 -0
  50. package/tests/index.test.ts +412 -0
  51. package/tests/integration/__mocks__/axios.js +3 -0
  52. package/tests/integration/dbHarness.ts +381 -0
  53. package/tests/integration/dbManager.integration.test.ts +69 -0
  54. package/tests/integration/globalSetup.ts +43 -0
  55. package/tests/integration/globalTeardown.ts +42 -0
  56. package/tests/integration/routes.authorization.integration.test.ts +319 -0
  57. package/tests/integration/routes.config.integration.test.ts +189 -0
  58. package/tests/integration/routes.crud.integration.test.ts +805 -0
  59. package/tests/integration/routes.custom.integration.test.ts +376 -0
  60. package/tests/integration/routes.foreign.integration.test.ts +306 -0
  61. package/tests/integration/routes.importExport.integration.test.ts +404 -0
  62. package/tests/integration/routes.integration.test.ts +366 -0
  63. package/tests/integration/routes.postgresql.integration.test.ts +88 -0
  64. package/tests/integration/routes.records.integration.test.ts +634 -0
  65. package/tests/integration/routes.upload.integration.test.ts +425 -0
  66. package/tests/setup/silence-console.ts +15 -0
@@ -0,0 +1,381 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { MySqlContainer } from "@testcontainers/mysql";
5
+ import { PostgreSqlContainer } from "@testcontainers/postgresql";
6
+ import type { StartedMySqlContainer } from "@testcontainers/mysql";
7
+ import type { StartedPostgreSqlContainer } from "@testcontainers/postgresql";
8
+ import * as mysqlDriver from "mysql";
9
+ import { Client as PgClient } from "pg";
10
+ import dbManager from "../../src/utils/dbManager";
11
+
12
+ /**
13
+ * Real-database harness for the integration suite.
14
+ *
15
+ * The MySQL and PostgreSQL containers are started ONCE per jest run by
16
+ * tests/integration/globalSetup.ts, which records their connection details
17
+ * in a state file (path exported through GALADMIN_IT_STATE so every worker
18
+ * and the teardown find it). The factories below only create fresh driver
19
+ * pools against the shared containers; globalTeardown stops the containers.
20
+ *
21
+ * Started explicitly via `npm run test:integration` (TEST_INTEGRATION=1 with
22
+ * jest.integration.config.js). When the flag is absent, globalSetup starts
23
+ * nothing and describeIntegration skips every block, so laptop runs without
24
+ * Docker still pass.
25
+ *
26
+ * One MySQL container backs both driver integrations (the `mysql` package and
27
+ * `mysql2` via the useMysql2 flag); one PostgreSQL container backs the
28
+ * third backend. Schema DDL is applied once by globalSetup through the
29
+ * native drivers, one statement at a time (multipleStatements stays off —
30
+ * that is a security property worth not relaxing in tests).
31
+ */
32
+
33
+ export const INTEGRATION_ENABLED = process.env.TEST_INTEGRATION === "1";
34
+
35
+ // describeIntegration is only consumed by test files, which always run inside
36
+ // jest where `describe` is injected. globalSetup/globalTeardown import this
37
+ // module OUTSIDE jest, where the global does not exist — hence the guard.
38
+ const jestDescribe: any = (globalThis as any).describe;
39
+ export const describeIntegration: any = jestDescribe
40
+ ? INTEGRATION_ENABLED
41
+ ? jestDescribe
42
+ : jestDescribe.skip
43
+ : undefined;
44
+
45
+ export type BackendName = "mysql" | "mysql2" | "postgresql";
46
+
47
+ export const MYSQL_DDL = [
48
+ `CREATE TABLE fruits (
49
+ id INT AUTO_INCREMENT PRIMARY KEY,
50
+ fruitName VARCHAR(255) NULL,
51
+ acidity INT NULL,
52
+ note VARCHAR(255) NULL,
53
+ slug VARCHAR(255) NULL,
54
+ ownerId INT NULL,
55
+ role VARCHAR(50) NULL,
56
+ age INT NULL,
57
+ activity_id INT NULL
58
+ )`,
59
+ `CREATE TABLE fruit_tags (
60
+ id INT AUTO_INCREMENT PRIMARY KEY,
61
+ tag VARCHAR(255) NOT NULL,
62
+ fruitId INT NOT NULL
63
+ )`,
64
+ `CREATE TABLE fruit_notes (
65
+ id INT AUTO_INCREMENT PRIMARY KEY,
66
+ text VARCHAR(255) NULL,
67
+ fruitId INT NOT NULL
68
+ )`,
69
+ `CREATE TABLE uuid_fruits (
70
+ id VARCHAR(64) PRIMARY KEY,
71
+ fruitName VARCHAR(255) NULL
72
+ )`,
73
+ `CREATE TABLE unique_fruits (
74
+ id INT AUTO_INCREMENT PRIMARY KEY,
75
+ fruitName VARCHAR(255) NOT NULL UNIQUE
76
+ )`,
77
+ // The joined lookup table's PK is activity_id (not id) so the bare `id`
78
+ // tiebreak in ORDER BY stays unambiguous on every engine.
79
+ `CREATE TABLE activities (
80
+ activity_id INT AUTO_INCREMENT PRIMARY KEY,
81
+ name VARCHAR(255) NULL
82
+ )`,
83
+ // Foreign-select fixtures: familyId doubles as the default label source,
84
+ // uuid/active/ownerId/f_id exercise foreignName, filters and joins.
85
+ `CREATE TABLE families (
86
+ id INT AUTO_INCREMENT PRIMARY KEY,
87
+ name VARCHAR(255) NULL,
88
+ familyId INT NULL,
89
+ uuid VARCHAR(64) NULL,
90
+ active INT NULL,
91
+ ownerId INT NULL,
92
+ f_id INT NULL
93
+ )`,
94
+ `CREATE TABLE family_labels (
95
+ id INT AUTO_INCREMENT PRIMARY KEY,
96
+ label VARCHAR(255) NULL
97
+ )`,
98
+ ];
99
+
100
+ export const PG_DDL = [
101
+ `CREATE TABLE fruits (
102
+ id SERIAL PRIMARY KEY,
103
+ "fruitName" VARCHAR(255),
104
+ acidity INT,
105
+ note VARCHAR(255),
106
+ slug VARCHAR(255),
107
+ "ownerId" INT,
108
+ role VARCHAR(50),
109
+ age INT,
110
+ activity_id INT
111
+ )`,
112
+ `CREATE TABLE fruit_tags (
113
+ id SERIAL PRIMARY KEY,
114
+ tag VARCHAR(255) NOT NULL,
115
+ "fruitId" INT NOT NULL
116
+ )`,
117
+ `CREATE TABLE fruit_notes (
118
+ id SERIAL PRIMARY KEY,
119
+ text VARCHAR(255),
120
+ "fruitId" INT NOT NULL
121
+ )`,
122
+ `CREATE TABLE uuid_fruits (
123
+ id VARCHAR(64) PRIMARY KEY,
124
+ "fruitName" VARCHAR(255)
125
+ )`,
126
+ `CREATE TABLE unique_fruits (
127
+ id SERIAL PRIMARY KEY,
128
+ "fruitName" VARCHAR(255) NOT NULL UNIQUE
129
+ )`,
130
+ `CREATE TABLE activities (
131
+ activity_id SERIAL PRIMARY KEY,
132
+ name VARCHAR(255)
133
+ )`,
134
+ `CREATE TABLE families (
135
+ id SERIAL PRIMARY KEY,
136
+ name VARCHAR(255),
137
+ "familyId" INT,
138
+ uuid VARCHAR(64),
139
+ active INT,
140
+ "ownerId" INT,
141
+ f_id INT
142
+ )`,
143
+ `CREATE TABLE family_labels (
144
+ id SERIAL PRIMARY KEY,
145
+ label VARCHAR(255)
146
+ )`,
147
+ ];
148
+
149
+ /** Truncated between tests; order matters (children before parents). */
150
+ export const RESET_TABLES = [
151
+ "fruit_tags",
152
+ "fruit_notes",
153
+ "fruits",
154
+ "uuid_fruits",
155
+ "unique_fruits",
156
+ "activities",
157
+ "families",
158
+ "family_labels",
159
+ ];
160
+
161
+ const DB_NAME = "galadmin_test";
162
+ const DB_USER = "galadmin";
163
+ const DB_PASSWORD = "galadmin";
164
+
165
+ export type MysqlConnInfo = {
166
+ host: string;
167
+ port: number;
168
+ user: string;
169
+ password: string;
170
+ database: string;
171
+ };
172
+ export type PgConnInfo = MysqlConnInfo & { connectionUri: string };
173
+
174
+ export type IntegrationState = {
175
+ enabled: boolean;
176
+ mysql: MysqlConnInfo | null;
177
+ postgresql: PgConnInfo | null;
178
+ containerIds: { mysql?: string; postgresql?: string };
179
+ };
180
+
181
+ export function stateFilePath(): string {
182
+ return (
183
+ process.env.GALADMIN_IT_STATE ||
184
+ path.join(os.tmpdir(), "galadmin-it-state.json")
185
+ );
186
+ }
187
+
188
+ export function writeIntegrationState(state: IntegrationState) {
189
+ fs.writeFileSync(stateFilePath(), JSON.stringify(state));
190
+ }
191
+
192
+ export function readIntegrationState(): IntegrationState | null {
193
+ try {
194
+ return JSON.parse(fs.readFileSync(stateFilePath(), "utf8"));
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ export class DbHarness {
201
+ manager: dbManager;
202
+ /** Raw pg client for maintenance SQL that must bypass the conversion pipeline. */
203
+ rawPg?: PgClient;
204
+
205
+ get dbType() {
206
+ return this.manager.dbType;
207
+ }
208
+
209
+ query(sql: string, args: any[] = []) {
210
+ return this.manager.query(sql, args);
211
+ }
212
+
213
+ async stop() {
214
+ // Close the driver pool; the containers are shared for the whole jest
215
+ // run and are stopped by globalTeardown. Closing before the run's end
216
+ // is fine — each pool is private to the file that created it.
217
+ await this.rawPg?.end();
218
+ const pool: any = (this.manager as any)._db;
219
+ if (pool?.end) {
220
+ await new Promise<void>((resolve) => pool.end(() => resolve()));
221
+ }
222
+ }
223
+ }
224
+
225
+ function managerForMysql(conn: MysqlConnInfo, useMysql2: boolean) {
226
+ const manager = new dbManager();
227
+ manager.init({
228
+ mysql: conn,
229
+ useMysql2,
230
+ });
231
+ return manager;
232
+ }
233
+
234
+ function managerForPg(conn: PgConnInfo) {
235
+ const manager = new dbManager();
236
+ manager.init({
237
+ postgresql: conn,
238
+ });
239
+ return manager;
240
+ }
241
+
242
+ /**
243
+ * Applies DDL through the NATIVE driver, not the galadmin manager:
244
+ * quotePostgresQuery only knows DML keywords, so DDL sent through the PG
245
+ * conversion pipeline would get its CREATE/TABLE/PRIMARY KEY keywords quoted
246
+ * and rejected by the server.
247
+ */
248
+ export async function applyMysqlDdl(
249
+ conn: MysqlConnInfo,
250
+ statements: string[],
251
+ ) {
252
+ const connection = mysqlDriver.createConnection(conn);
253
+ await new Promise<void>((resolve, reject) =>
254
+ connection.connect((err) => (err ? reject(err) : resolve())),
255
+ );
256
+ for (const statement of statements) {
257
+ await new Promise<void>((resolve, reject) =>
258
+ connection.query(statement, (err) => (err ? reject(err) : resolve())),
259
+ );
260
+ }
261
+ await new Promise<void>((resolve) => connection.end(() => resolve()));
262
+ }
263
+
264
+ export async function applyPgDdl(conn: PgConnInfo, statements: string[]) {
265
+ const client = new PgClient({ connectionString: conn.connectionUri });
266
+ await client.connect();
267
+ for (const statement of statements) {
268
+ await client.query(statement);
269
+ }
270
+ await client.end();
271
+ }
272
+
273
+ /**
274
+ * Starts the shared containers, applies the schema on both engines and
275
+ * returns the state that globalSetup persists for the rest of the run.
276
+ * Container handles are stashed on globalThis so globalTeardown (same jest
277
+ * process) can stop them gracefully.
278
+ */
279
+ export async function startSharedContainers(): Promise<IntegrationState> {
280
+ const mysqlContainer = await new MySqlContainer("mysql:8.0")
281
+ .withDatabase(DB_NAME)
282
+ .withUsername(DB_USER)
283
+ .withUserPassword(DB_PASSWORD)
284
+ // The legacy `mysql` driver does not support MySQL 8's default
285
+ // caching_sha2_password; real deployments using the v1 driver must run
286
+ // with mysql_native_password (or switch to useMysql2).
287
+ .withCommand(["--default-authentication-plugin=mysql_native_password"])
288
+ .start();
289
+ const mysqlConn: MysqlConnInfo = {
290
+ host: mysqlContainer.getHost(),
291
+ port: mysqlContainer.getPort(),
292
+ user: DB_USER,
293
+ password: DB_PASSWORD,
294
+ database: mysqlContainer.getDatabase(),
295
+ };
296
+ await applyMysqlDdl(mysqlConn, MYSQL_DDL);
297
+
298
+ const pgContainer = await new PostgreSqlContainer("postgres:16-alpine")
299
+ .withDatabase(DB_NAME)
300
+ .withUsername(DB_USER)
301
+ .withPassword(DB_PASSWORD)
302
+ .start();
303
+ const pgConn: PgConnInfo = {
304
+ host: pgContainer.getHost(),
305
+ port: pgContainer.getPort(),
306
+ user: pgContainer.getUsername(),
307
+ password: pgContainer.getPassword(),
308
+ database: pgContainer.getDatabase(),
309
+ connectionUri: pgContainer.getConnectionUri(),
310
+ };
311
+ await applyPgDdl(pgConn, PG_DDL);
312
+
313
+ (globalThis as any).__GALADMIN_IT_CONTAINERS__ = {
314
+ mysql: mysqlContainer,
315
+ postgresql: pgContainer,
316
+ };
317
+
318
+ return {
319
+ enabled: true,
320
+ mysql: mysqlConn,
321
+ postgresql: pgConn,
322
+ containerIds: {
323
+ mysql: mysqlContainer.getId(),
324
+ postgresql: pgContainer.getId(),
325
+ },
326
+ };
327
+ }
328
+
329
+ function requireState(): IntegrationState {
330
+ const state = readIntegrationState();
331
+ if (!state || !state.enabled) {
332
+ throw new Error(
333
+ "Integration state missing — containers must be started via globalSetup (run with TEST_INTEGRATION=1).",
334
+ );
335
+ }
336
+ return state;
337
+ }
338
+
339
+ /** Fresh `mysql` driver pool against the shared MySQL container. */
340
+ export async function getMysqlHarness(): Promise<DbHarness> {
341
+ const state = requireState();
342
+ const harness = new DbHarness();
343
+ harness.manager = managerForMysql(state.mysql!, false);
344
+ return harness;
345
+ }
346
+
347
+ /** Fresh `mysql2` driver pool sharing the same MySQL container/tables. */
348
+ export async function getMysql2Harness(): Promise<DbHarness> {
349
+ const state = requireState();
350
+ const harness = new DbHarness();
351
+ harness.manager = managerForMysql(state.mysql!, true);
352
+ return harness;
353
+ }
354
+
355
+ /** Fresh pg pool + raw maintenance client against the shared PG container. */
356
+ export async function getPgHarness(): Promise<DbHarness> {
357
+ const state = requireState();
358
+ const harness = new DbHarness();
359
+ harness.manager = managerForPg(state.postgresql!);
360
+ harness.rawPg = new PgClient({
361
+ connectionString: state.postgresql!.connectionUri,
362
+ });
363
+ await harness.rawPg.connect();
364
+ return harness;
365
+ }
366
+
367
+ /** Removes every row created by previous tests (keeps the schema, resets ids). */
368
+ export async function resetTables(harness: DbHarness) {
369
+ if (harness.dbType === "POSTGRESQL") {
370
+ // TRUNCATE/RESTART IDENTITY are DDL: run them through the raw client,
371
+ // the conversion pipeline quotes non-DML keywords.
372
+ await harness.rawPg!.query(
373
+ `TRUNCATE TABLE ${RESET_TABLES.join(", ")} RESTART IDENTITY`,
374
+ );
375
+ return;
376
+ }
377
+ for (const statement of RESET_TABLES.map((t) => `TRUNCATE TABLE ${t}`)) {
378
+ const { err } = await harness.query(statement);
379
+ if (err) throw err;
380
+ }
381
+ }
@@ -0,0 +1,69 @@
1
+ import {
2
+ DbHarness,
3
+ describeIntegration,
4
+ getMysql2Harness,
5
+ getPgHarness,
6
+ } from "./dbHarness";
7
+
8
+ /**
9
+ * dbManager pool wiring + driver interaction against real engines. The
10
+ * scripted suite asserted the createPool options; here the manager is
11
+ * initialized against the shared containers and the result-shape
12
+ * normalization ({err, rows, fields}) is verified for real.
13
+ */
14
+ describeIntegration("dbManager against real databases", () => {
15
+ const harnesses: Record<string, DbHarness> = {};
16
+
17
+ beforeAll(async () => {
18
+ harnesses.mysql2 = await getMysql2Harness();
19
+ harnesses.postgresql = await getPgHarness();
20
+ });
21
+
22
+ afterAll(async () => {
23
+ await harnesses.mysql2?.stop();
24
+ await harnesses.postgresql?.stop();
25
+ });
26
+
27
+ it("resolves {err, rows, fields} passthrough for MySQL", async () => {
28
+ const result = await harnesses.mysql2.query("SELECT 1 AS one");
29
+ expect(result.err).toBeNull();
30
+ expect(result.rows).toEqual([{ one: 1 }]);
31
+ expect(result.fields).toBeDefined();
32
+ });
33
+
34
+ it("unwraps {rows, fields} from the pg result object", async () => {
35
+ const result = await harnesses.postgresql.query("SELECT 1 AS one");
36
+ // pg resolves success with an undefined err (MySQL passes null); every
37
+ // consumer only checks truthiness.
38
+ expect(result.err).toBeFalsy();
39
+ expect(result.rows).toEqual([{ one: 1 }]);
40
+ expect(result.fields).toBeDefined();
41
+ });
42
+
43
+ it("propagates engine errors instead of throwing", async () => {
44
+ const result = await harnesses.postgresql.query("SELECT broken");
45
+ expect(result.err).toBeTruthy();
46
+ expect(result.rows).toBeUndefined();
47
+ });
48
+
49
+ it("keeps MySQL-style ? placeholders working on the MySQL2 driver", async () => {
50
+ await harnesses.mysql2.query(
51
+ "CREATE TEMPORARY TABLE gm_probe (id INT PRIMARY KEY, name VARCHAR(50))",
52
+ );
53
+ const { err } = await harnesses.mysql2.query(
54
+ "INSERT INTO gm_probe (id, name) VALUES (?, ?)",
55
+ [1, "pomme"],
56
+ );
57
+ expect(err).toBeFalsy();
58
+ const { rows } = await harnesses.mysql2.query(
59
+ "SELECT name FROM gm_probe WHERE id = ?",
60
+ [1],
61
+ );
62
+ expect(rows).toEqual([{ name: "pomme" }]);
63
+ });
64
+
65
+ it("rejects stacked statements (multipleStatements off by default)", async () => {
66
+ const result = await harnesses.mysql2.query("SELECT 1; SELECT 2");
67
+ expect(result.err).toBeTruthy();
68
+ });
69
+ });
@@ -0,0 +1,43 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ import {
4
+ INTEGRATION_ENABLED,
5
+ startSharedContainers,
6
+ writeIntegrationState,
7
+ } from "./dbHarness";
8
+
9
+ const run = promisify(execFile);
10
+
11
+ // Fail fast with an actionable message when Docker is unreachable.
12
+ async function assertDockerAvailable() {
13
+ try {
14
+ await run("docker", ["info"], { timeout: 10_000 });
15
+ } catch (err: any) {
16
+ throw new Error(
17
+ "Integration tests (and therefore `npm run test:coverage`) require a running Docker daemon, " +
18
+ "but `docker info` failed. Start Docker, or use `npm test` for the unit-only suite. " +
19
+ `Cause: ${err?.message ?? err}`,
20
+ );
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Jest globalSetup for the integration suite: boots the shared MySQL and
26
+ * PostgreSQL containers once for the whole run and persists their connection
27
+ * details to the state file every harness factory reads. Without
28
+ * TEST_INTEGRATION=1 it records an "enabled: false" state so the harness
29
+ * factories fail loudly if anything tries to reach the databases.
30
+ */
31
+ export default async function globalSetup() {
32
+ if (!INTEGRATION_ENABLED) {
33
+ writeIntegrationState({
34
+ enabled: false,
35
+ mysql: null,
36
+ postgresql: null,
37
+ containerIds: {},
38
+ });
39
+ return;
40
+ }
41
+ await assertDockerAvailable();
42
+ writeIntegrationState(await startSharedContainers());
43
+ }
@@ -0,0 +1,42 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ import { readIntegrationState, stateFilePath } from "./dbHarness";
4
+
5
+ const run = promisify(execFile);
6
+
7
+ /**
8
+ * Jest globalTeardown: stops the shared containers. The graceful path uses
9
+ * the container handles stashed on globalThis by globalSetup (same jest
10
+ * process); if that fails or is unavailable, fall back to `docker rm -f`
11
+ * with the recorded container ids. Anything left behind is reaped by ryuk
12
+ * once this process exits.
13
+ */
14
+ export default async function globalTeardown() {
15
+ const handles = (globalThis as any).__GALADMIN_IT_CONTAINERS__;
16
+ for (const key of ["mysql", "postgresql"]) {
17
+ const handle = handles?.[key];
18
+ if (handle) {
19
+ try {
20
+ await handle.stop();
21
+ } catch {
22
+ // fall through to the docker fallback below
23
+ }
24
+ }
25
+ }
26
+ const state = readIntegrationState();
27
+ if (state?.enabled) {
28
+ for (const id of [state.containerIds.mysql, state.containerIds.postgresql]) {
29
+ if (!id) continue;
30
+ try {
31
+ await run("docker", ["rm", "-f", id]);
32
+ } catch {
33
+ // already removed by the graceful path — nothing to do
34
+ }
35
+ }
36
+ }
37
+ try {
38
+ require("fs").unlinkSync(stateFilePath());
39
+ } catch {
40
+ // state file is disposable
41
+ }
42
+ }