@ultimat3/testing 22.6.1 → 22.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "22.6.1",
3
+ "version": "22.7.0",
4
4
  "description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,16 +35,18 @@
35
35
  "test": "bun test"
36
36
  },
37
37
  "dependencies": {
38
- "@ultimat3/cache": "22.6.1",
39
- "@ultimat3/core": "22.6.1",
40
- "@ultimat3/db": "22.6.1",
41
- "@ultimat3/entity": "22.6.1",
42
- "@ultimat3/i18n": "22.6.1",
43
- "@ultimat3/jobs": "22.6.1",
44
- "@ultimat3/mail": "22.6.1",
45
- "@ultimat3/policy": "22.6.1",
46
- "@ultimat3/query": "22.6.1",
47
- "@ultimat3/realtime": "22.6.1",
48
- "@ultimat3/time": "22.6.1"
38
+ "@ultimat3/action": "22.7.0",
39
+ "@ultimat3/cache": "22.7.0",
40
+ "@ultimat3/core": "22.7.0",
41
+ "@ultimat3/db": "22.7.0",
42
+ "@ultimat3/entity": "22.7.0",
43
+ "@ultimat3/i18n": "22.7.0",
44
+ "@ultimat3/jobs": "22.7.0",
45
+ "@ultimat3/mail": "22.7.0",
46
+ "@ultimat3/policy": "22.7.0",
47
+ "@ultimat3/query": "22.7.0",
48
+ "@ultimat3/realtime": "22.7.0",
49
+ "@ultimat3/render": "22.7.0",
50
+ "@ultimat3/time": "22.7.0"
49
51
  }
50
52
  }
@@ -0,0 +1,30 @@
1
+ // An APP's tests compile every `.tsx` with the app's JSX factory from the first file on.
2
+ //
3
+ // Since 22.7 a worker runs many files in one module registry (no `--isolate`), so a `.tsx` module
4
+ // is compiled ONCE per worker and cached. `@ultimat3/render`'s loader is installed by importing
5
+ // `@ultimat3/render/server`; a file that reached a page component before any file had imported it
6
+ // cached the page compiled with Bun's classic factory, and every later file on that worker failed
7
+ // with `React is not defined` (18 notificado.co tests, measured). Installing the loader in the
8
+ // preload makes the first compile the right one.
9
+ //
10
+ // Only in an app (an `app.config.ts` at the run's root): the framework repository's own tests
11
+ // include suites that pin what a process WITHOUT the loader does, and they keep that process.
12
+
13
+ // why: Bun ships no synchronous file-exists check; Bun.file().exists() is async and a preload
14
+ // decides this before any test file loads.
15
+ import { existsSync } from 'node:fs';
16
+ // why: Bun ships no path-join primitive.
17
+ import { join } from 'node:path';
18
+
19
+ export const APP_CONFIG = 'app.config.ts';
20
+
21
+ export const isAppRoot = (root: string = process.cwd()): boolean =>
22
+ existsSync(join(root, APP_CONFIG));
23
+
24
+ /** Install the render loader when this run's root is an app. Returns whether it did. */
25
+ export async function installAppJsxLoader(root: string = process.cwd()): Promise<boolean> {
26
+ if (!isAppRoot(root)) return false;
27
+ const { installRenderLoader } = await import('@ultimat3/render/server');
28
+ installRenderLoader();
29
+ return true;
30
+ }
@@ -0,0 +1,138 @@
1
+ // "One test file ended, the next begins" — the moment a shared worker process must hand the next
2
+ // file the process it would have had alone.
3
+ //
4
+ // Since 22.7 `x test` / `x verify` run each worker WITHOUT `--isolate`: one global and one module
5
+ // registry per worker, reused across every file it runs (2-5x faster, measured in
6
+ // `@ultimat3/cli`'s `test-workers.ts`). What a file leaves on `globalThis` then reaches every
7
+ // later file on that worker. Measured on notificado.co: one island test that never disposed its
8
+ // mount left the fake `window`/`document` installed, PGlite then took the process for a browser
9
+ // (`window.location.pathname` of undefined), and 264 unrelated tests failed.
10
+ //
11
+ // Bun gives a preload no per-file hook. The one signal is a test file being LOADED, which
12
+ // `registry-leak-guard.ts` already intercepts; it calls `runFileBoundary()` there, and this module
13
+ // owns what happens: the hooks registered with `onFileBoundary` (island mounts disposed first,
14
+ // while their fake DOM is still installed), then the globals put back.
15
+
16
+ type Hook = () => void;
17
+
18
+ const hooks: Hook[] = [];
19
+
20
+ /** Run `hook` between every two test files of one worker process. */
21
+ export function onFileBoundary(hook: Hook): void {
22
+ hooks.push(hook);
23
+ }
24
+
25
+ /**
26
+ * Globals a file may add and keep: `__`-prefixed helper slots (tslib's `__extends`, seroval's
27
+ * `__SEROVAL_REFS__`) that a module installs ONCE at import and reads back later — the module is
28
+ * cached, so deleting its slot would break the next file that uses it, not protect it.
29
+ */
30
+ const kept = (key: string): boolean => key.startsWith('__') || /^\d+$/.test(key);
31
+
32
+ interface Baseline {
33
+ readonly keys: ReadonlySet<string>;
34
+ readonly descriptors: ReadonlyMap<string, PropertyDescriptor>;
35
+ }
36
+
37
+ let baseline: Baseline | undefined;
38
+ let envBaseline: Readonly<Record<string, string | undefined>> | undefined;
39
+
40
+ /**
41
+ * `process.env` back to what the first file saw: a test that sets `ULTIMATE_EVAL_RECORD` (or any
42
+ * key) and forgets it changes what every later file on the worker reads — measured, the framework's
43
+ * own `verify.run` MCP test then saw the eval step red. Returns the keys it touched.
44
+ */
45
+ export function restoreEnv(
46
+ snapshot: Readonly<Record<string, string | undefined>>,
47
+ env: Record<string, string | undefined> = process.env,
48
+ ): readonly string[] {
49
+ const touched: string[] = [];
50
+ for (const key of Object.keys(env)) {
51
+ if (!(key in snapshot)) {
52
+ delete env[key];
53
+ touched.push(key);
54
+ }
55
+ }
56
+ for (const [key, value] of Object.entries(snapshot)) {
57
+ if (env[key] !== value && value !== undefined) {
58
+ env[key] = value;
59
+ touched.push(key);
60
+ }
61
+ }
62
+ return touched;
63
+ }
64
+
65
+ /**
66
+ * The globals the process started with — taken at the FIRST file boundary, not at preload time,
67
+ * so every preload (the app's own, a DOM registrator among them) is part of the baseline.
68
+ */
69
+ export function captureGlobals(host: object = globalThis): Baseline {
70
+ const keys = new Set(Object.getOwnPropertyNames(host));
71
+ const descriptors = new Map<string, PropertyDescriptor>();
72
+ for (const key of keys) {
73
+ if (kept(key)) continue;
74
+ const descriptor = Object.getOwnPropertyDescriptor(host, key);
75
+ if (descriptor !== undefined) descriptors.set(key, descriptor);
76
+ }
77
+ return { keys, descriptors };
78
+ }
79
+
80
+ const same = (a: PropertyDescriptor, b: PropertyDescriptor): boolean =>
81
+ a.value === b.value && a.get === b.get && a.set === b.set;
82
+
83
+ /**
84
+ * Delete what the last file added and put back what it replaced. Returns the keys it touched, so
85
+ * a test can pin it. A property the host refuses to change is left alone — never a throw here.
86
+ */
87
+ export function restoreGlobals(snapshot: Baseline, host: object = globalThis): readonly string[] {
88
+ const record = host as Record<string, unknown>;
89
+ const touched: string[] = [];
90
+ for (const key of Object.getOwnPropertyNames(host)) {
91
+ if (kept(key)) continue;
92
+ if (!snapshot.keys.has(key)) {
93
+ try {
94
+ if (delete record[key]) touched.push(key);
95
+ } catch {
96
+ // Non-configurable: nothing a restore can do, and the run must go on.
97
+ }
98
+ continue;
99
+ }
100
+ const was = snapshot.descriptors.get(key);
101
+ const now = Object.getOwnPropertyDescriptor(host, key);
102
+ if (was === undefined || now === undefined || same(was, now)) continue;
103
+ try {
104
+ Object.defineProperty(host, key, was);
105
+ touched.push(key);
106
+ } catch {
107
+ // Non-configurable and changed: leave it.
108
+ }
109
+ }
110
+ for (const [key, descriptor] of snapshot.descriptors) {
111
+ if (Object.getOwnPropertyDescriptor(host, key) !== undefined) continue;
112
+ try {
113
+ Object.defineProperty(host, key, descriptor);
114
+ touched.push(key);
115
+ } catch {
116
+ // Could not be put back; the next file sees it missing, as it would have anyway.
117
+ }
118
+ }
119
+ return touched;
120
+ }
121
+
122
+ /** Called by the leak guard as each test file loads. The first call only takes the baseline. */
123
+ export function runFileBoundary(): void {
124
+ if (baseline === undefined) {
125
+ baseline = captureGlobals();
126
+ envBaseline = { ...process.env };
127
+ return;
128
+ }
129
+ for (const hook of hooks) {
130
+ try {
131
+ hook();
132
+ } catch {
133
+ // A hook that throws (an island's own disposer) must not stop the rest of the reset.
134
+ }
135
+ }
136
+ restoreGlobals(baseline);
137
+ if (envBaseline !== undefined) restoreEnv(envBaseline);
138
+ }
@@ -218,6 +218,26 @@ function installGlobals(values: Readonly<Record<string, unknown>>): () => void {
218
218
  * file's island. The disposer runs while the fake `document` is still installed, because what it
219
219
  * clears (a listener on `document`, an interval whose callback reads it) was made against that one.
220
220
  */
221
+ /**
222
+ * Every mount not yet disposed. A worker runs many files in one global (22.7), so a mount a file
223
+ * forgot to `using` is disposed at the file boundary (`file-boundary.ts`) instead of handing its
224
+ * fake `window`/`document` to every later file.
225
+ */
226
+ const liveMounts = new Set<() => void>();
227
+
228
+ /** Dispose every mount still installed, newest first. Returns how many there were. */
229
+ export function disposeLiveIslands(): number {
230
+ const pending = [...liveMounts].reverse();
231
+ for (const dispose of pending) {
232
+ try {
233
+ dispose();
234
+ } catch {
235
+ // The island's own disposer threw; its globals were still restored in `dispose`'s finally.
236
+ }
237
+ }
238
+ return pending.length;
239
+ }
240
+
221
241
  export async function mountIsland(options: MountIslandOptions): Promise<MountedIsland> {
222
242
  // `only`, because this fixture has always KNOWN which island it wants and asked for all of them
223
243
  // anyway — `island-bundle.ts` added the option for exactly this caller ("a test that mounts a
@@ -266,6 +286,7 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
266
286
  // disposer is idempotent while an island's `clearInterval` wrapper need not be.
267
287
  if (disposed) return;
268
288
  disposed = true;
289
+ liveMounts.delete(dispose);
269
290
  try {
270
291
  unmount?.();
271
292
  } finally {
@@ -274,6 +295,7 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
274
295
  restore();
275
296
  }
276
297
  };
298
+ liveMounts.add(dispose);
277
299
  const resolve = (target: string | FakeElement | null | undefined): FakeElement | null =>
278
300
  typeof target === 'string' ? el.querySelector(target) : (target ?? null);
279
301
  return {
package/src/index.ts CHANGED
@@ -271,6 +271,8 @@ export type { RegistryLeak, RegistrySample } from './registry-leak-guard';
271
271
  export { installRegistryLeakGuard, leakBetween, sampleRegistries } from './registry-leak-guard';
272
272
  export type { ProcessRegistrySnapshot } from './registry-snapshot';
273
273
  export { captureProcessRegistries, restoreProcessRegistries } from './registry-snapshot';
274
+ export type { DatabaseSnapshot } from './reusable-database';
275
+ export { restoreDatabase, reusableDatabase, snapshotDatabase } from './reusable-database';
274
276
  export type { MockRoute, NetworkSnapshot, NetworkState } from './sealed-network';
275
277
  // `setNetworkState` is deliberately not here: it is the offline gate's one writer, and a test that
276
278
  // called it directly would bypass the `network` fixture's disposal and leave the whole process
package/src/preload.ts CHANGED
@@ -8,6 +8,9 @@
8
8
  import { installDeterminism } from './determinism';
9
9
  import { registerFrameworkFixtures } from './framework-fixtures';
10
10
  import './matchers';
11
+ import { installAppJsxLoader } from './app-jsx-loader';
12
+ import { onFileBoundary } from './file-boundary';
13
+ import { disposeLiveIslands } from './fixture-island';
11
14
  import { releasePluginsAfterIsolatedFile } from './isolated-plugins';
12
15
  import { installRegistryLeakGuard } from './registry-leak-guard';
13
16
  import { sealNetwork } from './sealed-network';
@@ -22,10 +25,32 @@ installDeterminism({
22
25
 
23
26
  registerFrameworkFixtures();
24
27
 
28
+ // Inside a test process the machine's test slots are already accounted for — by the `x test` /
29
+ // `x verify` that spawned it, or by nobody when `bun test` ran bare. An `x test` a test runs in
30
+ // process (the framework's own suites do) must never lease from the real pool: it would wait on
31
+ // its own parent's slots. `@ultimat3/cli`'s `test-slots.ts` reads this.
32
+ Bun.env['ULTIMATE_TEST_SLOT_HELD'] ??= '1';
33
+
25
34
  // One `bun test` invocation is one process: a file that leaves a process-global registry dirty
26
35
  // fails a later file in another package, for a reason nothing in that file explains.
27
36
  installRegistryLeakGuard();
28
37
 
38
+ // A worker runs many files in one global unless the repo opted into `--isolate` (22.7): a mount a
39
+ // file never disposed is disposed between files, and `file-boundary.ts` then puts `globalThis`
40
+ // back to what the first file saw.
41
+ onFileBoundary(disposeLiveIslands);
42
+
43
+ // A shared worker only (an isolated file is a fresh registry anyway): the app's
44
+ // `defineApi({ pathStyle })` evaluates once per worker, in whichever file first imports it, and
45
+ // must not be refused because an EARLIER file derived action paths under the default style.
46
+ if (Bun.env['ULTIMATE_TEST_ISOLATED'] !== '1') {
47
+ const { forgetHandedOutActionPaths } = await import('@ultimat3/action');
48
+ onFileBoundary(forgetHandedOutActionPaths);
49
+ }
50
+
51
+ // An app's `.tsx` compiles with the app's JSX factory on the first file, never cached classic.
52
+ await installAppJsxLoader();
53
+
29
54
  // Isolated runs only (`x test` says so): Bun 1.4.0 keeps every finished file alive while a plugin
30
55
  // is registered. See `isolated-plugins.ts`.
31
56
  releasePluginsAfterIsolatedFile();
@@ -5,9 +5,15 @@
5
5
 
6
6
  import { afterAll } from 'bun:test';
7
7
  import { knownTags, registeredTiers } from '@ultimat3/cache';
8
+ import { isAppRoot } from './app-jsx-loader';
8
9
  import { RegistryLeakError } from './errors';
10
+ import { runFileBoundary } from './file-boundary';
9
11
  import type { ProcessRegistrySnapshot } from './registry-snapshot';
10
- import { captureProcessRegistries, restoreProcessRegistries } from './registry-snapshot';
12
+ import {
13
+ captureProcessRegistries,
14
+ mergeSnapshots,
15
+ restoreProcessRegistries,
16
+ } from './registry-snapshot';
11
17
 
12
18
  /**
13
19
  * What is REPORTED, and why only these two. Both are BOOT installs — `declareTags` takes the
@@ -34,7 +40,7 @@ import { captureProcessRegistries, restoreProcessRegistries } from './registry-s
34
40
  * | permissions / roles | `restorePermissions` / `restoreRoles` | `@ultimat3/policy` | yes |
35
41
  * | routes | `clearRoutes` | `@ultimat3/render` | **no** |
36
42
  * | jobs | `resetJobs` | `@ultimat3/jobs` | **no** |
37
- * | tasks | `resetTasks` | `@ultimat3/jobs` | **no** |
43
+ * | tasks | `restoreTasks` | `@ultimat3/jobs` | yes (22.7) |
38
44
  * | actions | `resetRegistry` | `@ultimat3/action` | **no** |
39
45
  * | queries | `resetRegistry` | `@ultimat3/query` | **no** |
40
46
  * | models / prompts / agents | `resetModels` / `resetPrompts` / `resetAgents` | `@ultimat3/ai` | **no** |
@@ -129,6 +135,11 @@ export function installRegistryLeakGuard(): void {
129
135
  }
130
136
  | undefined;
131
137
  const leaks: RegistryLeak[] = [];
138
+ // Everything every earlier file inherited, merged: what a shared worker restores to.
139
+ let accumulated: ProcessRegistrySnapshot | undefined;
140
+ // The locale config before any app code ran — preload time.
141
+ const pristineLocales = JSON.stringify(captureProcessRegistries().locales);
142
+ const inApp = isAppRoot();
132
143
 
133
144
  const close = (): void => {
134
145
  if (current === undefined) return;
@@ -137,7 +148,33 @@ export function installRegistryLeakGuard(): void {
137
148
  // The repair, at the only point it is safe: the file is over and the next one has not
138
149
  // evaluated yet, so what goes back is exactly what that file inherited — module-scope
139
150
  // declarations included, which is the half a plain `resetX()` in a `beforeEach` destroys.
140
- restoreProcessRegistries(current.snapshot);
151
+ //
152
+ // In an APP, declarations are put back as a UNION with what the process holds now (22.7,
153
+ // shared worker): an app module a test imported lazily declared its permissions and roles once,
154
+ // for the life of the worker, and taking them away here would leave every later file without
155
+ // them. The framework repository's own suites keep the exact restore: many of them lean on an
156
+ // EMPTY permission set (no validation), and a test body's declaration must not switch it on for
157
+ // every later file. Tasks go back EXACTLY everywhere: one a test body registered must not fire
158
+ // in the next file's scheduler round.
159
+ const live = captureProcessRegistries();
160
+ const merged = inApp ? mergeSnapshots(live, current.snapshot) : current.snapshot;
161
+ // Locales likewise, in one case: the file's baseline still held the framework's pristine
162
+ // config and an app's `defineCatalogs()` ran during the file (a lazily imported catalog
163
+ // module) — that configured the worker for good, since the module will not evaluate again.
164
+ // A test that called `configureLocales()` by hand is undone, as before.
165
+ // Only in an app: there `defineCatalogs()` can only be THE app's catalog module, while the
166
+ // framework repository's own suites define a fixture app's catalogs per test, to be undone.
167
+ const declaredLazily =
168
+ inApp &&
169
+ JSON.stringify(current.snapshot.locales) === pristineLocales &&
170
+ live.catalogDeclarations > current.snapshot.catalogDeclarations;
171
+ const restored = {
172
+ ...merged,
173
+ locales: declaredLazily ? live.locales : current.snapshot.locales,
174
+ tasks: current.snapshot.tasks,
175
+ };
176
+ restoreProcessRegistries(restored);
177
+ accumulated = mergeSnapshots(accumulated, restored);
141
178
  current = undefined;
142
179
  };
143
180
 
@@ -146,11 +183,8 @@ export function installRegistryLeakGuard(): void {
146
183
  // how an app declares its tags — and everything after this point is the file's own to undo.
147
184
  hookHost[BASELINE_HOOK] = () => {
148
185
  if (pending === undefined) return;
149
- current = {
150
- file: pending,
151
- before: sampleRegistries(),
152
- snapshot: captureProcessRegistries(),
153
- };
186
+ accumulated = mergeSnapshots(accumulated, captureProcessRegistries());
187
+ current = { file: pending, before: sampleRegistries(), snapshot: accumulated };
154
188
  pending = undefined;
155
189
  };
156
190
 
@@ -172,6 +206,8 @@ export function installRegistryLeakGuard(): void {
172
206
  // `.test.tsx` files exist and the convention is `<file>.test.ts`, so the narrower filter
173
207
  // costs nothing today; a `.test.tsx` added later is unguarded rather than mis-compiled.
174
208
  build.onLoad({ filter: /\.test\.ts$/ }, async (args) => {
209
+ // Before the registry restore: an island disposed here still needs its fake DOM.
210
+ runFileBoundary();
175
211
  close();
176
212
  pending = repoRelative(args.path);
177
213
  return {
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { Catalog, Locale, LocaleConfig } from '@ultimat3/i18n';
12
12
  import {
13
+ catalogDeclarationCount,
13
14
  catalogFor,
14
15
  configureLocales,
15
16
  localeConfig,
@@ -18,6 +19,8 @@ import {
18
19
  registeredLocales,
19
20
  resetCatalogs,
20
21
  } from '@ultimat3/i18n';
22
+ import type { TaskHandle } from '@ultimat3/jobs';
23
+ import { registeredTasks, restoreTasks } from '@ultimat3/jobs';
21
24
  import type { RoleMap } from '@ultimat3/policy';
22
25
  import {
23
26
  knownPermissions,
@@ -39,6 +42,14 @@ export interface ProcessRegistrySnapshot {
39
42
  readonly roles: RoleMap;
40
43
  /** Kept beside the map: restoring through `defineRoles()` would rewrite every site. */
41
44
  readonly roleSites: Readonly<Record<string, string>>;
45
+ /**
46
+ * Scheduled tasks. Since 22.7 a worker process runs many files in ONE global, so a task a test
47
+ * body registered (`packages/jobs/src/task.test.ts`'s deliberately throwing `aBrokenDigest`)
48
+ * fired in every later file's `appManifest` round — 14 framework failures, measured.
49
+ */
50
+ readonly tasks: readonly TaskHandle[];
51
+ /** `defineCatalogs()` calls so far — see `@ultimat3/i18n`'s `catalogDeclarationCount`. */
52
+ readonly catalogDeclarations: number;
42
53
  }
43
54
 
44
55
  export function captureProcessRegistries(): ProcessRegistrySnapshot {
@@ -48,6 +59,8 @@ export function captureProcessRegistries(): ProcessRegistrySnapshot {
48
59
  permissions: knownPermissions(),
49
60
  roles: roleDefinitions(),
50
61
  roleSites: roleDeclarationSites(),
62
+ tasks: registeredTasks(),
63
+ catalogDeclarations: catalogDeclarationCount(),
51
64
  };
52
65
  }
53
66
 
@@ -63,6 +76,7 @@ export function restoreProcessRegistries(snapshot: ProcessRegistrySnapshot): voi
63
76
  restoreCatalogs(snapshot.catalogs);
64
77
  restorePermissions(snapshot.permissions);
65
78
  restoreRoles(snapshot.roles, snapshot.roleSites);
79
+ restoreTasks(snapshot.tasks);
66
80
  }
67
81
 
68
82
  /**
@@ -99,3 +113,34 @@ function restoreCatalogs(snapshot: readonly (readonly [Locale, Catalog])[]): voi
99
113
  registerCatalog(locale, mergeCatalogs(inherited.get(locale) ?? {}, live.get(locale) ?? {}));
100
114
  }
101
115
  }
116
+
117
+ /**
118
+ * What a shared worker restores to after a file: everything any earlier file's module graph
119
+ * declared, plus what this file inherited. Since 22.7 a worker runs many files in one module
120
+ * registry, so a module a file imports evaluates ONCE — a later file that resets the permission
121
+ * set at module scope would otherwise hand every file after it a registry missing declarations no
122
+ * cached module will ever make again (measured on notificado.co: "admin:read is not in the
123
+ * permission set (8 known)"). Newer wins on a conflict, so a file's own redefinition still stands
124
+ * for that file.
125
+ */
126
+ export function mergeSnapshots(
127
+ older: ProcessRegistrySnapshot | undefined,
128
+ newer: ProcessRegistrySnapshot,
129
+ ): ProcessRegistrySnapshot {
130
+ if (older === undefined) return newer;
131
+ const catalogs = new Map(older.catalogs);
132
+ for (const [locale, catalog] of newer.catalogs) {
133
+ catalogs.set(locale, mergeCatalogs(catalogs.get(locale) ?? {}, catalog));
134
+ }
135
+ const tasks = new Map(older.tasks.map((handle) => [handle.name, handle] as const));
136
+ for (const handle of newer.tasks) tasks.set(handle.name, handle);
137
+ return {
138
+ locales: newer.locales,
139
+ catalogs: [...catalogs],
140
+ permissions: [...new Set([...older.permissions, ...newer.permissions])],
141
+ roles: { ...older.roles, ...newer.roles },
142
+ roleSites: { ...older.roleSites, ...newer.roleSites },
143
+ tasks: [...tasks.values()],
144
+ catalogDeclarations: Math.max(older.catalogDeclarations, newer.catalogDeclarations),
145
+ };
146
+ }
@@ -0,0 +1,118 @@
1
+ // One database per test WORKER, reset between files — instead of one booted per test file.
2
+ //
3
+ // Booting an embedded Postgres (PGlite) from a migrated template costs 2.4-7 s of CPU on a
4
+ // 12-core box (measured, 64 MB template: 2.7-7.2 s cold, 2.4-2.9 s with the WASM module and fs
5
+ // bundle already compiled — the untar and the postgres start are the cost, not the compile). A
6
+ // suite that boots one per file pays that in every file: notificado.co's unit tier has 236 such
7
+ // files, ~700 s of CPU, which is most of its wall time at any width.
8
+ //
9
+ // Since 22.7 a worker runs many files in one process (no `--isolate`), so the database can outlive
10
+ // a file. `reusableDatabase(open)` opens it ONCE per worker, snapshots every table's rows and every
11
+ // sequence right after (the migrated template: reference data, seeded rows), and on each later
12
+ // acquire puts the data back to exactly that: TRUNCATE every table, re-insert the snapshot, reset
13
+ // the sequences — with triggers off for the duration (`session_replication_role = replica`), so an
14
+ // insert-only guard on an evidence table does not refuse the reset the way it refuses a test.
15
+ //
16
+ // What it does NOT undo: DDL a test ran (a table it created stays; one it dropped stays gone). A
17
+ // test that changes the schema opens its own database.
18
+
19
+ import type { DbClient, SqlFragment } from '@ultimat3/db';
20
+ import { identifier, raw, sql } from '@ultimat3/db';
21
+
22
+ interface TableSnapshot {
23
+ readonly table: string;
24
+ /** Insertable columns — generated ones are recomputed, never written. */
25
+ readonly columns: readonly string[];
26
+ /** The rows as JSON text, or null for an empty table. */
27
+ readonly rows: string | null;
28
+ }
29
+
30
+ interface SequenceSnapshot {
31
+ readonly sequence: string;
32
+ readonly lastValue: string | null;
33
+ }
34
+
35
+ export interface DatabaseSnapshot {
36
+ readonly tables: readonly TableSnapshot[];
37
+ readonly sequences: readonly SequenceSnapshot[];
38
+ }
39
+
40
+ const list = (names: readonly string[]): SqlFragment =>
41
+ raw(names.map((name) => `"${name.replaceAll('"', '""')}"`).join(', '));
42
+
43
+ /** Every public table's rows and every public sequence's position, as they are now. */
44
+ export async function snapshotDatabase(client: DbClient): Promise<DatabaseSnapshot> {
45
+ const tables = await client.query<{ table_name: string }>(
46
+ sql`select table_name from information_schema.tables where table_schema = 'public' and table_type = 'BASE TABLE' order by table_name`,
47
+ );
48
+ const snapshots: TableSnapshot[] = [];
49
+ for (const { table_name: table } of tables) {
50
+ const columns = (
51
+ await client.query<{ column_name: string }>(
52
+ sql`select column_name from information_schema.columns where table_schema = 'public' and table_name = ${table} and is_generated = 'NEVER' order by ordinal_position`,
53
+ )
54
+ ).map((row) => row.column_name);
55
+ const row = await client.one<{ rows: string | null }>(
56
+ sql`select json_agg(t)::text as rows from ${identifier(table)} t`,
57
+ );
58
+ snapshots.push({ table, columns, rows: row?.rows ?? null });
59
+ }
60
+ const sequences = await client.query<{ sequencename: string; last_value: string | null }>(
61
+ sql`select sequencename, last_value::text as last_value from pg_sequences where schemaname = 'public'`,
62
+ );
63
+ return {
64
+ tables: snapshots,
65
+ sequences: sequences.map((row) => ({ sequence: row.sequencename, lastValue: row.last_value })),
66
+ };
67
+ }
68
+
69
+ /** Put every table's rows and every sequence back to `snapshot`. Triggers are off meanwhile. */
70
+ export async function restoreDatabase(client: DbClient, snapshot: DatabaseSnapshot): Promise<void> {
71
+ const current = (
72
+ await client.query<{ table_name: string }>(
73
+ sql`select table_name from information_schema.tables where table_schema = 'public' and table_type = 'BASE TABLE'`,
74
+ )
75
+ ).map((row) => row.table_name);
76
+ await client.execute(raw('set session_replication_role = replica'));
77
+ try {
78
+ if (current.length > 0) {
79
+ await client.execute(sql`truncate ${list(current)} restart identity cascade`);
80
+ }
81
+ for (const table of snapshot.tables) {
82
+ if (table.rows === null || !current.includes(table.table)) continue;
83
+ const columns = list(table.columns);
84
+ await client.execute(
85
+ sql`insert into ${identifier(table.table)} (${columns}) overriding system value select ${columns} from json_populate_recordset(null::${identifier(table.table)}, ${table.rows}::json)`,
86
+ );
87
+ }
88
+ for (const { sequence, lastValue } of snapshot.sequences) {
89
+ await client.execute(
90
+ lastValue === null
91
+ ? sql`select setval(${`"${sequence}"`}::regclass, 1, false)`
92
+ : sql`select setval(${`"${sequence}"`}::regclass, ${lastValue}::bigint, true)`,
93
+ );
94
+ }
95
+ } finally {
96
+ await client.execute(raw('set session_replication_role = origin'));
97
+ }
98
+ }
99
+
100
+ /**
101
+ * `acquire()` answers the same client for the life of the worker process, with its data reset to
102
+ * what it held right after `open()`. The first call opens and snapshots; each later one restores.
103
+ * Never close what it hands out — the worker's exit does.
104
+ */
105
+ export function reusableDatabase<C extends DbClient>(open: () => Promise<C>): () => Promise<C> {
106
+ let opened: Promise<{ client: C; snapshot: DatabaseSnapshot }> | undefined;
107
+ let first = true;
108
+ return async () => {
109
+ opened ??= (async () => {
110
+ const client = await open();
111
+ return { client, snapshot: await snapshotDatabase(client) };
112
+ })();
113
+ const { client, snapshot } = await opened;
114
+ if (first) first = false;
115
+ else await restoreDatabase(client, snapshot);
116
+ return client;
117
+ };
118
+ }