@softure-ai/db 0.1.12 → 0.1.13

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/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ Newest first. Each version lists what changed for an app that uses `@softure-ai/
4
4
  production, the version gets a line `verified in: <app>@<commit>` ([docs/05](../../docs/05-adoption-playbook.md),
5
5
  "Definition of done"). Versions before the first one below are described in their GitHub Releases (`db@x.y.z`).
6
6
 
7
+ ## 0.1.13
8
+
9
+ - `@softure-ai/db/testing/global-setup`, a Vitest `globalSetup` module: each module set is migrated once per run
10
+ instead of once per test file (#641). The template is written to a folder the setup creates and removes
11
+ (`SOFTURE_DB_TEST_TEMPLATES`), named by a hash of the module migration files, the drizzle folders' files and the
12
+ baseline; every other file and worker loads it from disk. An app hook other than `drizzleAppMigrations` needs the
13
+ new `templateKey` option to be shared; without it, its template stays per file as before.
14
+ - `createTestDatabase(modules, { reuse: true })`: `close()` returns the database to a pool and the next call gets it
15
+ back reset to the template (rows, seed rows and ledgers, sequences, session state) in milliseconds instead of a new
16
+ copy (~0.5 s). A database whose schema a test changed is replaced by a fresh copy.
17
+ - Without the setup and without `reuse`, nothing changes.
18
+
7
19
  ## 0.1.12
8
20
 
9
21
  - `drizzleAppMigrations(folder, { migrationsTable, migrationsSchema })` names the drizzle ledger the folder uses
package/README.md CHANGED
@@ -427,7 +427,7 @@ For fresh databases of an app that adopted a module, declare a baseline (section
427
427
 
428
428
  **Tests:** `createTestDatabase(modules)` from `@softure-ai/db/testing` returns `{ db, client, close }`:
429
429
  an in-memory PGlite with the ledger and the listed modules migrated (list the module and the
430
- modules it depends on). Each module set is migrated once per test worker and copied per call; a
430
+ modules it depends on). Each module set is migrated once into a template and copied per call; a
431
431
  broken migration throws naming the file. An app with its own migrations passes the same hooks,
432
432
  `createTestDatabase(modules, { app: appMigrations })`, so module tables can reference app tables; the
433
433
  template is cached per hook object, so import the shared object rather than writing hooks inline.
@@ -439,6 +439,53 @@ const database = await createTestDatabase([notes()]);
439
439
  afterEach(() => database.close());
440
440
  ```
441
441
 
442
+ **A template per run, not per file.** The template cache is module state, and Vitest isolates modules
443
+ per test file, so without more each test file migrates again (seconds per file). Add the global setup,
444
+ and every module set is migrated once per run and loaded from disk by every other file and worker:
445
+
446
+ ```ts
447
+ // vitest.config.ts
448
+ export default defineConfig({ test: { globalSetup: ["@softure-ai/db/testing/global-setup"] } });
449
+ ```
450
+
451
+ - The setup creates a temporary folder, names it in `SOFTURE_DB_TEST_TEMPLATES` for the workers, and
452
+ removes it when the run ends. An app that sets the variable itself keeps its folder; that folder must
453
+ not outlive a dependency upgrade, since the file names do not cover the PGlite or package version.
454
+ - A template on disk is named by a hash of what it is built from: the module ids and every file in their
455
+ migration folders, each `drizzleAppMigrations` folder's files and ledger, `templateKey`, the baseline.
456
+ Editing a migration gives a new template.
457
+ - An app hook other than `drizzleAppMigrations` is a function nothing can identify in another process: give
458
+ it `templateKey` (change the key when its migrations change), or its template stays per file:
459
+ `createTestDatabase(modules, { app: appMigrations, templateKey: "app-v12" })`.
460
+ - Two workers that need the same template at once may both build it; the file is written under a
461
+ temporary name and renamed, so a reader sees the whole file or none.
462
+
463
+ **Reuse: a reset instead of a new copy.** A copy of the template costs ~0.4-0.6 s per call; most tests
464
+ only need empty tables. With `reuse: true`, `close()` returns the database to a pool, and the next call
465
+ gets it back reset to the template (a few milliseconds):
466
+
467
+ ```ts
468
+ let database: TestDatabase;
469
+ beforeEach(async () => {
470
+ database = await createTestDatabase([notes()], { reuse: true });
471
+ });
472
+ afterEach(() => database.close());
473
+ afterAll(() => closeReusedTestDatabases());
474
+ ```
475
+
476
+ - Reset: session state discarded (`DISCARD ALL`: settings, temporary tables, prepared statements,
477
+ listeners, advisory locks), every table emptied and the template's rows copied back (seed rows and the
478
+ migration ledgers included), sequences set to the template's values, populated materialized views
479
+ refreshed.
480
+ - A test that changed the schema (a table, column, index, constraint, function, trigger or enum value)
481
+ or left a transaction open hands back a database that cannot be reset; the next call closes it and
482
+ gets a fresh copy. Two databases open at the same time are never the same instance.
483
+ - The pool is module state: one per test file under Vitest's default isolation.
484
+ `closeReusedTestDatabases()` in `afterAll` closes its idle databases.
485
+ - Seed rows, identity values and sequences come back exactly as a fresh copy has them. A test that
486
+ counts on an exact id after inserts should read it, not assume it: PGlite's sequences resume past
487
+ their logged values in every copy, with or without reuse.
488
+
442
489
  ## 6. Environment variables
443
490
 
444
491
  None read by the package. The app passes `DATABASE_URL` (or any variable) through
@@ -15,4 +15,6 @@ export interface DrizzleAppMigrationsOptions {
15
15
  export declare function drizzleAppMigrations(folder: string | URL, options?: DrizzleAppMigrationsOptions): AppMigrationHook;
16
16
  /** The ledger (`schema.table`) a `drizzleAppMigrations` hook writes to; `undefined` for any other hook. */
17
17
  export declare function getDrizzleLedger(hook: AppMigrationHook): string | undefined;
18
+ /** The absolute folder a `drizzleAppMigrations` hook applies; `undefined` for any other hook. */
19
+ export declare function getDrizzleFolder(hook: AppMigrationHook): string | undefined;
18
20
  //# sourceMappingURL=drizzle.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"drizzle.d.ts","sourceRoot":"","sources":["../../src/migrations/drizzle.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEtD,MAAM,WAAW,2BAA2B;IAC1C,+EAA+E;IAC/E,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,oDAAoD;IACpD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AAQD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,EAAE,OAAO,GAAE,2BAAgC,GAAG,gBAAgB,CAkBtH;AAED,2GAA2G;AAC3G,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,GAAG,SAAS,CAE3E"}
1
+ {"version":3,"file":"drizzle.d.ts","sourceRoot":"","sources":["../../src/migrations/drizzle.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEtD,MAAM,WAAW,2BAA2B;IAC1C,+EAA+E;IAC/E,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,oDAAoD;IACpD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AAcD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,EAAE,OAAO,GAAE,2BAAgC,GAAG,gBAAgB,CAkBtH;AAED,2GAA2G;AAC3G,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,GAAG,SAAS,CAE3E;AAED,iGAAiG;AACjG,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,GAAG,SAAS,CAE3E"}
@@ -1,10 +1,10 @@
1
1
  // The app's own drizzle-kit migrations as an app migration hook: drizzle's migrator for the handle's driver,
2
2
  // loaded only when the hook runs, so an app ships just its own driver.
3
+ import { resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  const DEFAULT_TABLE = "__drizzle_migrations";
5
6
  const DEFAULT_SCHEMA = "drizzle";
6
- // The ledger each hook writes to, so `migrate` can refuse a `before` and an `after` sharing one (issue #534).
7
- const ledgers = new WeakMap();
7
+ const sources = new WeakMap();
8
8
  /**
9
9
  * A hook for `app.before` (or `app.after`) that applies the drizzle-kit folder (`meta/_journal.json` and its SQL
10
10
  * files) with drizzle's migrator. Drizzle skips what its ledger (`drizzle.__drizzle_migrations` unless `options`
@@ -28,11 +28,15 @@ export function drizzleAppMigrations(folder, options = {}) {
28
28
  const { migrate } = await import("drizzle-orm/pglite/migrator");
29
29
  await migrate(handle.db, config);
30
30
  };
31
- ledgers.set(hook, `${config.migrationsSchema}.${config.migrationsTable}`);
31
+ sources.set(hook, { ledger: `${config.migrationsSchema}.${config.migrationsTable}`, folder: resolve(migrationsFolder) });
32
32
  return hook;
33
33
  }
34
34
  /** The ledger (`schema.table`) a `drizzleAppMigrations` hook writes to; `undefined` for any other hook. */
35
35
  export function getDrizzleLedger(hook) {
36
- return ledgers.get(hook);
36
+ return sources.get(hook)?.ledger;
37
+ }
38
+ /** The absolute folder a `drizzleAppMigrations` hook applies; `undefined` for any other hook. */
39
+ export function getDrizzleFolder(hook) {
40
+ return sources.get(hook)?.folder;
37
41
  }
38
42
  //# sourceMappingURL=drizzle.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"drizzle.js","sourceRoot":"","sources":["../../src/migrations/drizzle.ts"],"names":[],"mappings":"AAAA,6GAA6G;AAC7G,uEAAuE;AACvE,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAUzC,MAAM,aAAa,GAAG,sBAAsB,CAAC;AAC7C,MAAM,cAAc,GAAG,SAAS,CAAC;AAEjC,8GAA8G;AAC9G,MAAM,OAAO,GAAG,IAAI,OAAO,EAA4B,CAAC;AAExD;;;;;;GAMG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAoB,EAAE,UAAuC,EAAE;IAClG,MAAM,gBAAgB,GAAG,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;IACrF,MAAM,MAAM,GAAG;QACb,gBAAgB;QAChB,eAAe,EAAE,OAAO,CAAC,eAAe,IAAI,aAAa;QACzD,gBAAgB,EAAE,OAAO,CAAC,gBAAgB,IAAI,cAAc;KAC7D,CAAC;IACF,MAAM,IAAI,GAAqB,KAAK,EAAE,MAAM,EAAE,EAAE;QAC9C,IAAI,MAAM,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;YAC/B,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,MAAM,CAAC,oCAAoC,CAAC,CAAC;YACvE,MAAM,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;YACjC,OAAO;QACT,CAAC;QACD,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,MAAM,CAAC,6BAA6B,CAAC,CAAC;QAChE,MAAM,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;IACnC,CAAC,CAAC;IACF,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,MAAM,CAAC,gBAAgB,IAAI,MAAM,CAAC,eAAe,EAAE,CAAC,CAAC;IAC1E,OAAO,IAAI,CAAC;AACd,CAAC;AAED,2GAA2G;AAC3G,MAAM,UAAU,gBAAgB,CAAC,IAAsB;IACrD,OAAO,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AAC3B,CAAC"}
1
+ {"version":3,"file":"drizzle.js","sourceRoot":"","sources":["../../src/migrations/drizzle.ts"],"names":[],"mappings":"AAAA,6GAA6G;AAC7G,uEAAuE;AACvE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAUzC,MAAM,aAAa,GAAG,sBAAsB,CAAC;AAC7C,MAAM,cAAc,GAAG,SAAS,CAAC;AASjC,MAAM,OAAO,GAAG,IAAI,OAAO,EAAuC,CAAC;AAEnE;;;;;;GAMG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAoB,EAAE,UAAuC,EAAE;IAClG,MAAM,gBAAgB,GAAG,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;IACrF,MAAM,MAAM,GAAG;QACb,gBAAgB;QAChB,eAAe,EAAE,OAAO,CAAC,eAAe,IAAI,aAAa;QACzD,gBAAgB,EAAE,OAAO,CAAC,gBAAgB,IAAI,cAAc;KAC7D,CAAC;IACF,MAAM,IAAI,GAAqB,KAAK,EAAE,MAAM,EAAE,EAAE;QAC9C,IAAI,MAAM,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;YAC/B,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,MAAM,CAAC,oCAAoC,CAAC,CAAC;YACvE,MAAM,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;YACjC,OAAO;QACT,CAAC;QACD,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,MAAM,CAAC,6BAA6B,CAAC,CAAC;QAChE,MAAM,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;IACnC,CAAC,CAAC;IACF,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC,gBAAgB,IAAI,MAAM,CAAC,eAAe,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,gBAAgB,CAAC,EAAE,CAAC,CAAC;IACzH,OAAO,IAAI,CAAC;AACd,CAAC;AAED,2GAA2G;AAC3G,MAAM,UAAU,gBAAgB,CAAC,IAAsB;IACrD,OAAO,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;AACnC,CAAC;AAED,iGAAiG;AACjG,MAAM,UAAU,gBAAgB,CAAC,IAAsB;IACrD,OAAO,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;AACnC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export default function setup(): Promise<() => Promise<void>>;
2
+ //# sourceMappingURL=global-setup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"global-setup.d.ts","sourceRoot":"","sources":["../../src/testing/global-setup.ts"],"names":[],"mappings":"AASA,wBAA8B,KAAK,IAAI,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAWlE"}
@@ -0,0 +1,21 @@
1
+ // A Vitest `globalSetup` module (issue #641): `test: { globalSetup: ["@softure-ai/db/testing/global-setup"] }`.
2
+ // It runs once in the main process before any worker starts and gives the run a template folder; workers inherit the
3
+ // variable, so `createTestDatabase` migrates each module set once per run instead of once per test file. The folder
4
+ // is removed when the run ends. A folder the app named itself (the variable already set) is kept.
5
+ import { mkdtemp, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { TEMPLATE_DIR_VARIABLE } from "./template-store.js";
9
+ export default async function setup() {
10
+ const given = process.env[TEMPLATE_DIR_VARIABLE];
11
+ if (given !== undefined && given !== "") {
12
+ return () => Promise.resolve();
13
+ }
14
+ const dir = await mkdtemp(join(tmpdir(), "softure-db-templates-"));
15
+ process.env[TEMPLATE_DIR_VARIABLE] = dir;
16
+ return async () => {
17
+ delete process.env[TEMPLATE_DIR_VARIABLE];
18
+ await rm(dir, { recursive: true, force: true });
19
+ };
20
+ }
21
+ //# sourceMappingURL=global-setup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"global-setup.js","sourceRoot":"","sources":["../../src/testing/global-setup.ts"],"names":[],"mappings":"AAAA,gHAAgH;AAChH,qHAAqH;AACrH,oHAAoH;AACpH,kGAAkG;AAClG,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAE5D,MAAM,CAAC,OAAO,CAAC,KAAK,UAAU,KAAK;IACjC,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC;IACjD,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;QACxC,OAAO,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;IACjC,CAAC;IACD,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,uBAAuB,CAAC,CAAC,CAAC;IACnE,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC,GAAG,GAAG,CAAC;IACzC,OAAO,KAAK,IAAI,EAAE;QAChB,OAAO,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC;QAC1C,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAClD,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,15 @@
1
+ import type { PGlite } from "@electric-sql/pglite";
2
+ import type { PgliteClientDatabase } from "../client.js";
3
+ export interface PooledDatabase {
4
+ readonly db: PgliteClientDatabase;
5
+ readonly client: PGlite;
6
+ readonly close: () => Promise<void>;
7
+ }
8
+ /**
9
+ * A database from the pool of `key`, reset to the template's state, or a fresh copy from `open` when none is idle.
10
+ * Its `close` puts it back; a second `close` does nothing.
11
+ */
12
+ export declare function acquirePooledDatabase(key: string, open: () => Promise<PooledDatabase>): Promise<PooledDatabase>;
13
+ /** Closes every idle database of every pool; for `afterAll`. A database still held is closed when it comes back. */
14
+ export declare function closePooledDatabases(): Promise<void>;
15
+ //# sourceMappingURL=reuse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reuse.d.ts","sourceRoot":"","sources":["../../src/testing/reuse.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAEzD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,oBAAoB,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAkED;;;GAGG;AACH,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,cAAc,CAAC,GAAG,OAAO,CAAC,cAAc,CAAC,CAkBrH;AAED,oHAAoH;AACpH,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,IAAI,CAAC,CAM1D"}
@@ -0,0 +1,155 @@
1
+ const pools = new Map();
2
+ const USER_NAMESPACE = "n.nspname not in ('pg_catalog', 'information_schema') and n.nspname not like 'pg\\_toast%' and n.nspname not like 'pg\\_temp\\_%'";
3
+ // What a reset cannot undo. Indexes, constraints and triggers are listed with their table, enum labels with their type.
4
+ const FINGERPRINT_SQL = `
5
+ select coalesce(string_agg(entry, E'\\n' order by entry), '') as fingerprint from (
6
+ select format('rel %s %I.%I %s', c.relkind, n.nspname, c.relname,
7
+ (select string_agg(format('%I:%s', a.attname, format_type(a.atttypid, a.atttypmod)), ',' order by a.attnum)
8
+ from pg_attribute a where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped)) as entry
9
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace where ${USER_NAMESPACE}
10
+ union all
11
+ select format('fn %I.%I(%s)', n.nspname, p.proname, pg_get_function_identity_arguments(p.oid))
12
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace where ${USER_NAMESPACE}
13
+ union all
14
+ select format('con %s %s %s', co.conrelid::regclass, co.conname, co.contype)
15
+ from pg_constraint co join pg_namespace n on n.oid = co.connamespace where ${USER_NAMESPACE}
16
+ union all
17
+ select format('trg %s %s %s', t.tgrelid::regclass, t.tgname, t.tgenabled) from pg_trigger t where not t.tgisinternal
18
+ union all
19
+ select format('enum %s %s', e.enumtypid::regtype, e.enumlabel)
20
+ from pg_enum e join pg_type ty on ty.oid = e.enumtypid join pg_namespace n on n.oid = ty.typnamespace where ${USER_NAMESPACE}
21
+ ) entries`;
22
+ const TABLES_SQL = `
23
+ select format('%I.%I', n.nspname, c.relname) as name, c.relkind::text as kind, c.relispopulated as populated,
24
+ (select string_agg(format('%I', a.attname), ', ' order by a.attnum) from pg_attribute a
25
+ where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped and a.attgenerated = '') as columns
26
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
27
+ where c.relkind in ('r', 'p', 'm') and ${USER_NAMESPACE}
28
+ order by 1`;
29
+ const SEQUENCES_SQL = `
30
+ select format('%I.%I', schemaname, sequencename) as name, coalesce(last_value, start_value)::text as value, last_value is not null as called
31
+ from pg_sequences where schemaname not in ('pg_catalog', 'information_schema')`;
32
+ const RESTORE_SEQUENCES_SQL = "select setval(s.name::regclass, s.value, s.called) from json_to_recordset($1::json) as s(name text, value bigint, called boolean)";
33
+ /**
34
+ * A database from the pool of `key`, reset to the template's state, or a fresh copy from `open` when none is idle.
35
+ * Its `close` puts it back; a second `close` does nothing.
36
+ */
37
+ export async function acquirePooledDatabase(key, open) {
38
+ const pool = getPool(key);
39
+ for (let idle = pool.idle.pop(); idle !== undefined; idle = pool.idle.pop()) {
40
+ if (pool.state !== undefined && (await tryRestore(idle.client, await pool.state))) {
41
+ return lend(pool, idle);
42
+ }
43
+ await idle.close();
44
+ }
45
+ const fresh = await open();
46
+ try {
47
+ pool.state ??= captureTemplateState(fresh.client);
48
+ await pool.state;
49
+ }
50
+ catch (error) {
51
+ pool.state = undefined;
52
+ await fresh.close();
53
+ throw error;
54
+ }
55
+ return lend(pool, fresh);
56
+ }
57
+ /** Closes every idle database of every pool; for `afterAll`. A database still held is closed when it comes back. */
58
+ export async function closePooledDatabases() {
59
+ const retired = [...pools.values()];
60
+ pools.clear();
61
+ for (const pool of retired)
62
+ pool.isRetired = true;
63
+ const idle = retired.flatMap((pool) => pool.idle.splice(0));
64
+ await Promise.all(idle.map((database) => database.close()));
65
+ }
66
+ function getPool(key) {
67
+ let pool = pools.get(key);
68
+ if (pool === undefined) {
69
+ pool = { idle: [], state: undefined, isRetired: false };
70
+ pools.set(key, pool);
71
+ }
72
+ return pool;
73
+ }
74
+ function lend(pool, database) {
75
+ let isReturned = false;
76
+ return {
77
+ db: database.db,
78
+ client: database.client,
79
+ close: () => {
80
+ if (isReturned) {
81
+ return Promise.resolve();
82
+ }
83
+ isReturned = true;
84
+ if (pool.isRetired) {
85
+ return database.close();
86
+ }
87
+ pool.idle.push(database);
88
+ return Promise.resolve();
89
+ },
90
+ };
91
+ }
92
+ async function captureTemplateState(client) {
93
+ const fingerprint = await readFingerprint(client);
94
+ const relations = await client.query(TABLES_SQL);
95
+ const seeds = [];
96
+ for (const relation of relations.rows) {
97
+ if (relation.kind !== "r" || relation.columns === null) {
98
+ continue;
99
+ }
100
+ const hasRows = await client.query(`select exists (select 1 from ${relation.name}) as has_rows`);
101
+ if (hasRows.rows[0]?.has_rows === true) {
102
+ const copied = await client.query(`copy ${relation.name} (${relation.columns}) to '/dev/blob'`);
103
+ if (copied.blob === undefined) {
104
+ throw new Error(`createTestDatabase: copying the template rows of ${relation.name} returned no data`);
105
+ }
106
+ seeds.push({ table: relation.name, columns: relation.columns, rows: copied.blob });
107
+ }
108
+ }
109
+ const sequences = await client.query(SEQUENCES_SQL);
110
+ return {
111
+ fingerprint,
112
+ tables: relations.rows.filter((relation) => relation.kind !== "m").map((relation) => relation.name),
113
+ seeds,
114
+ sequences: sequences.rows,
115
+ materializedViews: relations.rows.filter((relation) => relation.kind === "m" && relation.populated).map((relation) => relation.name),
116
+ };
117
+ }
118
+ async function readFingerprint(client) {
119
+ const result = await client.query(FINGERPRINT_SQL);
120
+ return result.rows[0]?.fingerprint ?? "";
121
+ }
122
+ /**
123
+ * Restores the template's rows and session state. `false` when the database cannot be reset (its schema changed, or
124
+ * the test left it in a state a reset fails on, such as an open transaction): the caller replaces it.
125
+ */
126
+ async function tryRestore(client, state) {
127
+ try {
128
+ await client.exec("DISCARD ALL");
129
+ if ((await readFingerprint(client)) !== state.fingerprint) {
130
+ return false;
131
+ }
132
+ await client.transaction(async (tx) => {
133
+ // Rows go back as they were, so foreign keys and triggers stay out of the way.
134
+ await tx.exec("SET LOCAL session_replication_role = replica");
135
+ if (state.tables.length > 0) {
136
+ await tx.exec(`TRUNCATE ${state.tables.join(", ")} CASCADE`);
137
+ }
138
+ for (const seed of state.seeds) {
139
+ await tx.query(`copy ${seed.table} (${seed.columns}) from '/dev/blob'`, [], { blob: seed.rows });
140
+ }
141
+ if (state.sequences.length > 0) {
142
+ await tx.query(RESTORE_SEQUENCES_SQL, [JSON.stringify(state.sequences)]);
143
+ }
144
+ for (const view of state.materializedViews) {
145
+ await tx.exec(`REFRESH MATERIALIZED VIEW ${view}`);
146
+ }
147
+ });
148
+ return true;
149
+ }
150
+ catch {
151
+ // Deliberately not reported: a database a test broke is replaced by a fresh copy, which is what the test expects.
152
+ return false;
153
+ }
154
+ }
155
+ //# sourceMappingURL=reuse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reuse.js","sourceRoot":"","sources":["../../src/testing/reuse.ts"],"names":[],"mappings":"AA0CA,MAAM,KAAK,GAAG,IAAI,GAAG,EAAgB,CAAC;AAEtC,MAAM,cAAc,GAAG,mIAAmI,CAAC;AAE3J,wHAAwH;AACxH,MAAM,eAAe,GAAG;;;;;0EAKkD,cAAc;;;yEAGf,cAAc;;;iFAGN,cAAc;;;;;kHAKmB,cAAc;UACtH,CAAC;AAEX,MAAM,UAAU,GAAG;;;;;yCAKsB,cAAc;WAC5C,CAAC;AAEZ,MAAM,aAAa,GAAG;;+EAEyD,CAAC;AAEhF,MAAM,qBAAqB,GAAG,mIAAmI,CAAC;AAElK;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,GAAW,EAAE,IAAmC;IAC1F,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAC1B,KAAK,IAAI,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,IAAI,KAAK,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;QAC5E,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,IAAI,CAAC,MAAM,UAAU,CAAC,IAAI,CAAC,MAAM,EAAE,MAAM,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;YAClF,OAAO,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QAC1B,CAAC;QACD,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC;IACrB,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,IAAI,EAAE,CAAC;IAC3B,IAAI,CAAC;QACH,IAAI,CAAC,KAAK,KAAK,oBAAoB,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QAClD,MAAM,IAAI,CAAC,KAAK,CAAC;IACnB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC,KAAK,GAAG,SAAS,CAAC;QACvB,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC;QACpB,MAAM,KAAK,CAAC;IACd,CAAC;IACD,OAAO,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AAC3B,CAAC;AAED,oHAAoH;AACpH,MAAM,CAAC,KAAK,UAAU,oBAAoB;IACxC,MAAM,OAAO,GAAG,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;IACpC,KAAK,CAAC,KAAK,EAAE,CAAC;IACd,KAAK,MAAM,IAAI,IAAI,OAAO;QAAE,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;IAClD,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;IAC5D,MAAM,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;AAC9D,CAAC;AAED,SAAS,OAAO,CAAC,GAAW;IAC1B,IAAI,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC1B,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,IAAI,GAAG,EAAE,IAAI,EAAE,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;QACxD,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IACvB,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,IAAI,CAAC,IAAU,EAAE,QAAwB;IAChD,IAAI,UAAU,GAAG,KAAK,CAAC;IACvB,OAAO;QACL,EAAE,EAAE,QAAQ,CAAC,EAAE;QACf,MAAM,EAAE,QAAQ,CAAC,MAAM;QACvB,KAAK,EAAE,GAAG,EAAE;YACV,IAAI,UAAU,EAAE,CAAC;gBACf,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;YAC3B,CAAC;YACD,UAAU,GAAG,IAAI,CAAC;YAClB,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;gBACnB,OAAO,QAAQ,CAAC,KAAK,EAAE,CAAC;YAC1B,CAAC;YACD,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YACzB,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;QAC3B,CAAC;KACF,CAAC;AACJ,CAAC;AAED,KAAK,UAAU,oBAAoB,CAAC,MAAc;IAChD,MAAM,WAAW,GAAG,MAAM,eAAe,CAAC,MAAM,CAAC,CAAC;IAClD,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,KAAK,CAA6E,UAAU,CAAC,CAAC;IAC7H,MAAM,KAAK,GAAgB,EAAE,CAAC;IAC9B,KAAK,MAAM,QAAQ,IAAI,SAAS,CAAC,IAAI,EAAE,CAAC;QACtC,IAAI,QAAQ,CAAC,IAAI,KAAK,GAAG,IAAI,QAAQ,CAAC,OAAO,KAAK,IAAI,EAAE,CAAC;YACvD,SAAS;QACX,CAAC;QACD,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,KAAK,CAAwB,gCAAgC,QAAQ,CAAC,IAAI,eAAe,CAAC,CAAC;QACxH,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,EAAE,CAAC;YACvC,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,QAAQ,QAAQ,CAAC,IAAI,KAAK,QAAQ,CAAC,OAAO,kBAAkB,CAAC,CAAC;YAChG,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;gBAC9B,MAAM,IAAI,KAAK,CAAC,oDAAoD,QAAQ,CAAC,IAAI,mBAAmB,CAAC,CAAC;YACxG,CAAC;YACD,KAAK,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;QACrF,CAAC;IACH,CAAC;IACD,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,KAAK,CAAgB,aAAa,CAAC,CAAC;IACnE,OAAO;QACL,WAAW;QACX,MAAM,EAAE,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,KAAK,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC;QACnG,KAAK;QACL,SAAS,EAAE,SAAS,CAAC,IAAI;QACzB,iBAAiB,EAAE,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,KAAK,GAAG,IAAI,QAAQ,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC;KACrI,CAAC;AACJ,CAAC;AAED,KAAK,UAAU,eAAe,CAAC,MAAc;IAC3C,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,KAAK,CAA0B,eAAe,CAAC,CAAC;IAC5E,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,WAAW,IAAI,EAAE,CAAC;AAC3C,CAAC;AAED;;;GAGG;AACH,KAAK,UAAU,UAAU,CAAC,MAAc,EAAE,KAAoB;IAC5D,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;QACjC,IAAI,CAAC,MAAM,eAAe,CAAC,MAAM,CAAC,CAAC,KAAK,KAAK,CAAC,WAAW,EAAE,CAAC;YAC1D,OAAO,KAAK,CAAC;QACf,CAAC;QACD,MAAM,MAAM,CAAC,WAAW,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;YACpC,+EAA+E;YAC/E,MAAM,EAAE,CAAC,IAAI,CAAC,8CAA8C,CAAC,CAAC;YAC9D,IAAI,KAAK,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBAC5B,MAAM,EAAE,CAAC,IAAI,CAAC,YAAY,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;YAC/D,CAAC;YACD,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;gBAC/B,MAAM,EAAE,CAAC,KAAK,CAAC,QAAQ,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC,OAAO,oBAAoB,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;YACnG,CAAC;YACD,IAAI,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBAC/B,MAAM,EAAE,CAAC,KAAK,CAAC,qBAAqB,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;YAC3E,CAAC;YACD,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,iBAAiB,EAAE,CAAC;gBAC3C,MAAM,EAAE,CAAC,IAAI,CAAC,6BAA6B,IAAI,EAAE,CAAC,CAAC;YACrD,CAAC;QACH,CAAC,CAAC,CAAC;QACH,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,kHAAkH;QAClH,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC"}
@@ -0,0 +1,23 @@
1
+ import type { AnySoftureModule } from "@softure-ai/core";
2
+ import type { AppMigrations } from "../migrations/migrator.js";
3
+ /** The variable naming the run's template folder. Set by the global setup; an app may set it itself. */
4
+ export declare const TEMPLATE_DIR_VARIABLE = "SOFTURE_DB_TEST_TEMPLATES";
5
+ export interface TemplateSource {
6
+ readonly modules: readonly AnySoftureModule[];
7
+ readonly app: AppMigrations;
8
+ readonly templateKey: string | undefined;
9
+ }
10
+ export declare function getTemplateDir(): string | null;
11
+ /**
12
+ * The name of a template on disk, or `null` when it cannot be identified across processes: an app hook other than
13
+ * `drizzleAppMigrations` without a `templateKey`.
14
+ */
15
+ export declare function computeTemplateName(source: TemplateSource): Promise<string | null>;
16
+ /** The stored template, or `null` when this run has not written it yet. */
17
+ export declare function readStoredTemplate(dir: string, name: string): Promise<Blob | null>;
18
+ /**
19
+ * Writes the template under a temporary name and renames it, so a worker reading at the same time sees the whole
20
+ * file or none. Two workers building the same template both write it; the second rename wins with identical bytes.
21
+ */
22
+ export declare function storeTemplate(dir: string, name: string, template: Blob): Promise<void>;
23
+ //# sourceMappingURL=template-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"template-store.d.ts","sourceRoot":"","sources":["../../src/testing/template-store.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD,OAAO,KAAK,EAAoB,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAEjF,wGAAwG;AACxG,eAAO,MAAM,qBAAqB,8BAA8B,CAAC;AAKjE,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAC9C,QAAQ,CAAC,GAAG,EAAE,aAAa,CAAC;IAC5B,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CAC1C;AAED,wBAAgB,cAAc,IAAI,MAAM,GAAG,IAAI,CAG9C;AAED;;;GAGG;AACH,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiBxF;AAED,2EAA2E;AAC3E,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,CASxF;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAS5F"}
@@ -0,0 +1,103 @@
1
+ // Migrated test templates on disk, shared by every test file and worker of one run (issue #641). The global setup
2
+ // (`@softure-ai/db/testing/global-setup`) gives the run a folder through an environment variable; without it nothing
3
+ // is written and each file migrates its own template, as before. A template is named by a hash of what it is built
4
+ // from (the module and drizzle files, the hooks' `templateKey`, the baseline), never by object identity, which means
5
+ // nothing in another process.
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { readdir, readFile, rename, writeFile } from "node:fs/promises";
8
+ import { join, relative } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { getDrizzleFolder, getDrizzleLedger } from "../migrations/drizzle.js";
11
+ /** The variable naming the run's template folder. Set by the global setup; an app may set it itself. */
12
+ export const TEMPLATE_DIR_VARIABLE = "SOFTURE_DB_TEST_TEMPLATES";
13
+ // Bumped when the stored format or the key changes, so a folder an app keeps across runs is not misread.
14
+ const STORE_VERSION = "1";
15
+ export function getTemplateDir() {
16
+ const dir = process.env[TEMPLATE_DIR_VARIABLE];
17
+ return dir === undefined || dir === "" ? null : dir;
18
+ }
19
+ /**
20
+ * The name of a template on disk, or `null` when it cannot be identified across processes: an app hook other than
21
+ * `drizzleAppMigrations` without a `templateKey`.
22
+ */
23
+ export async function computeTemplateName(source) {
24
+ const parts = [`store:${STORE_VERSION}`];
25
+ for (const module of source.modules) {
26
+ const dir = module.migrations === null || module.migrations === undefined ? "-" : await hashFolder(fileURLToPath(module.migrations.dir));
27
+ parts.push(`module:${module.id}:${dir}`);
28
+ }
29
+ for (const [phase, hook] of [["before", source.app.before], ["after", source.app.after]]) {
30
+ const identity = await identifyHook(hook, source.templateKey);
31
+ if (identity === null) {
32
+ return null;
33
+ }
34
+ parts.push(`${phase}:${identity}`);
35
+ }
36
+ parts.push(`templateKey:${source.templateKey ?? "-"}`);
37
+ const baseline = Object.entries(source.app.baseline ?? {}).sort(([left], [right]) => left.localeCompare(right));
38
+ parts.push(`baseline:${baseline.map(([id, through]) => `${id}=${through}`).join(",")}`);
39
+ return createHash("sha256").update(parts.join("\n")).digest("hex");
40
+ }
41
+ /** The stored template, or `null` when this run has not written it yet. */
42
+ export async function readStoredTemplate(dir, name) {
43
+ try {
44
+ return new Blob([await readFile(join(dir, `${name}.tar`))]);
45
+ }
46
+ catch (error) {
47
+ if (isMissingFile(error)) {
48
+ return null;
49
+ }
50
+ throw new Error(`createTestDatabase: reading the stored template ${name} in ${dir} failed`, { cause: error });
51
+ }
52
+ }
53
+ /**
54
+ * Writes the template under a temporary name and renames it, so a worker reading at the same time sees the whole
55
+ * file or none. Two workers building the same template both write it; the second rename wins with identical bytes.
56
+ */
57
+ export async function storeTemplate(dir, name, template) {
58
+ const path = join(dir, `${name}.tar`);
59
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
60
+ try {
61
+ await writeFile(temporary, new Uint8Array(await template.arrayBuffer()));
62
+ await rename(temporary, path);
63
+ }
64
+ catch (error) {
65
+ throw new Error(`createTestDatabase: storing the template ${name} in ${dir} failed`, { cause: error });
66
+ }
67
+ }
68
+ async function identifyHook(hook, templateKey) {
69
+ if (hook === undefined) {
70
+ return "-";
71
+ }
72
+ const folder = getDrizzleFolder(hook);
73
+ if (folder !== undefined) {
74
+ return `drizzle:${getDrizzleLedger(hook) ?? "-"}:${await hashFolder(folder)}`;
75
+ }
76
+ // A custom hook is a function: only the app can say which migrations it runs.
77
+ return templateKey === undefined ? null : "custom";
78
+ }
79
+ // Every file under the folder, by relative path and content. A missing folder hashes as such: `migrate` reports it.
80
+ async function hashFolder(folder) {
81
+ const hash = createHash("sha256");
82
+ let entries;
83
+ try {
84
+ entries = await readdir(folder, { recursive: true, withFileTypes: true });
85
+ }
86
+ catch (error) {
87
+ if (isMissingFile(error)) {
88
+ return "missing";
89
+ }
90
+ throw new Error(`createTestDatabase: reading the migrations folder ${folder} failed`, { cause: error });
91
+ }
92
+ const files = entries.filter((entry) => entry.isFile()).map((entry) => join(entry.parentPath, entry.name)).sort();
93
+ for (const file of files) {
94
+ hash.update(`${relative(folder, file)}\0`);
95
+ hash.update(await readFile(file));
96
+ hash.update("\0");
97
+ }
98
+ return hash.digest("hex");
99
+ }
100
+ function isMissingFile(error) {
101
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
102
+ }
103
+ //# sourceMappingURL=template-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"template-store.js","sourceRoot":"","sources":["../../src/testing/template-store.ts"],"names":[],"mappings":"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"}
package/dist/testing.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { AnySoftureModule } from "@softure-ai/core";
3
3
  import { type PgliteClientDatabase } from "./client.js";
4
4
  import { type AppMigrations } from "./migrations/migrator.js";
5
5
  export { DEFAULT_DATABASE_OPENERS, expectEntriesOpenDatabase, findEntriesReachingDatabase, type EntryGuardOptions, type EntryReach, } from "./entry-guard.js";
6
+ export { TEMPLATE_DIR_VARIABLE } from "./testing/template-store.js";
6
7
  export interface TestDatabase {
7
8
  readonly db: PgliteClientDatabase;
8
9
  readonly client: PGlite;
@@ -14,6 +15,18 @@ export interface CreateTestDatabaseOptions {
14
15
  * cached per hook object: define the hooks once, at module level, not inline per call.
15
16
  */
16
17
  readonly app?: AppMigrations;
18
+ /**
19
+ * Names what the app hooks migrate, for the run-wide template folder of the global setup. Needed only for a hook
20
+ * other than `drizzleAppMigrations` (whose files are read); without it such a template is kept per test file.
21
+ * Change it when those migrations change, e.g. include a version.
22
+ */
23
+ readonly templateKey?: string;
24
+ /**
25
+ * `true`: a database a test closed is reset to the template and handed out again (a few milliseconds instead of a
26
+ * new copy). Rows, sequences and session state are reset; a test that changes the schema gets a fresh copy next
27
+ * time. Two open databases never share an instance.
28
+ */
29
+ readonly reuse?: boolean;
17
30
  }
18
31
  /**
19
32
  * A fresh, migrated, in-memory database per call. List the module under test and the modules it
@@ -21,4 +34,6 @@ export interface CreateTestDatabaseOptions {
21
34
  * module package or the app's migrations.
22
35
  */
23
36
  export declare function createTestDatabase(modules?: readonly AnySoftureModule[], options?: CreateTestDatabaseOptions): Promise<TestDatabase>;
37
+ /** Closes the idle databases `reuse` keeps, so a test file can free them in `afterAll`. */
38
+ export declare function closeReusedTestDatabases(): Promise<void>;
24
39
  //# sourceMappingURL=testing.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,EAAsB,KAAK,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,EAAkC,KAAK,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAG9F,OAAO,EACL,wBAAwB,EACxB,yBAAyB,EACzB,2BAA2B,EAC3B,KAAK,iBAAiB,EACtB,KAAK,UAAU,GAChB,MAAM,kBAAkB,CAAC;AAE1B,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,EAAE,oBAAoB,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED,MAAM,WAAW,yBAAyB;IACxC;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,aAAa,CAAC;CAC9B;AAMD;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,GAAE,SAAS,gBAAgB,EAAO,EAAE,OAAO,GAAE,yBAA8B,GAAG,OAAO,CAAC,YAAY,CAAC,CAIlJ"}
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,EAAsB,KAAK,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,EAAkC,KAAK,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAK9F,OAAO,EACL,wBAAwB,EACxB,yBAAyB,EACzB,2BAA2B,EAC3B,KAAK,iBAAiB,EACtB,KAAK,UAAU,GAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,qBAAqB,EAAE,MAAM,6BAA6B,CAAC;AAEpE,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,EAAE,oBAAoB,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED,MAAM,WAAW,yBAAyB;IACxC;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,aAAa,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;CAC1B;AAMD;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,GAAE,SAAS,gBAAgB,EAAO,EAAE,OAAO,GAAE,yBAA8B,GAAG,OAAO,CAAC,YAAY,CAAC,CASlJ;AAED,2FAA2F;AAC3F,wBAAgB,wBAAwB,IAAI,OAAO,CAAC,IAAI,CAAC,CAExD"}
package/dist/testing.js CHANGED
@@ -1,14 +1,19 @@
1
1
  // Test databases for module unit tests (docs/02-module-standard.md §10): a real Postgres (PGlite)
2
- // with the real migrations of the given modules. Migrating costs about two seconds, so each
3
- // module set is migrated once per test worker into a template, and every call starts from a copy
4
- // of the template's data folder (FIRE_TRACKER `src/db/test-db.ts`). An app with its own migrations
5
- // passes the same `app` hooks (and baseline) as to `migrate`, so module tables can reference app
6
- // tables and a module the app's history already creates is adopted in the template.
2
+ // with the real migrations of the given modules. Migrating costs seconds, so each module set is
3
+ // migrated once into a template, and every call starts from a copy of the template's data folder
4
+ // (FIRE_TRACKER `src/db/test-db.ts`). The template is kept per test file, and per run when the
5
+ // global setup gives the run a folder (testing/template-store.ts). `reuse` hands back a used
6
+ // database reset to the template instead of a new copy (testing/reuse.ts). An app with its own
7
+ // migrations passes the same `app` hooks (and baseline) as to `migrate`, so module tables can
8
+ // reference app tables and a module the app's history already creates is adopted in the template.
7
9
  import { PGlite } from "@electric-sql/pglite";
8
10
  import { createPgliteHandle } from "./client.js";
9
11
  import { migrate } from "./migrations/migrator.js";
10
12
  import { describeProblem } from "./migrations/problems.js";
13
+ import { acquirePooledDatabase, closePooledDatabases } from "./testing/reuse.js";
14
+ import { computeTemplateName, getTemplateDir, readStoredTemplate, storeTemplate } from "./testing/template-store.js";
11
15
  export { DEFAULT_DATABASE_OPENERS, expectEntriesOpenDatabase, findEntriesReachingDatabase, } from "./entry-guard.js";
16
+ export { TEMPLATE_DIR_VARIABLE } from "./testing/template-store.js";
12
17
  const templates = new Map();
13
18
  const hookIds = new WeakMap();
14
19
  let nextHookId = 1;
@@ -18,17 +23,28 @@ let nextHookId = 1;
18
23
  * module package or the app's migrations.
19
24
  */
20
25
  export async function createTestDatabase(modules = [], options = {}) {
21
- const client = new PGlite({ loadDataDir: await getTemplate(modules, options.app ?? {}) });
22
- const handle = await createPgliteHandle(client);
23
- return { db: handle.db, client, close: handle.close };
26
+ const app = options.app ?? {};
27
+ const key = computeTemplateKey(modules, app, options.templateKey);
28
+ const openCopy = async () => {
29
+ const client = new PGlite({ loadDataDir: await getTemplate(key, modules, app, options.templateKey) });
30
+ const handle = await createPgliteHandle(client);
31
+ return { db: handle.db, client, close: handle.close };
32
+ };
33
+ return options.reuse === true ? acquirePooledDatabase(key, openCopy) : openCopy();
24
34
  }
25
- function getTemplate(modules, app) {
35
+ /** Closes the idle databases `reuse` keeps, so a test file can free them in `afterAll`. */
36
+ export function closeReusedTestDatabases() {
37
+ return closePooledDatabases();
38
+ }
39
+ function computeTemplateKey(modules, app, templateKey) {
26
40
  const moduleKey = modules.map((module) => `${module.id}@${module.migrations?.dir.href ?? "-"}`).join("|");
27
41
  const baselineKey = Object.entries(app.baseline ?? {}).sort(([left], [right]) => left.localeCompare(right)).map(([id, through]) => `${id}:${through}`).join(",");
28
- const key = `${moduleKey}#before:${identifyHook(app.before)}#after:${identifyHook(app.after)}#baseline:${baselineKey}`;
42
+ return `${moduleKey}#before:${identifyHook(app.before)}#after:${identifyHook(app.after)}#baseline:${baselineKey}#key:${templateKey ?? "-"}`;
43
+ }
44
+ function getTemplate(key, modules, app, templateKey) {
29
45
  let template = templates.get(key);
30
46
  if (template === undefined) {
31
- template = buildTemplate(modules, app);
47
+ template = loadOrBuildTemplate(modules, app, templateKey);
32
48
  templates.set(key, template);
33
49
  // A failed build is not cached: the next call reports the problem again.
34
50
  template.catch(() => templates.delete(key));
@@ -47,6 +63,21 @@ function identifyHook(hook) {
47
63
  }
48
64
  return String(id);
49
65
  }
66
+ // From the run's folder when another file already built it; otherwise built here and stored for the others.
67
+ async function loadOrBuildTemplate(modules, app, templateKey) {
68
+ const dir = getTemplateDir();
69
+ const name = dir === null ? null : await computeTemplateName({ modules, app, templateKey });
70
+ if (dir === null || name === null) {
71
+ return buildTemplate(modules, app);
72
+ }
73
+ const stored = await readStoredTemplate(dir, name);
74
+ if (stored !== null) {
75
+ return stored;
76
+ }
77
+ const built = await buildTemplate(modules, app);
78
+ await storeTemplate(dir, name, built);
79
+ return built;
80
+ }
50
81
  async function buildTemplate(modules, app) {
51
82
  const handle = await createPgliteHandle(new PGlite());
52
83
  try {
@@ -1 +1 @@
1
- {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softure-ai/db",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "SOFTURE AI database layer: pg/PGlite client, per-module migrator with plan and adopt, PGlite test databases.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,6 +38,11 @@
38
38
  "types": "./dist/testing.d.ts",
39
39
  "default": "./dist/testing.js"
40
40
  },
41
+ "./testing/global-setup": {
42
+ "@softure-ai/source": "./src/testing/global-setup.ts",
43
+ "types": "./dist/testing/global-setup.d.ts",
44
+ "default": "./dist/testing/global-setup.js"
45
+ },
41
46
  "./cli": {
42
47
  "@softure-ai/source": "./src/cli/index.ts",
43
48
  "types": "./dist/cli/index.d.ts",
@@ -1,5 +1,6 @@
1
1
  // The app's own drizzle-kit migrations as an app migration hook: drizzle's migrator for the handle's driver,
2
2
  // loaded only when the hook runs, so an app ships just its own driver.
3
+ import { resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import type { AppMigrationHook } from "./migrator.js";
5
6
 
@@ -13,8 +14,14 @@ export interface DrizzleAppMigrationsOptions {
13
14
  const DEFAULT_TABLE = "__drizzle_migrations";
14
15
  const DEFAULT_SCHEMA = "drizzle";
15
16
 
16
- // The ledger each hook writes to, so `migrate` can refuse a `before` and an `after` sharing one (issue #534).
17
- const ledgers = new WeakMap<AppMigrationHook, string>();
17
+ interface DrizzleHookSource {
18
+ /** `schema.table`, so `migrate` can refuse a `before` and an `after` sharing one (issue #534). */
19
+ readonly ledger: string;
20
+ /** The absolute folder, so a test template can be identified by its files across processes (issue #641). */
21
+ readonly folder: string;
22
+ }
23
+
24
+ const sources = new WeakMap<AppMigrationHook, DrizzleHookSource>();
18
25
 
19
26
  /**
20
27
  * A hook for `app.before` (or `app.after`) that applies the drizzle-kit folder (`meta/_journal.json` and its SQL
@@ -39,11 +46,16 @@ export function drizzleAppMigrations(folder: string | URL, options: DrizzleAppMi
39
46
  const { migrate } = await import("drizzle-orm/pglite/migrator");
40
47
  await migrate(handle.db, config);
41
48
  };
42
- ledgers.set(hook, `${config.migrationsSchema}.${config.migrationsTable}`);
49
+ sources.set(hook, { ledger: `${config.migrationsSchema}.${config.migrationsTable}`, folder: resolve(migrationsFolder) });
43
50
  return hook;
44
51
  }
45
52
 
46
53
  /** The ledger (`schema.table`) a `drizzleAppMigrations` hook writes to; `undefined` for any other hook. */
47
54
  export function getDrizzleLedger(hook: AppMigrationHook): string | undefined {
48
- return ledgers.get(hook);
55
+ return sources.get(hook)?.ledger;
56
+ }
57
+
58
+ /** The absolute folder a `drizzleAppMigrations` hook applies; `undefined` for any other hook. */
59
+ export function getDrizzleFolder(hook: AppMigrationHook): string | undefined {
60
+ return sources.get(hook)?.folder;
49
61
  }
@@ -0,0 +1,21 @@
1
+ // A Vitest `globalSetup` module (issue #641): `test: { globalSetup: ["@softure-ai/db/testing/global-setup"] }`.
2
+ // It runs once in the main process before any worker starts and gives the run a template folder; workers inherit the
3
+ // variable, so `createTestDatabase` migrates each module set once per run instead of once per test file. The folder
4
+ // is removed when the run ends. A folder the app named itself (the variable already set) is kept.
5
+ import { mkdtemp, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { TEMPLATE_DIR_VARIABLE } from "./template-store.js";
9
+
10
+ export default async function setup(): Promise<() => Promise<void>> {
11
+ const given = process.env[TEMPLATE_DIR_VARIABLE];
12
+ if (given !== undefined && given !== "") {
13
+ return () => Promise.resolve();
14
+ }
15
+ const dir = await mkdtemp(join(tmpdir(), "softure-db-templates-"));
16
+ process.env[TEMPLATE_DIR_VARIABLE] = dir;
17
+ return async () => {
18
+ delete process.env[TEMPLATE_DIR_VARIABLE];
19
+ await rm(dir, { recursive: true, force: true });
20
+ };
21
+ }
@@ -0,0 +1,205 @@
1
+ // Reusable test databases (`createTestDatabase(modules, { reuse: true })`, issue #641). Booting a PGlite from the
2
+ // template costs ~0.6 s; putting a used one back costs a few milliseconds. A database closed by a test goes back to a
3
+ // pool per template; the next call takes it and restores the template's state: every table emptied and its template
4
+ // rows copied back (migration ledgers and seed rows included), sequences set to the template's values, materialized
5
+ // views refreshed, session state discarded. A database whose schema the test changed (a table, column, function,
6
+ // trigger, constraint or enum value) is closed and replaced by a fresh copy, since rows are all a reset can undo.
7
+ // The pool is module state: one test file under Vitest's default isolation; `closeReusedTestDatabases` empties it.
8
+ import type { PGlite } from "@electric-sql/pglite";
9
+ import type { PgliteClientDatabase } from "../client.js";
10
+
11
+ export interface PooledDatabase {
12
+ readonly db: PgliteClientDatabase;
13
+ readonly client: PGlite;
14
+ readonly close: () => Promise<void>;
15
+ }
16
+
17
+ interface SeedTable {
18
+ readonly table: string;
19
+ readonly columns: string;
20
+ readonly rows: Blob;
21
+ }
22
+
23
+ interface SequenceValue {
24
+ readonly name: string;
25
+ readonly value: string;
26
+ readonly called: boolean;
27
+ }
28
+
29
+ interface TemplateState {
30
+ readonly fingerprint: string;
31
+ readonly tables: readonly string[];
32
+ readonly seeds: readonly SeedTable[];
33
+ readonly sequences: readonly SequenceValue[];
34
+ readonly materializedViews: readonly string[];
35
+ }
36
+
37
+ interface Pool {
38
+ readonly idle: PooledDatabase[];
39
+ state: Promise<TemplateState> | undefined;
40
+ isRetired: boolean;
41
+ }
42
+
43
+ const pools = new Map<string, Pool>();
44
+
45
+ const USER_NAMESPACE = "n.nspname not in ('pg_catalog', 'information_schema') and n.nspname not like 'pg\\_toast%' and n.nspname not like 'pg\\_temp\\_%'";
46
+
47
+ // What a reset cannot undo. Indexes, constraints and triggers are listed with their table, enum labels with their type.
48
+ const FINGERPRINT_SQL = `
49
+ select coalesce(string_agg(entry, E'\\n' order by entry), '') as fingerprint from (
50
+ select format('rel %s %I.%I %s', c.relkind, n.nspname, c.relname,
51
+ (select string_agg(format('%I:%s', a.attname, format_type(a.atttypid, a.atttypmod)), ',' order by a.attnum)
52
+ from pg_attribute a where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped)) as entry
53
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace where ${USER_NAMESPACE}
54
+ union all
55
+ select format('fn %I.%I(%s)', n.nspname, p.proname, pg_get_function_identity_arguments(p.oid))
56
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace where ${USER_NAMESPACE}
57
+ union all
58
+ select format('con %s %s %s', co.conrelid::regclass, co.conname, co.contype)
59
+ from pg_constraint co join pg_namespace n on n.oid = co.connamespace where ${USER_NAMESPACE}
60
+ union all
61
+ select format('trg %s %s %s', t.tgrelid::regclass, t.tgname, t.tgenabled) from pg_trigger t where not t.tgisinternal
62
+ union all
63
+ select format('enum %s %s', e.enumtypid::regtype, e.enumlabel)
64
+ from pg_enum e join pg_type ty on ty.oid = e.enumtypid join pg_namespace n on n.oid = ty.typnamespace where ${USER_NAMESPACE}
65
+ ) entries`;
66
+
67
+ const TABLES_SQL = `
68
+ select format('%I.%I', n.nspname, c.relname) as name, c.relkind::text as kind, c.relispopulated as populated,
69
+ (select string_agg(format('%I', a.attname), ', ' order by a.attnum) from pg_attribute a
70
+ where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped and a.attgenerated = '') as columns
71
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
72
+ where c.relkind in ('r', 'p', 'm') and ${USER_NAMESPACE}
73
+ order by 1`;
74
+
75
+ const SEQUENCES_SQL = `
76
+ select format('%I.%I', schemaname, sequencename) as name, coalesce(last_value, start_value)::text as value, last_value is not null as called
77
+ from pg_sequences where schemaname not in ('pg_catalog', 'information_schema')`;
78
+
79
+ const RESTORE_SEQUENCES_SQL = "select setval(s.name::regclass, s.value, s.called) from json_to_recordset($1::json) as s(name text, value bigint, called boolean)";
80
+
81
+ /**
82
+ * A database from the pool of `key`, reset to the template's state, or a fresh copy from `open` when none is idle.
83
+ * Its `close` puts it back; a second `close` does nothing.
84
+ */
85
+ export async function acquirePooledDatabase(key: string, open: () => Promise<PooledDatabase>): Promise<PooledDatabase> {
86
+ const pool = getPool(key);
87
+ for (let idle = pool.idle.pop(); idle !== undefined; idle = pool.idle.pop()) {
88
+ if (pool.state !== undefined && (await tryRestore(idle.client, await pool.state))) {
89
+ return lend(pool, idle);
90
+ }
91
+ await idle.close();
92
+ }
93
+ const fresh = await open();
94
+ try {
95
+ pool.state ??= captureTemplateState(fresh.client);
96
+ await pool.state;
97
+ } catch (error) {
98
+ pool.state = undefined;
99
+ await fresh.close();
100
+ throw error;
101
+ }
102
+ return lend(pool, fresh);
103
+ }
104
+
105
+ /** Closes every idle database of every pool; for `afterAll`. A database still held is closed when it comes back. */
106
+ export async function closePooledDatabases(): Promise<void> {
107
+ const retired = [...pools.values()];
108
+ pools.clear();
109
+ for (const pool of retired) pool.isRetired = true;
110
+ const idle = retired.flatMap((pool) => pool.idle.splice(0));
111
+ await Promise.all(idle.map((database) => database.close()));
112
+ }
113
+
114
+ function getPool(key: string): Pool {
115
+ let pool = pools.get(key);
116
+ if (pool === undefined) {
117
+ pool = { idle: [], state: undefined, isRetired: false };
118
+ pools.set(key, pool);
119
+ }
120
+ return pool;
121
+ }
122
+
123
+ function lend(pool: Pool, database: PooledDatabase): PooledDatabase {
124
+ let isReturned = false;
125
+ return {
126
+ db: database.db,
127
+ client: database.client,
128
+ close: () => {
129
+ if (isReturned) {
130
+ return Promise.resolve();
131
+ }
132
+ isReturned = true;
133
+ if (pool.isRetired) {
134
+ return database.close();
135
+ }
136
+ pool.idle.push(database);
137
+ return Promise.resolve();
138
+ },
139
+ };
140
+ }
141
+
142
+ async function captureTemplateState(client: PGlite): Promise<TemplateState> {
143
+ const fingerprint = await readFingerprint(client);
144
+ const relations = await client.query<{ name: string; kind: string; populated: boolean; columns: string | null }>(TABLES_SQL);
145
+ const seeds: SeedTable[] = [];
146
+ for (const relation of relations.rows) {
147
+ if (relation.kind !== "r" || relation.columns === null) {
148
+ continue;
149
+ }
150
+ const hasRows = await client.query<{ has_rows: boolean }>(`select exists (select 1 from ${relation.name}) as has_rows`);
151
+ if (hasRows.rows[0]?.has_rows === true) {
152
+ const copied = await client.query(`copy ${relation.name} (${relation.columns}) to '/dev/blob'`);
153
+ if (copied.blob === undefined) {
154
+ throw new Error(`createTestDatabase: copying the template rows of ${relation.name} returned no data`);
155
+ }
156
+ seeds.push({ table: relation.name, columns: relation.columns, rows: copied.blob });
157
+ }
158
+ }
159
+ const sequences = await client.query<SequenceValue>(SEQUENCES_SQL);
160
+ return {
161
+ fingerprint,
162
+ tables: relations.rows.filter((relation) => relation.kind !== "m").map((relation) => relation.name),
163
+ seeds,
164
+ sequences: sequences.rows,
165
+ materializedViews: relations.rows.filter((relation) => relation.kind === "m" && relation.populated).map((relation) => relation.name),
166
+ };
167
+ }
168
+
169
+ async function readFingerprint(client: PGlite): Promise<string> {
170
+ const result = await client.query<{ fingerprint: string }>(FINGERPRINT_SQL);
171
+ return result.rows[0]?.fingerprint ?? "";
172
+ }
173
+
174
+ /**
175
+ * Restores the template's rows and session state. `false` when the database cannot be reset (its schema changed, or
176
+ * the test left it in a state a reset fails on, such as an open transaction): the caller replaces it.
177
+ */
178
+ async function tryRestore(client: PGlite, state: TemplateState): Promise<boolean> {
179
+ try {
180
+ await client.exec("DISCARD ALL");
181
+ if ((await readFingerprint(client)) !== state.fingerprint) {
182
+ return false;
183
+ }
184
+ await client.transaction(async (tx) => {
185
+ // Rows go back as they were, so foreign keys and triggers stay out of the way.
186
+ await tx.exec("SET LOCAL session_replication_role = replica");
187
+ if (state.tables.length > 0) {
188
+ await tx.exec(`TRUNCATE ${state.tables.join(", ")} CASCADE`);
189
+ }
190
+ for (const seed of state.seeds) {
191
+ await tx.query(`copy ${seed.table} (${seed.columns}) from '/dev/blob'`, [], { blob: seed.rows });
192
+ }
193
+ if (state.sequences.length > 0) {
194
+ await tx.query(RESTORE_SEQUENCES_SQL, [JSON.stringify(state.sequences)]);
195
+ }
196
+ for (const view of state.materializedViews) {
197
+ await tx.exec(`REFRESH MATERIALIZED VIEW ${view}`);
198
+ }
199
+ });
200
+ return true;
201
+ } catch {
202
+ // Deliberately not reported: a database a test broke is replaced by a fresh copy, which is what the test expects.
203
+ return false;
204
+ }
205
+ }
@@ -0,0 +1,116 @@
1
+ // Migrated test templates on disk, shared by every test file and worker of one run (issue #641). The global setup
2
+ // (`@softure-ai/db/testing/global-setup`) gives the run a folder through an environment variable; without it nothing
3
+ // is written and each file migrates its own template, as before. A template is named by a hash of what it is built
4
+ // from (the module and drizzle files, the hooks' `templateKey`, the baseline), never by object identity, which means
5
+ // nothing in another process.
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { readdir, readFile, rename, writeFile } from "node:fs/promises";
8
+ import { join, relative } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import type { AnySoftureModule } from "@softure-ai/core";
11
+ import { getDrizzleFolder, getDrizzleLedger } from "../migrations/drizzle.js";
12
+ import type { AppMigrationHook, AppMigrations } from "../migrations/migrator.js";
13
+
14
+ /** The variable naming the run's template folder. Set by the global setup; an app may set it itself. */
15
+ export const TEMPLATE_DIR_VARIABLE = "SOFTURE_DB_TEST_TEMPLATES";
16
+
17
+ // Bumped when the stored format or the key changes, so a folder an app keeps across runs is not misread.
18
+ const STORE_VERSION = "1";
19
+
20
+ export interface TemplateSource {
21
+ readonly modules: readonly AnySoftureModule[];
22
+ readonly app: AppMigrations;
23
+ readonly templateKey: string | undefined;
24
+ }
25
+
26
+ export function getTemplateDir(): string | null {
27
+ const dir = process.env[TEMPLATE_DIR_VARIABLE];
28
+ return dir === undefined || dir === "" ? null : dir;
29
+ }
30
+
31
+ /**
32
+ * The name of a template on disk, or `null` when it cannot be identified across processes: an app hook other than
33
+ * `drizzleAppMigrations` without a `templateKey`.
34
+ */
35
+ export async function computeTemplateName(source: TemplateSource): Promise<string | null> {
36
+ const parts = [`store:${STORE_VERSION}`];
37
+ for (const module of source.modules) {
38
+ const dir = module.migrations === null || module.migrations === undefined ? "-" : await hashFolder(fileURLToPath(module.migrations.dir));
39
+ parts.push(`module:${module.id}:${dir}`);
40
+ }
41
+ for (const [phase, hook] of [["before", source.app.before], ["after", source.app.after]] as const) {
42
+ const identity = await identifyHook(hook, source.templateKey);
43
+ if (identity === null) {
44
+ return null;
45
+ }
46
+ parts.push(`${phase}:${identity}`);
47
+ }
48
+ parts.push(`templateKey:${source.templateKey ?? "-"}`);
49
+ const baseline = Object.entries(source.app.baseline ?? {}).sort(([left], [right]) => left.localeCompare(right));
50
+ parts.push(`baseline:${baseline.map(([id, through]) => `${id}=${through}`).join(",")}`);
51
+ return createHash("sha256").update(parts.join("\n")).digest("hex");
52
+ }
53
+
54
+ /** The stored template, or `null` when this run has not written it yet. */
55
+ export async function readStoredTemplate(dir: string, name: string): Promise<Blob | null> {
56
+ try {
57
+ return new Blob([await readFile(join(dir, `${name}.tar`))]);
58
+ } catch (error) {
59
+ if (isMissingFile(error)) {
60
+ return null;
61
+ }
62
+ throw new Error(`createTestDatabase: reading the stored template ${name} in ${dir} failed`, { cause: error });
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Writes the template under a temporary name and renames it, so a worker reading at the same time sees the whole
68
+ * file or none. Two workers building the same template both write it; the second rename wins with identical bytes.
69
+ */
70
+ export async function storeTemplate(dir: string, name: string, template: Blob): Promise<void> {
71
+ const path = join(dir, `${name}.tar`);
72
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
73
+ try {
74
+ await writeFile(temporary, new Uint8Array(await template.arrayBuffer()));
75
+ await rename(temporary, path);
76
+ } catch (error) {
77
+ throw new Error(`createTestDatabase: storing the template ${name} in ${dir} failed`, { cause: error });
78
+ }
79
+ }
80
+
81
+ async function identifyHook(hook: AppMigrationHook | undefined, templateKey: string | undefined): Promise<string | null> {
82
+ if (hook === undefined) {
83
+ return "-";
84
+ }
85
+ const folder = getDrizzleFolder(hook);
86
+ if (folder !== undefined) {
87
+ return `drizzle:${getDrizzleLedger(hook) ?? "-"}:${await hashFolder(folder)}`;
88
+ }
89
+ // A custom hook is a function: only the app can say which migrations it runs.
90
+ return templateKey === undefined ? null : "custom";
91
+ }
92
+
93
+ // Every file under the folder, by relative path and content. A missing folder hashes as such: `migrate` reports it.
94
+ async function hashFolder(folder: string): Promise<string> {
95
+ const hash = createHash("sha256");
96
+ let entries;
97
+ try {
98
+ entries = await readdir(folder, { recursive: true, withFileTypes: true });
99
+ } catch (error) {
100
+ if (isMissingFile(error)) {
101
+ return "missing";
102
+ }
103
+ throw new Error(`createTestDatabase: reading the migrations folder ${folder} failed`, { cause: error });
104
+ }
105
+ const files = entries.filter((entry) => entry.isFile()).map((entry) => join(entry.parentPath, entry.name)).sort();
106
+ for (const file of files) {
107
+ hash.update(`${relative(folder, file)}\0`);
108
+ hash.update(await readFile(file));
109
+ hash.update("\0");
110
+ }
111
+ return hash.digest("hex");
112
+ }
113
+
114
+ function isMissingFile(error: unknown): boolean {
115
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
116
+ }
package/src/testing.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  // Test databases for module unit tests (docs/02-module-standard.md §10): a real Postgres (PGlite)
2
- // with the real migrations of the given modules. Migrating costs about two seconds, so each
3
- // module set is migrated once per test worker into a template, and every call starts from a copy
4
- // of the template's data folder (FIRE_TRACKER `src/db/test-db.ts`). An app with its own migrations
5
- // passes the same `app` hooks (and baseline) as to `migrate`, so module tables can reference app
6
- // tables and a module the app's history already creates is adopted in the template.
2
+ // with the real migrations of the given modules. Migrating costs seconds, so each module set is
3
+ // migrated once into a template, and every call starts from a copy of the template's data folder
4
+ // (FIRE_TRACKER `src/db/test-db.ts`). The template is kept per test file, and per run when the
5
+ // global setup gives the run a folder (testing/template-store.ts). `reuse` hands back a used
6
+ // database reset to the template instead of a new copy (testing/reuse.ts). An app with its own
7
+ // migrations passes the same `app` hooks (and baseline) as to `migrate`, so module tables can
8
+ // reference app tables and a module the app's history already creates is adopted in the template.
7
9
  import { PGlite } from "@electric-sql/pglite";
8
10
  import type { AnySoftureModule } from "@softure-ai/core";
9
11
  import { createPgliteHandle, type PgliteClientDatabase } from "./client.js";
10
12
  import { migrate, type AppMigrationHook, type AppMigrations } from "./migrations/migrator.js";
11
13
  import { describeProblem } from "./migrations/problems.js";
14
+ import { acquirePooledDatabase, closePooledDatabases } from "./testing/reuse.js";
15
+ import { computeTemplateName, getTemplateDir, readStoredTemplate, storeTemplate } from "./testing/template-store.js";
12
16
 
13
17
  export {
14
18
  DEFAULT_DATABASE_OPENERS,
@@ -17,6 +21,7 @@ export {
17
21
  type EntryGuardOptions,
18
22
  type EntryReach,
19
23
  } from "./entry-guard.js";
24
+ export { TEMPLATE_DIR_VARIABLE } from "./testing/template-store.js";
20
25
 
21
26
  export interface TestDatabase {
22
27
  readonly db: PgliteClientDatabase;
@@ -30,6 +35,18 @@ export interface CreateTestDatabaseOptions {
30
35
  * cached per hook object: define the hooks once, at module level, not inline per call.
31
36
  */
32
37
  readonly app?: AppMigrations;
38
+ /**
39
+ * Names what the app hooks migrate, for the run-wide template folder of the global setup. Needed only for a hook
40
+ * other than `drizzleAppMigrations` (whose files are read); without it such a template is kept per test file.
41
+ * Change it when those migrations change, e.g. include a version.
42
+ */
43
+ readonly templateKey?: string;
44
+ /**
45
+ * `true`: a database a test closed is reset to the template and handed out again (a few milliseconds instead of a
46
+ * new copy). Rows, sequences and session state are reset; a test that changes the schema gets a fresh copy next
47
+ * time. Two open databases never share an instance.
48
+ */
49
+ readonly reuse?: boolean;
33
50
  }
34
51
 
35
52
  const templates = new Map<string, Promise<Blob | File>>();
@@ -42,18 +59,31 @@ let nextHookId = 1;
42
59
  * module package or the app's migrations.
43
60
  */
44
61
  export async function createTestDatabase(modules: readonly AnySoftureModule[] = [], options: CreateTestDatabaseOptions = {}): Promise<TestDatabase> {
45
- const client = new PGlite({ loadDataDir: await getTemplate(modules, options.app ?? {}) });
46
- const handle = await createPgliteHandle(client);
47
- return { db: handle.db, client, close: handle.close };
62
+ const app = options.app ?? {};
63
+ const key = computeTemplateKey(modules, app, options.templateKey);
64
+ const openCopy = async (): Promise<TestDatabase> => {
65
+ const client = new PGlite({ loadDataDir: await getTemplate(key, modules, app, options.templateKey) });
66
+ const handle = await createPgliteHandle(client);
67
+ return { db: handle.db, client, close: handle.close };
68
+ };
69
+ return options.reuse === true ? acquirePooledDatabase(key, openCopy) : openCopy();
70
+ }
71
+
72
+ /** Closes the idle databases `reuse` keeps, so a test file can free them in `afterAll`. */
73
+ export function closeReusedTestDatabases(): Promise<void> {
74
+ return closePooledDatabases();
48
75
  }
49
76
 
50
- function getTemplate(modules: readonly AnySoftureModule[], app: AppMigrations): Promise<Blob | File> {
77
+ function computeTemplateKey(modules: readonly AnySoftureModule[], app: AppMigrations, templateKey: string | undefined): string {
51
78
  const moduleKey = modules.map((module) => `${module.id}@${module.migrations?.dir.href ?? "-"}`).join("|");
52
79
  const baselineKey = Object.entries(app.baseline ?? {}).sort(([left], [right]) => left.localeCompare(right)).map(([id, through]) => `${id}:${through}`).join(",");
53
- const key = `${moduleKey}#before:${identifyHook(app.before)}#after:${identifyHook(app.after)}#baseline:${baselineKey}`;
80
+ return `${moduleKey}#before:${identifyHook(app.before)}#after:${identifyHook(app.after)}#baseline:${baselineKey}#key:${templateKey ?? "-"}`;
81
+ }
82
+
83
+ function getTemplate(key: string, modules: readonly AnySoftureModule[], app: AppMigrations, templateKey: string | undefined): Promise<Blob | File> {
54
84
  let template = templates.get(key);
55
85
  if (template === undefined) {
56
- template = buildTemplate(modules, app);
86
+ template = loadOrBuildTemplate(modules, app, templateKey);
57
87
  templates.set(key, template);
58
88
  // A failed build is not cached: the next call reports the problem again.
59
89
  template.catch(() => templates.delete(key));
@@ -74,6 +104,22 @@ function identifyHook(hook: AppMigrationHook | undefined): string {
74
104
  return String(id);
75
105
  }
76
106
 
107
+ // From the run's folder when another file already built it; otherwise built here and stored for the others.
108
+ async function loadOrBuildTemplate(modules: readonly AnySoftureModule[], app: AppMigrations, templateKey: string | undefined): Promise<Blob | File> {
109
+ const dir = getTemplateDir();
110
+ const name = dir === null ? null : await computeTemplateName({ modules, app, templateKey });
111
+ if (dir === null || name === null) {
112
+ return buildTemplate(modules, app);
113
+ }
114
+ const stored = await readStoredTemplate(dir, name);
115
+ if (stored !== null) {
116
+ return stored;
117
+ }
118
+ const built = await buildTemplate(modules, app);
119
+ await storeTemplate(dir, name, built);
120
+ return built;
121
+ }
122
+
77
123
  async function buildTemplate(modules: readonly AnySoftureModule[], app: AppMigrations): Promise<Blob | File> {
78
124
  const handle = await createPgliteHandle(new PGlite());
79
125
  try {