@stratal/testing 0.0.27 → 0.1.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/CHANGELOG.md +562 -0
- package/README.md +137 -36
- package/dist/database/index.d.mts +2 -2
- package/dist/database/index.mjs +2 -2
- package/dist/database-Rojs69r3.mjs +453 -0
- package/dist/database-Rojs69r3.mjs.map +1 -0
- package/dist/{decorate-B7nr7eBl.mjs → decorate-RQD1h28J.mjs} +1 -1
- package/dist/feature-flags/index.mjs +1 -1
- package/dist/{feature-flags-BiLhfSGh.mjs → feature-flags-CZ1a4M2g.mjs} +2 -2
- package/dist/{feature-flags-BiLhfSGh.mjs.map → feature-flags-CZ1a4M2g.mjs.map} +1 -1
- package/dist/index-B8Mw1Dxk.d.mts +175 -0
- package/dist/index-B8Mw1Dxk.d.mts.map +1 -0
- package/dist/{index-CrHzUDKX.d.mts → index-D7FM6EKp.d.mts} +21 -3
- package/dist/index-D7FM6EKp.d.mts.map +1 -0
- package/dist/index-qgWNJRdC.d.mts.map +1 -1
- package/dist/index.d.mts +105 -46
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +218 -108
- package/dist/index.mjs.map +1 -1
- package/dist/mocks/cloudflare-workers.d.mts +24 -0
- package/dist/mocks/cloudflare-workers.d.mts.map +1 -0
- package/dist/mocks/cloudflare-workers.mjs +28 -0
- package/dist/mocks/cloudflare-workers.mjs.map +1 -0
- package/dist/mocks/index.d.mts +2 -2
- package/dist/mocks/index.mjs +2 -2
- package/dist/mocks/noop-rate-limiter-store.d.mts +1 -3
- package/dist/mocks/noop-rate-limiter-store.d.mts.map +1 -1
- package/dist/mocks/zenstack-language.d.mts +26 -29
- package/dist/mocks/zenstack-language.d.mts.map +1 -1
- package/dist/mocks/zenstack-language.mjs +1 -1
- package/dist/mocks/zenstack-language.mjs.map +1 -1
- package/dist/storage/index.d.mts +1 -1
- package/dist/storage/index.mjs +1 -1
- package/dist/{storage-DhoxWqyF.mjs → storage-BiGamMA8.mjs} +73 -6
- package/dist/storage-BiGamMA8.mjs.map +1 -0
- package/dist/test-worker-exports-dCRARYEc.d.mts +144 -0
- package/dist/test-worker-exports-dCRARYEc.d.mts.map +1 -0
- package/dist/test-workers-cache-D02Pt_dE.mjs +183 -0
- package/dist/test-workers-cache-D02Pt_dE.mjs.map +1 -0
- package/dist/vitest-plugin/index.d.mts +14 -28
- package/dist/vitest-plugin/index.d.mts.map +1 -1
- package/dist/vitest-plugin/index.mjs +32 -20
- package/dist/vitest-plugin/index.mjs.map +1 -1
- package/package.json +30 -21
- package/dist/database-B02eYKhE.mjs +0 -334
- package/dist/database-B02eYKhE.mjs.map +0 -1
- package/dist/index-BIr5nLof.d.mts +0 -122
- package/dist/index-BIr5nLof.d.mts.map +0 -1
- package/dist/index-CrHzUDKX.d.mts.map +0 -1
- package/dist/storage-DhoxWqyF.mjs.map +0 -1
- package/dist/test-email-provider-B7cjj97-.d.mts +0 -21
- package/dist/test-email-provider-B7cjj97-.d.mts.map +0 -1
- package/dist/test-email-provider-Dr-nhE0x.mjs +0 -34
- package/dist/test-email-provider-Dr-nhE0x.mjs.map +0 -1
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
//#region src/database/test-database.ts
|
|
5
|
+
/**
|
|
6
|
+
* Test database isolation helpers.
|
|
7
|
+
*
|
|
8
|
+
* Implements leased worker databases for parallel runs against Postgres. A
|
|
9
|
+
* migrated **template** database is built once in global setup. Each test file
|
|
10
|
+
* leases one numbered worker database for as long as its isolate lives and
|
|
11
|
+
* receives a fresh clone of the template in it, so a run holds at most as many
|
|
12
|
+
* databases as it runs files at once. See `@stratal/testing/database`.
|
|
13
|
+
*
|
|
14
|
+
* `pg` is imported dynamically so this module loads without it — consumers
|
|
15
|
+
* that don't use a database never pay the dependency.
|
|
16
|
+
*/
|
|
17
|
+
/** Env var carrying the name of the Hyperdrive binding to isolate. */
|
|
18
|
+
const BINDING_ENV_VAR = "STRATAL_TEST_DB_BINDING";
|
|
19
|
+
/** Default Hyperdrive binding name when none is configured. */
|
|
20
|
+
const DEFAULT_DB_BINDING = "DB";
|
|
21
|
+
/**
|
|
22
|
+
* Postgres' identifier length limit. Names longer than this are silently
|
|
23
|
+
* truncated by the server, which would cause distinct logical names to collide
|
|
24
|
+
* on the same physical database. We assert against it everywhere a name is
|
|
25
|
+
* derived.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_IDENTIFIER_LENGTH = 63;
|
|
28
|
+
/** Quote a Postgres identifier for safe interpolation into DDL. */
|
|
29
|
+
function quoteIdent$1(name) {
|
|
30
|
+
return `"${name.replace(/"/g, "\"\"")}"`;
|
|
31
|
+
}
|
|
32
|
+
/** Quote a Postgres string literal for safe interpolation into SQL. */
|
|
33
|
+
function quoteLiteral(value) {
|
|
34
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Dynamically import `pg` (an optional peer), surfacing an actionable error
|
|
38
|
+
* instead of a raw module-not-found when database isolation is requested
|
|
39
|
+
* without the dependency installed.
|
|
40
|
+
*/
|
|
41
|
+
async function importPg$1() {
|
|
42
|
+
try {
|
|
43
|
+
const { default: pg } = await import("pg");
|
|
44
|
+
return pg;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new Error("[stratal-testing] `pg` is required for database isolation but is not installed. Install it: `npm install --save-dev pg` (or `yarn add -D pg`).", { cause: error });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Assert a derived identifier fits within Postgres' {@link MAX_IDENTIFIER_LENGTH}
|
|
51
|
+
* limit. Throws a clear, actionable error instead of letting the server
|
|
52
|
+
* silently truncate and collide names.
|
|
53
|
+
*/
|
|
54
|
+
function assertIdentifierLength(name, kind) {
|
|
55
|
+
if (name.length > MAX_IDENTIFIER_LENGTH) throw new Error(`[stratal-testing] Derived ${kind} "${name}" is ${name.length} characters, exceeding Postgres' ${MAX_IDENTIFIER_LENGTH}-character identifier limit. Use a shorter base database name so the test-isolation suffix fits.`);
|
|
56
|
+
}
|
|
57
|
+
/** Parse the database name out of a Postgres connection URL. */
|
|
58
|
+
function databaseNameOf(connectionString) {
|
|
59
|
+
return new URL(connectionString).pathname.replace(/^\//, "") || "postgres";
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Derive an admin connection string pointing at the `postgres` maintenance
|
|
63
|
+
* database. `CREATE`/`DROP DATABASE` cannot run on a connection bound to the
|
|
64
|
+
* target database, so administration always goes through `postgres`.
|
|
65
|
+
*/
|
|
66
|
+
function deriveAdminConnectionString(connectionString) {
|
|
67
|
+
const url = new URL(connectionString);
|
|
68
|
+
url.pathname = "/postgres";
|
|
69
|
+
return url.toString();
|
|
70
|
+
}
|
|
71
|
+
/** Build a connection string identical to `base` but pointing at `dbName`. */
|
|
72
|
+
function buildConnectionString(base, dbName) {
|
|
73
|
+
const url = new URL(base);
|
|
74
|
+
url.pathname = `/${dbName}`;
|
|
75
|
+
return url.toString();
|
|
76
|
+
}
|
|
77
|
+
/** The shared prefix for worker databases, used as the sweep key. */
|
|
78
|
+
function databasePrefix(base) {
|
|
79
|
+
return `${databaseNameOf(base).replace(/[^a-z0-9_]/gi, "_")}_w_`;
|
|
80
|
+
}
|
|
81
|
+
/** Name of the migrated template database cloned per worker. */
|
|
82
|
+
function deriveTemplateName(base) {
|
|
83
|
+
const name = `${databaseNameOf(base).replace(/[^a-z0-9_]/gi, "_")}_template`;
|
|
84
|
+
assertIdentifierLength(name, "template database name");
|
|
85
|
+
return name;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Name of the worker database behind lease `slot`. Asserted against Postgres'
|
|
89
|
+
* 63-char identifier limit (keep the base name short so `_w_<slot>` fits).
|
|
90
|
+
*/
|
|
91
|
+
function deriveWorkerDbName(base, slot) {
|
|
92
|
+
const name = `${databaseNameOf(base).replace(/[^a-z0-9_]/gi, "_")}_w_${slot}`;
|
|
93
|
+
assertIdentifierLength(name, "worker database name");
|
|
94
|
+
return name;
|
|
95
|
+
}
|
|
96
|
+
/** The lease slot a worker database name carries, or `null` for any other name. */
|
|
97
|
+
function slotOfWorkerDb(prefix, dbName) {
|
|
98
|
+
if (!dbName.startsWith(prefix)) return null;
|
|
99
|
+
const slot = dbName.slice(prefix.length);
|
|
100
|
+
return /^\d+$/.test(slot) ? Number(slot) : null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The advisory-lock key that reserves lease `slot` of `template`. Keyed on the
|
|
104
|
+
* template so two suites with different base databases never compete for slots.
|
|
105
|
+
*/
|
|
106
|
+
function leaseLockKey(template, slot) {
|
|
107
|
+
return `stratal:worker-db-lease:${template}:${slot}`;
|
|
108
|
+
}
|
|
109
|
+
async function withAdminClient(adminConn, fn) {
|
|
110
|
+
const client = new (await (importPg$1())).Client({ connectionString: adminConn });
|
|
111
|
+
await client.connect();
|
|
112
|
+
try {
|
|
113
|
+
return await fn((sql) => client.query(sql));
|
|
114
|
+
} finally {
|
|
115
|
+
await client.end();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* True for SQLSTATE 55006 ("source database is being accessed by other users") —
|
|
120
|
+
* what `CREATE DATABASE ... TEMPLATE t` raises while another session is using the
|
|
121
|
+
* template (e.g. a concurrent clone of the same template).
|
|
122
|
+
*/
|
|
123
|
+
function isTemplateInUse(error) {
|
|
124
|
+
return error?.code === "55006";
|
|
125
|
+
}
|
|
126
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
127
|
+
/**
|
|
128
|
+
* Replace worker database `dbName` with a fresh clone of `template`.
|
|
129
|
+
*
|
|
130
|
+
* The clone is serialized across all workers and processes by one Postgres
|
|
131
|
+
* advisory lock keyed on the template. Postgres permits only one
|
|
132
|
+
* `CREATE DATABASE ... TEMPLATE t` at a time — a concurrent one fails with
|
|
133
|
+
* SQLSTATE 55006 — so without the lock every file starting at once would race
|
|
134
|
+
* and all but one would fail. The lock funnels them one at a time, each
|
|
135
|
+
* blocking *in Postgres* (not busy-waiting) until its turn. A create that still
|
|
136
|
+
* hits a transient 55006 is retried with backoff.
|
|
137
|
+
*
|
|
138
|
+
* `WITH (FORCE)` ends connections the previous lessee's pool left open: its
|
|
139
|
+
* isolate is gone, so nothing will close them.
|
|
140
|
+
*/
|
|
141
|
+
async function cloneWorkerDatabase(adminConn, dbName, template) {
|
|
142
|
+
await withAdminClient(adminConn, async (query) => {
|
|
143
|
+
const lockKey = quoteLiteral(`stratal:worker-db-clone:${template}`);
|
|
144
|
+
await query(`SELECT pg_advisory_lock(hashtext(${lockKey}))`);
|
|
145
|
+
try {
|
|
146
|
+
await query(`DROP DATABASE IF EXISTS ${quoteIdent$1(dbName)} WITH (FORCE)`);
|
|
147
|
+
const create = `CREATE DATABASE ${quoteIdent$1(dbName)} TEMPLATE ${quoteIdent$1(template)}`;
|
|
148
|
+
for (let attempt = 1;; attempt++) try {
|
|
149
|
+
await query(create);
|
|
150
|
+
return;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (isTemplateInUse(error) && attempt < 5) {
|
|
153
|
+
await sleep(250 * attempt);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
await query(`SELECT pg_advisory_unlock(hashtext(${lockKey}))`);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The connections holding this isolate's leases. A lease is a session-level
|
|
165
|
+
* advisory lock, so it lasts exactly as long as its connection: kept open here
|
|
166
|
+
* for the isolate's lifetime, it closes — and the slot frees — when the pool
|
|
167
|
+
* disposes the isolate at the end of its test file.
|
|
168
|
+
*/
|
|
169
|
+
const leaseConnections = [];
|
|
170
|
+
/**
|
|
171
|
+
* Lease the lowest free worker database for `base` and fill it with a fresh
|
|
172
|
+
* clone of the template.
|
|
173
|
+
*
|
|
174
|
+
* A slot is free when no other session holds its advisory lock. The pool runs
|
|
175
|
+
* each test file in its own isolate and disposes that isolate when the file
|
|
176
|
+
* ends, so a run never holds more slots than it runs files at once — and so
|
|
177
|
+
* never more worker databases. Every lease re-clones, so a file starts from
|
|
178
|
+
* exactly the template, including anything `prepare` baked into it.
|
|
179
|
+
*/
|
|
180
|
+
async function leaseWorkerDatabase(base) {
|
|
181
|
+
const pg = await importPg$1();
|
|
182
|
+
const adminConn = deriveAdminConnectionString(base);
|
|
183
|
+
const template = deriveTemplateName(base);
|
|
184
|
+
const client = new pg.Client({ connectionString: adminConn });
|
|
185
|
+
await client.connect();
|
|
186
|
+
try {
|
|
187
|
+
let slot = 0;
|
|
188
|
+
for (;; slot++) {
|
|
189
|
+
const { rows } = await client.query(`SELECT pg_try_advisory_lock(hashtext(${quoteLiteral(leaseLockKey(template, slot))})) AS acquired`);
|
|
190
|
+
if (rows[0]?.acquired) break;
|
|
191
|
+
}
|
|
192
|
+
const name = deriveWorkerDbName(base, slot);
|
|
193
|
+
await cloneWorkerDatabase(adminConn, name, template);
|
|
194
|
+
leaseConnections.push(client);
|
|
195
|
+
return {
|
|
196
|
+
name,
|
|
197
|
+
connectionString: buildConnectionString(base, name)
|
|
198
|
+
};
|
|
199
|
+
} catch (error) {
|
|
200
|
+
await client.end().catch(() => {});
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Drop worker databases nobody is using, leaving a concurrent process's live
|
|
206
|
+
* ones intact.
|
|
207
|
+
*
|
|
208
|
+
* Multiple setups can run at once (CI sharding, several e2e projects), so a
|
|
209
|
+
* database is dropped only when it has **no active backend connections** and
|
|
210
|
+
* its lease slot is free. The slot check closes the window between a lease and
|
|
211
|
+
* the lessee's first connection: a just-cloned database has no connections yet
|
|
212
|
+
* but is already spoken for. The sweep takes the slot's lock itself while it
|
|
213
|
+
* drops, so no lease can begin mid-drop.
|
|
214
|
+
*/
|
|
215
|
+
async function sweepStaleDatabases(adminConn, prefix, template) {
|
|
216
|
+
const likePrefix = prefix.replace(/'/g, "''").replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
217
|
+
await withAdminClient(adminConn, async (query) => {
|
|
218
|
+
const { rows } = await query(`SELECT d.datname FROM pg_database d WHERE d.datname LIKE '${likePrefix}%' ESCAPE '\\' AND NOT EXISTS (SELECT 1 FROM pg_stat_activity a WHERE a.datname = d.datname)`);
|
|
219
|
+
for (const { datname } of rows) {
|
|
220
|
+
const slot = slotOfWorkerDb(prefix, datname);
|
|
221
|
+
if (slot === null) continue;
|
|
222
|
+
const lockKey = quoteLiteral(leaseLockKey(template, slot));
|
|
223
|
+
const { rows: lock } = await query(`SELECT pg_try_advisory_lock(hashtext(${lockKey})) AS acquired`);
|
|
224
|
+
if (!lock[0]?.acquired) continue;
|
|
225
|
+
try {
|
|
226
|
+
await query(`DROP DATABASE IF EXISTS ${quoteIdent$1(datname)} WITH (FORCE)`);
|
|
227
|
+
} finally {
|
|
228
|
+
await query(`SELECT pg_advisory_unlock(hashtext(${lockKey}))`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Run `fn` while holding a session-level Postgres advisory lock keyed to
|
|
235
|
+
* `lockKey`, serializing template setup across concurrent processes (CI
|
|
236
|
+
* sharding, multiple e2e projects) so they don't drop/recreate the template out
|
|
237
|
+
* from under each other. The lock is released in `finally`.
|
|
238
|
+
*/
|
|
239
|
+
async function withAdvisoryLock(adminConn, lockKey, fn) {
|
|
240
|
+
return withAdminClient(adminConn, async (query) => {
|
|
241
|
+
await query(`SELECT pg_advisory_lock(hashtext(${quoteLiteral(lockKey)}))`);
|
|
242
|
+
try {
|
|
243
|
+
return await fn();
|
|
244
|
+
} finally {
|
|
245
|
+
await query(`SELECT pg_advisory_unlock(hashtext(${quoteLiteral(lockKey)}))`);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/** Schema source file extensions hashed into the template fingerprint. */
|
|
250
|
+
const SCHEMA_FILE_RE = /\.(zmodel|prisma|sql)$/;
|
|
251
|
+
/** Matches ZModel `import "..."` / `import '...'` statements. */
|
|
252
|
+
const ZMODEL_IMPORT_RE = /^\s*import\s+['"]([^'"]+)['"]/gm;
|
|
253
|
+
/**
|
|
254
|
+
* Follow a ZModel file's `import` graph, collecting the root plus every
|
|
255
|
+
* transitively imported `.zmodel` file — ZenStack supports multi-file schemas,
|
|
256
|
+
* so editing an imported file must invalidate the fingerprint. Import paths
|
|
257
|
+
* resolve relative to the importing file; the `.zmodel` extension is optional.
|
|
258
|
+
* A missing import target is skipped (migration surfaces the real error).
|
|
259
|
+
*/
|
|
260
|
+
function collectZmodelImports(file, seen) {
|
|
261
|
+
if (seen.has(file)) return;
|
|
262
|
+
let content;
|
|
263
|
+
try {
|
|
264
|
+
content = readFileSync(file, "utf8");
|
|
265
|
+
} catch {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
seen.add(file);
|
|
269
|
+
for (const [, importPath] of content.matchAll(ZMODEL_IMPORT_RE)) {
|
|
270
|
+
const target = resolve(dirname(file), importPath);
|
|
271
|
+
collectZmodelImports(target.endsWith(".zmodel") ? target : `${target}.zmodel`, seen);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Expand a schema path into the concrete files to hash. A directory contributes
|
|
276
|
+
* every schema file in its tree; a single `.zmodel` file contributes its whole
|
|
277
|
+
* `import` graph; any other file contributes itself.
|
|
278
|
+
*/
|
|
279
|
+
function collectSchemaFiles(path) {
|
|
280
|
+
if (statSync(path).isDirectory()) {
|
|
281
|
+
const out = [];
|
|
282
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
283
|
+
const full = join(path, entry.name);
|
|
284
|
+
if (entry.isDirectory()) out.push(...collectSchemaFiles(full));
|
|
285
|
+
else if (SCHEMA_FILE_RE.test(entry.name)) out.push(full);
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
if (path.endsWith(".zmodel")) {
|
|
290
|
+
const seen = /* @__PURE__ */ new Set();
|
|
291
|
+
collectZmodelImports(path, seen);
|
|
292
|
+
return [...seen];
|
|
293
|
+
}
|
|
294
|
+
return [path];
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* A content-derived fingerprint of the schema source(s) plus the migrate and
|
|
298
|
+
* prepare routines. The template is reused across runs while this is
|
|
299
|
+
* unchanged; any edit to a schema file — or to how migration/preparation run
|
|
300
|
+
* — changes it and forces a rebuild. Uses file basenames (not absolute paths)
|
|
301
|
+
* so it is stable across checkouts.
|
|
302
|
+
*
|
|
303
|
+
* @internal exported for fingerprint unit tests
|
|
304
|
+
*/
|
|
305
|
+
function computeSchemaFingerprint(schema, migrate, prepare) {
|
|
306
|
+
const roots = Array.isArray(schema) ? schema : [schema];
|
|
307
|
+
const files = roots.flatMap(collectSchemaFiles).sort();
|
|
308
|
+
if (files.length === 0) throw new Error(`[stratal-testing] No schema files found for fingerprinting under: ${roots.join(", ")}`);
|
|
309
|
+
const hash = createHash("sha256");
|
|
310
|
+
for (const file of files) {
|
|
311
|
+
hash.update(basename(file));
|
|
312
|
+
hash.update("\0");
|
|
313
|
+
hash.update(readFileSync(file));
|
|
314
|
+
hash.update("\0");
|
|
315
|
+
}
|
|
316
|
+
hash.update(migrate.toString());
|
|
317
|
+
hash.update("\0");
|
|
318
|
+
hash.update(prepare ? prepare.toString() : "");
|
|
319
|
+
return hash.digest("hex");
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Read the template database's stored schema fingerprint (kept as the database
|
|
323
|
+
* COMMENT). Returns `null` when the template does not exist or carries no
|
|
324
|
+
* fingerprint. Database comments are NOT copied by `CREATE DATABASE ...
|
|
325
|
+
* TEMPLATE`, so worker clones never inherit it.
|
|
326
|
+
*/
|
|
327
|
+
async function readTemplateFingerprint(query, template) {
|
|
328
|
+
const { rows } = await query(`SELECT shobj_description(oid, 'pg_database') AS fingerprint FROM pg_database WHERE datname = ${quoteLiteral(template)}`);
|
|
329
|
+
return rows.length === 0 ? null : rows[0].fingerprint;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Build a Vitest `globalSetup` default export that prepares the test database.
|
|
333
|
+
*
|
|
334
|
+
* Under a Postgres advisory lock (so concurrent setups across CI shards /
|
|
335
|
+
* multiple e2e projects don't clobber each other), sweeps unused worker
|
|
336
|
+
* databases, then ensures a migrated (and optionally prepared) template exists
|
|
337
|
+
* — ready to be cloned into each leased worker database.
|
|
338
|
+
*
|
|
339
|
+
* **Template reuse.** The template is fingerprinted from the `schema` source(s)
|
|
340
|
+
* + the `migrate` routine + the `prepare` routine, and the fingerprint is
|
|
341
|
+
* stored as the template's database COMMENT. On each run, a matching
|
|
342
|
+
* fingerprint means nothing has changed and the existing template is reused
|
|
343
|
+
* as-is — `migrate`/`prepare` run **only** on the first run after an edit (or on
|
|
344
|
+
* a fresh database). The fingerprint is stamped only after a successful
|
|
345
|
+
* migrate + prepare, so a match always implies a complete template; there is no
|
|
346
|
+
* force/skip flag — reuse is purely fingerprint-driven.
|
|
347
|
+
*
|
|
348
|
+
* **Concurrency model.** The reuse check + rebuild runs under
|
|
349
|
+
* `pg_advisory_lock(hashtext(<template>))`, so only one process rebuilds the
|
|
350
|
+
* template at a time. The sweep only drops worker databases with **no active
|
|
351
|
+
* connections** and a free lease slot, leaving a sibling process's live databases
|
|
352
|
+
* intact — which is what lets it run at teardown as well as at setup, so a run
|
|
353
|
+
* takes its own databases with it. Teardown deliberately does **not** drop the
|
|
354
|
+
* template: it is shared, and the next run reuses it by fingerprint.
|
|
355
|
+
*
|
|
356
|
+
* @example
|
|
357
|
+
* ```ts
|
|
358
|
+
* // test/global-setup.ts
|
|
359
|
+
* import { createTestDatabaseGlobalSetup } from '@stratal/testing/database'
|
|
360
|
+
*
|
|
361
|
+
* export default createTestDatabaseGlobalSetup({
|
|
362
|
+
* schema: schemaPath, // file or directory — reused-when-unchanged fingerprint
|
|
363
|
+
* migrate: (conn) => execFileSync(zenstackBin, ['db', 'push', '--force-reset', `--schema=${schemaPath}`, '--accept-data-loss'],
|
|
364
|
+
* { env: { ...process.env, DATABASE_URL: conn }, stdio: 'inherit' }),
|
|
365
|
+
* })
|
|
366
|
+
* ```
|
|
367
|
+
*/
|
|
368
|
+
function createTestDatabaseGlobalSetup(opts) {
|
|
369
|
+
return async () => {
|
|
370
|
+
const base = opts.connectionString ?? process.env.DATABASE_URL;
|
|
371
|
+
if (!base) throw new Error("[stratal-testing] No connection string for test database setup. Set process.env.DATABASE_URL or pass `connectionString`.");
|
|
372
|
+
const adminConn = deriveAdminConnectionString(base);
|
|
373
|
+
const template = opts.templateName ?? deriveTemplateName(base);
|
|
374
|
+
const prefix = databasePrefix(base);
|
|
375
|
+
const leaseTemplate = deriveTemplateName(base);
|
|
376
|
+
const fingerprint = computeSchemaFingerprint(opts.schema, opts.migrate, opts.prepare);
|
|
377
|
+
await withAdvisoryLock(adminConn, template, async () => {
|
|
378
|
+
await sweepStaleDatabases(adminConn, prefix, leaseTemplate);
|
|
379
|
+
if (await withAdminClient(adminConn, (query) => readTemplateFingerprint(query, template)) === fingerprint) return;
|
|
380
|
+
await withAdminClient(adminConn, async (query) => {
|
|
381
|
+
await query(`DROP DATABASE IF EXISTS ${quoteIdent$1(template)} WITH (FORCE)`);
|
|
382
|
+
await query(`CREATE DATABASE ${quoteIdent$1(template)}`);
|
|
383
|
+
});
|
|
384
|
+
const templateConn = buildConnectionString(base, template);
|
|
385
|
+
await opts.migrate(templateConn);
|
|
386
|
+
if (opts.prepare) await opts.prepare(templateConn);
|
|
387
|
+
await withAdminClient(adminConn, (query) => query(`COMMENT ON DATABASE ${quoteIdent$1(template)} IS ${quoteLiteral(fingerprint)}`));
|
|
388
|
+
});
|
|
389
|
+
return async () => {
|
|
390
|
+
await sweepStaleDatabases(adminConn, prefix, leaseTemplate);
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/database/reset.ts
|
|
396
|
+
function quoteIdent(ident) {
|
|
397
|
+
return `"${ident.replace(/"/g, "\"\"")}"`;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Build a single `TRUNCATE ... RESTART IDENTITY CASCADE` (empty when no tables).
|
|
401
|
+
* Takes `{ schema, table }` pairs and quotes each part independently, so a
|
|
402
|
+
* dotted identifier (a legal quoted name, e.g. `"my.table"`) is preserved
|
|
403
|
+
* verbatim rather than mis-split into extra qualifier segments.
|
|
404
|
+
*/
|
|
405
|
+
function buildTruncateSql(tables) {
|
|
406
|
+
if (tables.length === 0) return "";
|
|
407
|
+
return `TRUNCATE ${tables.map((t) => `${quoteIdent(t.schema)}.${quoteIdent(t.table)}`).join(", ")} RESTART IDENTITY CASCADE`;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Sentinel meaning "the active search-path schema" (`current_schema()`). A
|
|
411
|
+
* Symbol so it can never collide with a real schema name — a schema literally
|
|
412
|
+
* named `current` is passed through as the string literal `'current'`.
|
|
413
|
+
*/
|
|
414
|
+
const CURRENT_SCHEMA = Symbol("current_schema");
|
|
415
|
+
/**
|
|
416
|
+
* Build the SQL that discovers truncatable tables: every base table in the
|
|
417
|
+
* given schemas ({@link CURRENT_SCHEMA} → `current_schema()`), excluding
|
|
418
|
+
* migration bookkeeping (`_prisma%`) and any caller-supplied preserve patterns.
|
|
419
|
+
* Returns a SELECT of `schemaname, tablename`. Escaping lives here so both reset
|
|
420
|
+
* paths share one implementation.
|
|
421
|
+
*/
|
|
422
|
+
function buildTableDiscoverySql(schemas, preserve) {
|
|
423
|
+
return `SELECT schemaname::text AS schemaname, tablename::text AS tablename FROM pg_tables WHERE schemaname IN (${[CURRENT_SCHEMA, ...schemas].map((s) => typeof s === "symbol" ? "current_schema()" : `'${s.replace(/'/g, "''")}'`).join(", ")}) AND ${["_prisma%", ...preserve].map((p) => `tablename NOT LIKE '${p.replace(/'/g, "''")}'`).join(" AND ")}`;
|
|
424
|
+
}
|
|
425
|
+
async function importPg() {
|
|
426
|
+
const { default: mod } = await import("pg");
|
|
427
|
+
return mod;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Reset a worker database between tests: discover every non-preserved table in
|
|
431
|
+
* the target schemas and truncate them in one statement. Fast because the
|
|
432
|
+
* schema/DDL is baked into the worker DB — reset only clears mutable rows and
|
|
433
|
+
* resets identities. Opens and closes a short-lived direct `pg` connection.
|
|
434
|
+
*/
|
|
435
|
+
async function resetWorkerDatabase(connectionString, opts = {}) {
|
|
436
|
+
const client = new (await (importPg())).Client({ connectionString });
|
|
437
|
+
await client.connect();
|
|
438
|
+
try {
|
|
439
|
+
const sql = buildTableDiscoverySql(opts.schemas ?? [], opts.preserve ?? []);
|
|
440
|
+
const { rows } = await client.query(sql);
|
|
441
|
+
const truncateSql = buildTruncateSql(rows.map((r) => ({
|
|
442
|
+
schema: r.schemaname,
|
|
443
|
+
table: r.tablename
|
|
444
|
+
})));
|
|
445
|
+
if (truncateSql) await client.query(truncateSql);
|
|
446
|
+
} finally {
|
|
447
|
+
await client.end();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
//#endregion
|
|
451
|
+
export { DEFAULT_DB_BINDING as a, createTestDatabaseGlobalSetup as c, deriveTemplateName as d, deriveWorkerDbName as f, BINDING_ENV_VAR as i, databasePrefix as l, buildTruncateSql as n, buildConnectionString as o, leaseWorkerDatabase as p, resetWorkerDatabase as r, cloneWorkerDatabase as s, buildTableDiscoverySql as t, deriveAdminConnectionString as u };
|
|
452
|
+
|
|
453
|
+
//# sourceMappingURL=database-Rojs69r3.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"database-Rojs69r3.mjs","names":["quoteIdent","importPg"],"sources":["../src/database/test-database.ts","../src/database/reset.ts"],"sourcesContent":["/**\n * Test database isolation helpers.\n *\n * Implements leased worker databases for parallel runs against Postgres. A\n * migrated **template** database is built once in global setup. Each test file\n * leases one numbered worker database for as long as its isolate lives and\n * receives a fresh clone of the template in it, so a run holds at most as many\n * databases as it runs files at once. See `@stratal/testing/database`.\n *\n * `pg` is imported dynamically so this module loads without it — consumers\n * that don't use a database never pay the dependency.\n */\n\nimport { createHash } from 'node:crypto'\nimport { readFileSync, readdirSync, statSync } from 'node:fs'\nimport { basename, dirname, join, resolve } from 'node:path'\n\nimport type pg from 'pg'\n\n/** Env var carrying the name of the Hyperdrive binding to isolate. */\nexport const BINDING_ENV_VAR = 'STRATAL_TEST_DB_BINDING'\n\n/** Default Hyperdrive binding name when none is configured. */\nexport const DEFAULT_DB_BINDING = 'DB'\n\n/**\n * Postgres' identifier length limit. Names longer than this are silently\n * truncated by the server, which would cause distinct logical names to collide\n * on the same physical database. We assert against it everywhere a name is\n * derived.\n */\nconst MAX_IDENTIFIER_LENGTH = 63\n\n/** Quote a Postgres identifier for safe interpolation into DDL. */\nfunction quoteIdent(name: string): string {\n return `\"${name.replace(/\"/g, '\"\"')}\"`\n}\n\n/** Quote a Postgres string literal for safe interpolation into SQL. */\nfunction quoteLiteral(value: string): string {\n return `'${value.replace(/'/g, \"''\")}'`\n}\n\n/**\n * Dynamically import `pg` (an optional peer), surfacing an actionable error\n * instead of a raw module-not-found when database isolation is requested\n * without the dependency installed.\n */\nasync function importPg(): Promise<typeof pg> {\n try {\n const { default: pg } = await import('pg')\n return pg\n } catch (error) {\n throw new Error(\n \"[stratal-testing] `pg` is required for database isolation but is not installed. \" +\n 'Install it: `npm install --save-dev pg` (or `yarn add -D pg`).',\n { cause: error },\n )\n }\n}\n\n/**\n * Assert a derived identifier fits within Postgres' {@link MAX_IDENTIFIER_LENGTH}\n * limit. Throws a clear, actionable error instead of letting the server\n * silently truncate and collide names.\n */\nfunction assertIdentifierLength(name: string, kind: string): void {\n if (name.length > MAX_IDENTIFIER_LENGTH) {\n throw new Error(\n `[stratal-testing] Derived ${kind} \"${name}\" is ${name.length} characters, ` +\n `exceeding Postgres' ${MAX_IDENTIFIER_LENGTH}-character identifier limit. ` +\n 'Use a shorter base database name so the test-isolation suffix fits.',\n )\n }\n}\n\n/** Parse the database name out of a Postgres connection URL. */\nfunction databaseNameOf(connectionString: string): string {\n const name = new URL(connectionString).pathname.replace(/^\\//, '')\n return name || 'postgres'\n}\n\n/**\n * Derive an admin connection string pointing at the `postgres` maintenance\n * database. `CREATE`/`DROP DATABASE` cannot run on a connection bound to the\n * target database, so administration always goes through `postgres`.\n */\nexport function deriveAdminConnectionString(connectionString: string): string {\n const url = new URL(connectionString)\n url.pathname = '/postgres'\n return url.toString()\n}\n\n/** Build a connection string identical to `base` but pointing at `dbName`. */\nexport function buildConnectionString(base: string, dbName: string): string {\n const url = new URL(base)\n url.pathname = `/${dbName}`\n return url.toString()\n}\n\n/** The shared prefix for worker databases, used as the sweep key. */\nexport function databasePrefix(base: string): string {\n const baseName = databaseNameOf(base).replace(/[^a-z0-9_]/gi, '_')\n return `${baseName}_w_`\n}\n\n/** Name of the migrated template database cloned per worker. */\nexport function deriveTemplateName(base: string): string {\n const name = `${databaseNameOf(base).replace(/[^a-z0-9_]/gi, '_')}_template`\n assertIdentifierLength(name, 'template database name')\n return name\n}\n\n/**\n * Name of the worker database behind lease `slot`. Asserted against Postgres'\n * 63-char identifier limit (keep the base name short so `_w_<slot>` fits).\n */\nexport function deriveWorkerDbName(base: string, slot: number): string {\n const baseName = databaseNameOf(base).replace(/[^a-z0-9_]/gi, '_')\n const name = `${baseName}_w_${slot}`\n assertIdentifierLength(name, 'worker database name')\n return name\n}\n\n/** The lease slot a worker database name carries, or `null` for any other name. */\nfunction slotOfWorkerDb(prefix: string, dbName: string): number | null {\n if (!dbName.startsWith(prefix)) return null\n const slot = dbName.slice(prefix.length)\n return /^\\d+$/.test(slot) ? Number(slot) : null\n}\n\n/**\n * The advisory-lock key that reserves lease `slot` of `template`. Keyed on the\n * template so two suites with different base databases never compete for slots.\n */\nfunction leaseLockKey(template: string, slot: number): string {\n return `stratal:worker-db-lease:${template}:${slot}`\n}\n\nasync function withAdminClient<T>(adminConn: string, fn: (query: (sql: string) => Promise<unknown>) => Promise<T>): Promise<T> {\n const pg = await importPg()\n const client = new pg.Client({ connectionString: adminConn })\n await client.connect()\n try {\n return await fn((sql) => client.query(sql))\n } finally {\n await client.end()\n }\n}\n\n/**\n * True for SQLSTATE 55006 (\"source database is being accessed by other users\") —\n * what `CREATE DATABASE ... TEMPLATE t` raises while another session is using the\n * template (e.g. a concurrent clone of the same template).\n */\nfunction isTemplateInUse(error: unknown): boolean {\n return (error as { code?: string })?.code === '55006'\n}\n\nconst sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))\n\n/**\n * Replace worker database `dbName` with a fresh clone of `template`.\n *\n * The clone is serialized across all workers and processes by one Postgres\n * advisory lock keyed on the template. Postgres permits only one\n * `CREATE DATABASE ... TEMPLATE t` at a time — a concurrent one fails with\n * SQLSTATE 55006 — so without the lock every file starting at once would race\n * and all but one would fail. The lock funnels them one at a time, each\n * blocking *in Postgres* (not busy-waiting) until its turn. A create that still\n * hits a transient 55006 is retried with backoff.\n *\n * `WITH (FORCE)` ends connections the previous lessee's pool left open: its\n * isolate is gone, so nothing will close them.\n */\nexport async function cloneWorkerDatabase(\n adminConn: string,\n dbName: string,\n template: string,\n): Promise<void> {\n await withAdminClient(adminConn, async (query) => {\n const lockKey = quoteLiteral(`stratal:worker-db-clone:${template}`)\n await query(`SELECT pg_advisory_lock(hashtext(${lockKey}))`)\n try {\n await query(`DROP DATABASE IF EXISTS ${quoteIdent(dbName)} WITH (FORCE)`)\n const create = `CREATE DATABASE ${quoteIdent(dbName)} TEMPLATE ${quoteIdent(template)}`\n for (let attempt = 1; ; attempt++) {\n try {\n await query(create)\n return\n } catch (error) {\n if (isTemplateInUse(error) && attempt < 5) {\n await sleep(250 * attempt)\n continue\n }\n throw error\n }\n }\n } finally {\n await query(`SELECT pg_advisory_unlock(hashtext(${lockKey}))`)\n }\n })\n}\n\n/** A worker database leased to the current isolate. */\nexport interface WorkerDatabaseLease {\n /** The leased database's name (`<base>_w_<slot>`). */\n name: string\n /** `base` pointed at the leased database. */\n connectionString: string\n}\n\n/**\n * The connections holding this isolate's leases. A lease is a session-level\n * advisory lock, so it lasts exactly as long as its connection: kept open here\n * for the isolate's lifetime, it closes — and the slot frees — when the pool\n * disposes the isolate at the end of its test file.\n */\nconst leaseConnections: pg.Client[] = []\n\n/**\n * Lease the lowest free worker database for `base` and fill it with a fresh\n * clone of the template.\n *\n * A slot is free when no other session holds its advisory lock. The pool runs\n * each test file in its own isolate and disposes that isolate when the file\n * ends, so a run never holds more slots than it runs files at once — and so\n * never more worker databases. Every lease re-clones, so a file starts from\n * exactly the template, including anything `prepare` baked into it.\n */\nexport async function leaseWorkerDatabase(base: string): Promise<WorkerDatabaseLease> {\n const pg = await importPg()\n const adminConn = deriveAdminConnectionString(base)\n const template = deriveTemplateName(base)\n const client = new pg.Client({ connectionString: adminConn })\n await client.connect()\n try {\n let slot = 0\n for (; ; slot++) {\n const { rows } = await client.query<{ acquired: boolean }>(\n `SELECT pg_try_advisory_lock(hashtext(${quoteLiteral(leaseLockKey(template, slot))})) AS acquired`,\n )\n if (rows[0]?.acquired) break\n }\n const name = deriveWorkerDbName(base, slot)\n await cloneWorkerDatabase(adminConn, name, template)\n leaseConnections.push(client)\n return { name, connectionString: buildConnectionString(base, name) }\n } catch (error) {\n await client.end().catch(() => {\n // The lease failed; its connection's close error adds nothing.\n })\n throw error\n }\n}\n\n/**\n * Drop worker databases nobody is using, leaving a concurrent process's live\n * ones intact.\n *\n * Multiple setups can run at once (CI sharding, several e2e projects), so a\n * database is dropped only when it has **no active backend connections** and\n * its lease slot is free. The slot check closes the window between a lease and\n * the lessee's first connection: a just-cloned database has no connections yet\n * but is already spoken for. The sweep takes the slot's lock itself while it\n * drops, so no lease can begin mid-drop.\n */\nasync function sweepStaleDatabases(adminConn: string, prefix: string, template: string): Promise<void> {\n // Escape both the SQL-string quote and the LIKE metacharacters (`\\`, `%`, `_`)\n // so a prefix containing `_` (a single-char wildcard) can't over-match and drop\n // an unrelated database. `\\` is the explicit ESCAPE character below.\n const likePrefix = prefix\n .replace(/'/g, \"''\")\n .replace(/[\\\\%_]/g, (c) => `\\\\${c}`)\n await withAdminClient(adminConn, async (query) => {\n const { rows } = (await query(\n `SELECT d.datname FROM pg_database d ` +\n `WHERE d.datname LIKE '${likePrefix}%' ESCAPE '\\\\' ` +\n `AND NOT EXISTS (SELECT 1 FROM pg_stat_activity a WHERE a.datname = d.datname)`,\n )) as { rows: { datname: string }[] }\n for (const { datname } of rows) {\n const slot = slotOfWorkerDb(prefix, datname)\n if (slot === null) continue\n const lockKey = quoteLiteral(leaseLockKey(template, slot))\n const { rows: lock } = (await query(\n `SELECT pg_try_advisory_lock(hashtext(${lockKey})) AS acquired`,\n )) as { rows: { acquired: boolean }[] }\n if (!lock[0]?.acquired) continue\n try {\n await query(`DROP DATABASE IF EXISTS ${quoteIdent(datname)} WITH (FORCE)`)\n } finally {\n await query(`SELECT pg_advisory_unlock(hashtext(${lockKey}))`)\n }\n }\n })\n}\n\n/**\n * Run `fn` while holding a session-level Postgres advisory lock keyed to\n * `lockKey`, serializing template setup across concurrent processes (CI\n * sharding, multiple e2e projects) so they don't drop/recreate the template out\n * from under each other. The lock is released in `finally`.\n */\nasync function withAdvisoryLock<T>(adminConn: string, lockKey: string, fn: () => Promise<T>): Promise<T> {\n return withAdminClient(adminConn, async (query) => {\n await query(`SELECT pg_advisory_lock(hashtext(${quoteLiteral(lockKey)}))`)\n try {\n return await fn()\n } finally {\n await query(`SELECT pg_advisory_unlock(hashtext(${quoteLiteral(lockKey)}))`)\n }\n })\n}\n\n/** Schema source file extensions hashed into the template fingerprint. */\nconst SCHEMA_FILE_RE = /\\.(zmodel|prisma|sql)$/\n\n/** Matches ZModel `import \"...\"` / `import '...'` statements. */\nconst ZMODEL_IMPORT_RE = /^\\s*import\\s+['\"]([^'\"]+)['\"]/gm\n\n/**\n * Follow a ZModel file's `import` graph, collecting the root plus every\n * transitively imported `.zmodel` file — ZenStack supports multi-file schemas,\n * so editing an imported file must invalidate the fingerprint. Import paths\n * resolve relative to the importing file; the `.zmodel` extension is optional.\n * A missing import target is skipped (migration surfaces the real error).\n */\nfunction collectZmodelImports(file: string, seen: Set<string>): void {\n if (seen.has(file)) return\n let content: string\n try {\n content = readFileSync(file, 'utf8')\n } catch {\n return // missing import target — not part of the fingerprint\n }\n seen.add(file)\n for (const [, importPath] of content.matchAll(ZMODEL_IMPORT_RE)) {\n const target = resolve(dirname(file), importPath)\n collectZmodelImports(target.endsWith('.zmodel') ? target : `${target}.zmodel`, seen)\n }\n}\n\n/**\n * Expand a schema path into the concrete files to hash. A directory contributes\n * every schema file in its tree; a single `.zmodel` file contributes its whole\n * `import` graph; any other file contributes itself.\n */\nfunction collectSchemaFiles(path: string): string[] {\n if (statSync(path).isDirectory()) {\n const out: string[] = []\n for (const entry of readdirSync(path, { withFileTypes: true })) {\n const full = join(path, entry.name)\n if (entry.isDirectory()) out.push(...collectSchemaFiles(full))\n else if (SCHEMA_FILE_RE.test(entry.name)) out.push(full)\n }\n return out\n }\n if (path.endsWith('.zmodel')) {\n const seen = new Set<string>()\n collectZmodelImports(path, seen)\n return [...seen]\n }\n return [path]\n}\n\n/**\n * A content-derived fingerprint of the schema source(s) plus the migrate and\n * prepare routines. The template is reused across runs while this is\n * unchanged; any edit to a schema file — or to how migration/preparation run\n * — changes it and forces a rebuild. Uses file basenames (not absolute paths)\n * so it is stable across checkouts.\n *\n * @internal exported for fingerprint unit tests\n */\nexport function computeSchemaFingerprint(\n schema: string | string[],\n migrate: TestDatabaseGlobalSetupOptions['migrate'],\n prepare?: TestDatabaseGlobalSetupOptions['prepare'],\n): string {\n const roots = Array.isArray(schema) ? schema : [schema]\n const files = roots.flatMap(collectSchemaFiles).sort()\n if (files.length === 0) {\n throw new Error(\n `[stratal-testing] No schema files found for fingerprinting under: ${roots.join(', ')}`,\n )\n }\n const hash = createHash('sha256')\n for (const file of files) {\n hash.update(basename(file))\n hash.update('\\0')\n hash.update(readFileSync(file))\n hash.update('\\0')\n }\n hash.update(migrate.toString())\n hash.update('\\0')\n hash.update(prepare ? prepare.toString() : '')\n return hash.digest('hex')\n}\n\n/**\n * Read the template database's stored schema fingerprint (kept as the database\n * COMMENT). Returns `null` when the template does not exist or carries no\n * fingerprint. Database comments are NOT copied by `CREATE DATABASE ...\n * TEMPLATE`, so worker clones never inherit it.\n */\nasync function readTemplateFingerprint(\n query: (sql: string) => Promise<unknown>,\n template: string,\n): Promise<string | null> {\n const { rows } = (await query(\n `SELECT shobj_description(oid, 'pg_database') AS fingerprint ` +\n `FROM pg_database WHERE datname = ${quoteLiteral(template)}`,\n )) as { rows: { fingerprint: string | null }[] }\n return rows.length === 0 ? null : rows[0].fingerprint\n}\n\n/** Options for {@link createTestDatabaseGlobalSetup}. */\nexport interface TestDatabaseGlobalSetupOptions {\n /** Run migrations against the template connection string. */\n migrate: (connectionString: string) => void | Promise<void>\n /**\n * Schema source(s) — file or directory path(s). Contents + `migrate` + `prepare`\n * are hashed into the template fingerprint; the template is reused across runs\n * while unchanged. For a ZenStack multi-file schema pass the root `.zmodel`.\n */\n schema: string | string[]\n /**\n * One-time preparation run against the template **after** migrations (before\n * the fingerprint is stamped). Bake expensive baseline state here — seed data,\n * a default tenant schema, reference rows — so every worker database inherits\n * it via the clone instead of rebuilding it per test.\n *\n * The fingerprint hashes this hook's **source text only** — if `prepare` reads\n * external data files (seed JSON/SQL) at runtime, changing only those files\n * will NOT invalidate the template. Bump the hook's source (e.g. a version\n * comment) or drop the template manually when seed data changes.\n */\n prepare?: (connectionString: string) => void | Promise<void>\n /** Base/admin connection string. Defaults to `process.env.DATABASE_URL`. */\n connectionString?: string\n /** Template database name. Defaults to `<baseDbName>_template`. */\n templateName?: string\n}\n\n/**\n * Build a Vitest `globalSetup` default export that prepares the test database.\n *\n * Under a Postgres advisory lock (so concurrent setups across CI shards /\n * multiple e2e projects don't clobber each other), sweeps unused worker\n * databases, then ensures a migrated (and optionally prepared) template exists\n * — ready to be cloned into each leased worker database.\n *\n * **Template reuse.** The template is fingerprinted from the `schema` source(s)\n * + the `migrate` routine + the `prepare` routine, and the fingerprint is\n * stored as the template's database COMMENT. On each run, a matching\n * fingerprint means nothing has changed and the existing template is reused\n * as-is — `migrate`/`prepare` run **only** on the first run after an edit (or on\n * a fresh database). The fingerprint is stamped only after a successful\n * migrate + prepare, so a match always implies a complete template; there is no\n * force/skip flag — reuse is purely fingerprint-driven.\n *\n * **Concurrency model.** The reuse check + rebuild runs under\n * `pg_advisory_lock(hashtext(<template>))`, so only one process rebuilds the\n * template at a time. The sweep only drops worker databases with **no active\n * connections** and a free lease slot, leaving a sibling process's live databases\n * intact — which is what lets it run at teardown as well as at setup, so a run\n * takes its own databases with it. Teardown deliberately does **not** drop the\n * template: it is shared, and the next run reuses it by fingerprint.\n *\n * @example\n * ```ts\n * // test/global-setup.ts\n * import { createTestDatabaseGlobalSetup } from '@stratal/testing/database'\n *\n * export default createTestDatabaseGlobalSetup({\n * schema: schemaPath, // file or directory — reused-when-unchanged fingerprint\n * migrate: (conn) => execFileSync(zenstackBin, ['db', 'push', '--force-reset', `--schema=${schemaPath}`, '--accept-data-loss'],\n * { env: { ...process.env, DATABASE_URL: conn }, stdio: 'inherit' }),\n * })\n * ```\n */\nexport function createTestDatabaseGlobalSetup(\n opts: TestDatabaseGlobalSetupOptions,\n): () => Promise<() => Promise<void>> {\n return async () => {\n const base = opts.connectionString ?? process.env.DATABASE_URL\n if (!base) {\n throw new Error(\n '[stratal-testing] No connection string for test database setup. Set process.env.DATABASE_URL or pass `connectionString`.',\n )\n }\n\n const adminConn = deriveAdminConnectionString(base)\n const template = opts.templateName ?? deriveTemplateName(base)\n const prefix = databasePrefix(base)\n // Leases are keyed on the derived template name, so the sweep checks the same keys.\n const leaseTemplate = deriveTemplateName(base)\n const fingerprint = computeSchemaFingerprint(opts.schema, opts.migrate, opts.prepare)\n\n // Serialize template rebuild across concurrent setups so they don't drop or\n // migrate the template out from under each other.\n await withAdvisoryLock(adminConn, template, async () => {\n await sweepStaleDatabases(adminConn, prefix, leaseTemplate)\n\n // Reuse the existing template when its stored fingerprint matches — the\n // schema is unchanged, so the migrated template is ready to clone. Only a\n // fingerprint mismatch (or a missing template) pays the migration cost.\n const current = await withAdminClient(adminConn, (query) =>\n readTemplateFingerprint(query, template),\n )\n if (current === fingerprint) return\n\n await withAdminClient(adminConn, async (query) => {\n await query(`DROP DATABASE IF EXISTS ${quoteIdent(template)} WITH (FORCE)`)\n await query(`CREATE DATABASE ${quoteIdent(template)}`)\n })\n const templateConn = buildConnectionString(base, template)\n await opts.migrate(templateConn)\n if (opts.prepare) await opts.prepare(templateConn)\n // Stamp the fingerprint only after a successful migrate + prepare, so a\n // matching fingerprint always implies a complete, ready template. Database\n // COMMENTs are not copied by CREATE DATABASE ... TEMPLATE, so clones stay\n // clean.\n await withAdminClient(adminConn, (query) =>\n query(`COMMENT ON DATABASE ${quoteIdent(template)} IS ${quoteLiteral(fingerprint)}`),\n )\n })\n\n // The run reclaims its own worker databases as it ends.\n //\n // The sweep matches on the base database's name, so the only process that\n // ever sees these databases is one configured with that same name. Nothing\n // else will reach them, which is why the sweep belongs here as well as at\n // setup — and why a pile of them is otherwise bounded by nothing.\n //\n // Connection- and lease-guarded, which is what makes it safe here: by now\n // this run's files have finished and hold neither, while a concurrent\n // process's databases are still leased and are skipped. The template is\n // shared and reused by fingerprint, so it stays.\n //\n // Once per run, not once per file: the drop is trivial, but reaching the\n // maintenance database to issue it is not, and a per-file hook pays that\n // connection on every spec file.\n return async () => {\n await sweepStaleDatabases(adminConn, prefix, leaseTemplate)\n }\n }\n}\n","import type pg from 'pg'\n\n/** Options controlling which tables a per-test reset truncates. */\nexport interface ResetOptions {\n /** Extra schemas to include beyond `current_schema()` (e.g. a tenant schema). */\n schemas?: string[]\n /**\n * Bare table names or SQL LIKE patterns (matched against `tablename` across\n * all target schemas) that must survive the reset — reference/seed data baked\n * into the template. Not schema-qualified: a `schema.table` entry matches a\n * table literally named `schema.table`, not `table` in `schema`. Migration\n * bookkeeping (`_prisma%`) is always preserved.\n */\n preserve?: string[]\n}\n\nfunction quoteIdent(ident: string): string {\n return `\"${ident.replace(/\"/g, '\"\"')}\"`\n}\n\n/**\n * Build a single `TRUNCATE ... RESTART IDENTITY CASCADE` (empty when no tables).\n * Takes `{ schema, table }` pairs and quotes each part independently, so a\n * dotted identifier (a legal quoted name, e.g. `\"my.table\"`) is preserved\n * verbatim rather than mis-split into extra qualifier segments.\n */\nexport function buildTruncateSql(tables: { schema: string; table: string }[]): string {\n if (tables.length === 0) return ''\n const quoted = tables.map((t) => `${quoteIdent(t.schema)}.${quoteIdent(t.table)}`)\n return `TRUNCATE ${quoted.join(', ')} RESTART IDENTITY CASCADE`\n}\n\n/**\n * Sentinel meaning \"the active search-path schema\" (`current_schema()`). A\n * Symbol so it can never collide with a real schema name — a schema literally\n * named `current` is passed through as the string literal `'current'`.\n */\nconst CURRENT_SCHEMA = Symbol('current_schema')\n\n/**\n * Build the SQL that discovers truncatable tables: every base table in the\n * given schemas ({@link CURRENT_SCHEMA} → `current_schema()`), excluding\n * migration bookkeeping (`_prisma%`) and any caller-supplied preserve patterns.\n * Returns a SELECT of `schemaname, tablename`. Escaping lives here so both reset\n * paths share one implementation.\n */\nexport function buildTableDiscoverySql(schemas: string[], preserve: string[]): string {\n const schemaFilter = [CURRENT_SCHEMA, ...schemas]\n .map((s) => (typeof s === 'symbol' ? 'current_schema()' : `'${s.replace(/'/g, \"''\")}'`))\n .join(', ')\n const notLike = ['_prisma%', ...preserve]\n .map((p) => `tablename NOT LIKE '${p.replace(/'/g, \"''\")}'`)\n .join(' AND ')\n return `SELECT schemaname::text AS schemaname, tablename::text AS tablename FROM pg_tables WHERE schemaname IN (${schemaFilter}) AND ${notLike}`\n}\n\nasync function importPg(): Promise<typeof pg> {\n const { default: mod } = await import('pg')\n return mod\n}\n\n/**\n * Reset a worker database between tests: discover every non-preserved table in\n * the target schemas and truncate them in one statement. Fast because the\n * schema/DDL is baked into the worker DB — reset only clears mutable rows and\n * resets identities. Opens and closes a short-lived direct `pg` connection.\n */\nexport async function resetWorkerDatabase(connectionString: string, opts: ResetOptions = {}): Promise<void> {\n const pgMod = await importPg()\n const client = new pgMod.Client({ connectionString })\n await client.connect()\n try {\n const sql = buildTableDiscoverySql(opts.schemas ?? [], opts.preserve ?? [])\n const { rows } = await client.query<{ schemaname: string; tablename: string }>(sql)\n const tables = rows.map((r) => ({ schema: r.schemaname, table: r.tablename }))\n const truncateSql = buildTruncateSql(tables)\n if (truncateSql) await client.query(truncateSql)\n } finally {\n await client.end()\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorate.js
|
|
2
2
|
function __decorate(decorators, target, key, desc) {
|
|
3
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as FakeFeatureFlagService, t as FEATURE_FLAG_SERVICE_TOKEN } from "../feature-flags-
|
|
1
|
+
import { n as FakeFeatureFlagService, t as FEATURE_FLAG_SERVICE_TOKEN } from "../feature-flags-CZ1a4M2g.mjs";
|
|
2
2
|
export { FEATURE_FLAG_SERVICE_TOKEN, FakeFeatureFlagService };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as __decorate } from "./decorate-
|
|
1
|
+
import { t as __decorate } from "./decorate-RQD1h28J.mjs";
|
|
2
2
|
import { Singleton } from "stratal/di";
|
|
3
3
|
//#region src/feature-flags/fake-feature-flag.service.ts
|
|
4
4
|
/**
|
|
@@ -83,4 +83,4 @@ FakeFeatureFlagService = __decorate([Singleton(FEATURE_FLAG_SERVICE_TOKEN)], Fak
|
|
|
83
83
|
//#endregion
|
|
84
84
|
export { FakeFeatureFlagService as n, FEATURE_FLAG_SERVICE_TOKEN as t };
|
|
85
85
|
|
|
86
|
-
//# sourceMappingURL=feature-flags-
|
|
86
|
+
//# sourceMappingURL=feature-flags-CZ1a4M2g.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feature-flags-
|
|
1
|
+
{"version":3,"file":"feature-flags-CZ1a4M2g.mjs","names":[],"sources":["../src/feature-flags/fake-feature-flag.service.ts"],"sourcesContent":["import { Singleton } from 'stratal/di';\n\n/**\n * Global DI token for the feature-flags service.\n *\n * Mirrors `FEATURE_FLAG_TOKENS.FeatureFlagService` from `@stratal/feature-flags`\n * — `Symbol.for(...)` resolves to the same symbol across packages via the global\n * registry. Declared here so `@stratal/testing` needs no dependency on the\n * optional feature-flags package. Keep this string in sync with\n * `packages/feature-flags/src/feature-flags.tokens.ts`.\n */\nexport const FEATURE_FLAG_SERVICE_TOKEN = Symbol.for('stratal:feature-flags:service')\n\n/** A value a feature flag can resolve to. */\nexport type FlagValue = boolean | string | number | object\n\ninterface FakeFlagDetails<T> {\n flagKey: string\n value: T\n reason: string\n}\n\n/**\n * FakeFeatureFlagService\n *\n * In-memory stand-in for `@stratal/feature-flags`'s request-scoped\n * `FeatureFlagService`, auto-registered by the testing module so feature-gated\n * code resolves without a real Cloudflare Flagship binding (which only exists at\n * runtime). Mirrors the real service's public evaluation surface.\n *\n * Unset flags return the per-call default (or the type's zero value). Configure\n * values with {@link set} / {@link setAll}; access it in tests via\n * `module.featureFlags`.\n *\n * @example\n * ```typescript\n * module.featureFlags.set('new-checkout', true)\n * const enabled = await flags.getBooleanValue('new-checkout') // true\n * ```\n */\n@Singleton(FEATURE_FLAG_SERVICE_TOKEN)\nexport class FakeFeatureFlagService {\n private readonly flags = new Map<string, FlagValue>()\n\n // ==================== CONFIGURATION ====================\n\n /** Set a single flag value. */\n set(flagKey: string, value: FlagValue): this {\n this.flags.set(flagKey, value)\n return this\n }\n\n /** Replace all configured flags with the given map. */\n setAll(flags: Record<string, FlagValue>): this {\n this.reset()\n for (const [key, value] of Object.entries(flags)) this.flags.set(key, value)\n return this\n }\n\n /** Clear every configured flag. */\n reset(): this {\n this.flags.clear()\n return this\n }\n\n // ==================== EVALUATION ====================\n\n async get(flagKey: string, defaultValue?: unknown): Promise<unknown> {\n return Promise.resolve(this.flags.has(flagKey) ? this.flags.get(flagKey) : defaultValue)\n }\n\n async getBooleanValue(flagKey: string, defaultValue = false): Promise<boolean> {\n return this.resolve(flagKey, defaultValue)\n }\n\n async getStringValue(flagKey: string, defaultValue = ''): Promise<string> {\n return this.resolve(flagKey, defaultValue)\n }\n\n async getNumberValue(flagKey: string, defaultValue = 0): Promise<number> {\n return this.resolve(flagKey, defaultValue)\n }\n\n async getObjectValue<T extends object>(flagKey: string, defaultValue: T = {} as T): Promise<T> {\n return this.resolve(flagKey, defaultValue)\n }\n\n async getBooleanDetails(flagKey: string, defaultValue = false): Promise<FakeFlagDetails<boolean>> {\n return this.details(flagKey, await this.getBooleanValue(flagKey, defaultValue))\n }\n\n async getStringDetails(flagKey: string, defaultValue = ''): Promise<FakeFlagDetails<string>> {\n return this.details(flagKey, await this.getStringValue(flagKey, defaultValue))\n }\n\n async getNumberDetails(flagKey: string, defaultValue = 0): Promise<FakeFlagDetails<number>> {\n return this.details(flagKey, await this.getNumberValue(flagKey, defaultValue))\n }\n\n async getObjectDetails<T extends object>(flagKey: string, defaultValue: T = {} as T): Promise<FakeFlagDetails<T>> {\n return this.details(flagKey, await this.getObjectValue(flagKey, defaultValue))\n }\n\n /** Returns every configured flag as a `{ key: value }` map. */\n async all(): Promise<Record<string, FlagValue>> {\n return Promise.resolve(Object.fromEntries(this.flags))\n }\n\n /** Switching Flagship apps is a no-op in the fake. */\n use(): this {\n return this\n }\n\n /** The binding name this instance targets. */\n // oxlint-disable-next-line typescript/class-literal-property-style\n get app(): string {\n return 'fake'\n }\n\n // ==================== INTERNAL ====================\n\n private resolve<T extends FlagValue>(flagKey: string, defaultValue: T): Promise<T> {\n return Promise.resolve(this.flags.has(flagKey) ? (this.flags.get(flagKey) as T) : defaultValue)\n }\n\n private details<T>(flagKey: string, value: T): FakeFlagDetails<T> {\n return { flagKey, value, reason: 'fake' }\n }\n}\n"],"mappings":";;;;;;;;;;;;AAWA,MAAa,6BAA6B,OAAO,IAAI,+BAA+B;AA8B7E,IAAM,yBAAN,MAAM,uBAAuB;CAClC,wBAAyB,IAAI,IAAuB;;CAKpD,IAAI,SAAiB,OAAwB;EAC3C,KAAK,MAAM,IAAI,SAAS,KAAK;EAC7B,OAAO;CACT;;CAGA,OAAO,OAAwC;EAC7C,KAAK,MAAM;EACX,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,GAAG,KAAK,MAAM,IAAI,KAAK,KAAK;EAC3E,OAAO;CACT;;CAGA,QAAc;EACZ,KAAK,MAAM,MAAM;EACjB,OAAO;CACT;CAIA,MAAM,IAAI,SAAiB,cAA0C;EACnE,OAAO,QAAQ,QAAQ,KAAK,MAAM,IAAI,OAAO,IAAI,KAAK,MAAM,IAAI,OAAO,IAAI,YAAY;CACzF;CAEA,MAAM,gBAAgB,SAAiB,eAAe,OAAyB;EAC7E,OAAO,KAAK,QAAQ,SAAS,YAAY;CAC3C;CAEA,MAAM,eAAe,SAAiB,eAAe,IAAqB;EACxE,OAAO,KAAK,QAAQ,SAAS,YAAY;CAC3C;CAEA,MAAM,eAAe,SAAiB,eAAe,GAAoB;EACvE,OAAO,KAAK,QAAQ,SAAS,YAAY;CAC3C;CAEA,MAAM,eAAiC,SAAiB,eAAkB,CAAC,GAAoB;EAC7F,OAAO,KAAK,QAAQ,SAAS,YAAY;CAC3C;CAEA,MAAM,kBAAkB,SAAiB,eAAe,OAA0C;EAChG,OAAO,KAAK,QAAQ,SAAS,MAAM,KAAK,gBAAgB,SAAS,YAAY,CAAC;CAChF;CAEA,MAAM,iBAAiB,SAAiB,eAAe,IAAsC;EAC3F,OAAO,KAAK,QAAQ,SAAS,MAAM,KAAK,eAAe,SAAS,YAAY,CAAC;CAC/E;CAEA,MAAM,iBAAiB,SAAiB,eAAe,GAAqC;EAC1F,OAAO,KAAK,QAAQ,SAAS,MAAM,KAAK,eAAe,SAAS,YAAY,CAAC;CAC/E;CAEA,MAAM,iBAAmC,SAAiB,eAAkB,CAAC,GAAqC;EAChH,OAAO,KAAK,QAAQ,SAAS,MAAM,KAAK,eAAe,SAAS,YAAY,CAAC;CAC/E;;CAGA,MAAM,MAA0C;EAC9C,OAAO,QAAQ,QAAQ,OAAO,YAAY,KAAK,KAAK,CAAC;CACvD;;CAGA,MAAY;EACV,OAAO;CACT;;CAIA,IAAI,MAAc;EAChB,OAAO;CACT;CAIA,QAAqC,SAAiB,cAA6B;EACjF,OAAO,QAAQ,QAAQ,KAAK,MAAM,IAAI,OAAO,IAAK,KAAK,MAAM,IAAI,OAAO,IAAU,YAAY;CAChG;CAEA,QAAmB,SAAiB,OAA8B;EAChE,OAAO;GAAE;GAAS;GAAO,QAAQ;EAAO;CAC1C;AACF;AAxFC,yBAAA,WAAA,CAAA,UAAU,0BAA0B,CAAA,GAAA,sBAAA"}
|