@stratal/testing 0.0.21 → 0.0.23
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/README.md +0 -17
- package/dist/database/index.d.mts +2 -0
- package/dist/database/index.mjs +2 -0
- package/dist/database-B02eYKhE.mjs +334 -0
- package/dist/database-B02eYKhE.mjs.map +1 -0
- package/dist/decorate-B7nr7eBl.mjs +9 -0
- package/dist/feature-flags/index.d.mts +2 -0
- package/dist/feature-flags/index.mjs +2 -0
- package/dist/feature-flags-BiLhfSGh.mjs +86 -0
- package/dist/feature-flags-BiLhfSGh.mjs.map +1 -0
- package/dist/index-BIr5nLof.d.mts +122 -0
- package/dist/index-BIr5nLof.d.mts.map +1 -0
- package/dist/{index-D-Q2cR2v.d.mts → index-CrHzUDKX.d.mts} +1 -1
- package/dist/index-CrHzUDKX.d.mts.map +1 -0
- package/dist/index-qgWNJRdC.d.mts +65 -0
- package/dist/index-qgWNJRdC.d.mts.map +1 -0
- package/dist/index.d.mts +29 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +98 -68
- package/dist/index.mjs.map +1 -1
- package/dist/mocks/zenstack-language.d.mts.map +1 -1
- package/dist/mocks/zenstack-language.mjs.map +1 -1
- package/dist/storage/index.d.mts +2 -2
- package/dist/storage/index.mjs +1 -1
- package/dist/{storage-CIXR3QUE.mjs → storage-DhoxWqyF.mjs} +5 -18
- package/dist/{storage-CIXR3QUE.mjs.map → storage-DhoxWqyF.mjs.map} +1 -1
- package/dist/vitest-plugin/index.d.mts +71 -5
- package/dist/vitest-plugin/index.d.mts.map +1 -1
- package/dist/vitest-plugin/index.mjs +82 -11
- package/dist/vitest-plugin/index.mjs.map +1 -1
- package/package.json +26 -18
- package/dist/index-D-Q2cR2v.d.mts.map +0 -1
- package/dist/mocks/nodemailer.d.mts +0 -12
- package/dist/mocks/nodemailer.d.mts.map +0 -1
- package/dist/mocks/nodemailer.mjs +0 -7
- package/dist/mocks/nodemailer.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -37,7 +37,6 @@ Set up base modules once in your Vitest setup file, then create test modules in
|
|
|
37
37
|
|
|
38
38
|
```typescript
|
|
39
39
|
// vitest.setup.ts
|
|
40
|
-
import 'reflect-metadata'
|
|
41
40
|
import { Test } from '@stratal/testing'
|
|
42
41
|
import { CoreModule } from './src/core.module'
|
|
43
42
|
|
|
@@ -316,27 +315,11 @@ beforeEach(() => {
|
|
|
316
315
|
})
|
|
317
316
|
```
|
|
318
317
|
|
|
319
|
-
### Nodemailer mock
|
|
320
|
-
|
|
321
|
-
A drop-in mock for nodemailer, useful in Vitest's module mocking:
|
|
322
|
-
|
|
323
|
-
```typescript
|
|
324
|
-
// vitest.config.ts (or inline vi.mock)
|
|
325
|
-
export default defineConfig({
|
|
326
|
-
test: {
|
|
327
|
-
alias: {
|
|
328
|
-
nodemailer: '@stratal/testing/mocks/nodemailer',
|
|
329
|
-
},
|
|
330
|
-
},
|
|
331
|
-
})
|
|
332
|
-
```
|
|
333
|
-
|
|
334
318
|
## Sub-path Exports
|
|
335
319
|
|
|
336
320
|
```typescript
|
|
337
321
|
import { Test, TestingModule, createFetchMock } from '@stratal/testing'
|
|
338
322
|
import { createMock, type DeepMocked } from '@stratal/testing/mocks'
|
|
339
|
-
import nodemailer from '@stratal/testing/mocks/nodemailer'
|
|
340
323
|
```
|
|
341
324
|
|
|
342
325
|
## License
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as TestDatabaseGlobalSetupOptions, c as createTestDatabaseGlobalSetup, d as deriveDbName, f as deriveTemplateName, i as ISOLATION_ENV_VAR, l as databasePrefix, m as normalizeIsolation, n as DEFAULT_DB_BINDING, o as buildConnectionString, p as dropDatabase, r as DatabaseIsolation, s as createDatabaseFromTemplate, t as BINDING_ENV_VAR, u as deriveAdminConnectionString } from "../index-BIr5nLof.mjs";
|
|
2
|
+
export { BINDING_ENV_VAR, DEFAULT_DB_BINDING, type DatabaseIsolation, ISOLATION_ENV_VAR, type TestDatabaseGlobalSetupOptions, buildConnectionString, createDatabaseFromTemplate, createTestDatabaseGlobalSetup, databasePrefix, deriveAdminConnectionString, deriveDbName, deriveTemplateName, dropDatabase, normalizeIsolation };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as createDatabaseFromTemplate, c as deriveAdminConnectionString, d as dropDatabase, f as normalizeIsolation, i as buildConnectionString, l as deriveDbName, n as DEFAULT_DB_BINDING, o as createTestDatabaseGlobalSetup, r as ISOLATION_ENV_VAR, s as databasePrefix, t as BINDING_ENV_VAR, u as deriveTemplateName } from "../database-B02eYKhE.mjs";
|
|
2
|
+
export { BINDING_ENV_VAR, DEFAULT_DB_BINDING, ISOLATION_ENV_VAR, buildConnectionString, createDatabaseFromTemplate, createTestDatabaseGlobalSetup, databasePrefix, deriveAdminConnectionString, deriveDbName, deriveTemplateName, dropDatabase, normalizeIsolation };
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { createHash, randomUUID } 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 database-per-test-file isolation for parallel e2e runs against
|
|
9
|
+
* Postgres. A migrated **template** database is built once in global setup;
|
|
10
|
+
* each test file clones it instantly via `CREATE DATABASE ... TEMPLATE` and
|
|
11
|
+
* drops it on teardown. See `@stratal/testing/database`.
|
|
12
|
+
*
|
|
13
|
+
* `pg` is imported dynamically so this module loads without it — consumers
|
|
14
|
+
* that don't use a database never pay the dependency.
|
|
15
|
+
*/
|
|
16
|
+
/** Env var that selects the isolation mode (single source of truth). */
|
|
17
|
+
const ISOLATION_ENV_VAR = "STRATAL_TEST_DB_ISOLATION";
|
|
18
|
+
/** Env var carrying the name of the Hyperdrive binding to isolate. */
|
|
19
|
+
const BINDING_ENV_VAR = "STRATAL_TEST_DB_BINDING";
|
|
20
|
+
/** Default Hyperdrive binding name when none is configured. */
|
|
21
|
+
const DEFAULT_DB_BINDING = "DB";
|
|
22
|
+
/**
|
|
23
|
+
* Normalize an isolation value (from an option or env var) to a mode.
|
|
24
|
+
* Anything other than the literal `'database'` resolves to `'shared'` — the
|
|
25
|
+
* default — so parallel isolation is strictly opt-in.
|
|
26
|
+
*/
|
|
27
|
+
function normalizeIsolation(value) {
|
|
28
|
+
return value === "database" ? "database" : "shared";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Postgres' identifier length limit. Names longer than this are silently
|
|
32
|
+
* truncated by the server, which would cause distinct logical names to collide
|
|
33
|
+
* on the same physical database. We assert against it everywhere a name is
|
|
34
|
+
* derived.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_IDENTIFIER_LENGTH = 63;
|
|
37
|
+
/** Quote a Postgres identifier for safe interpolation into DDL. */
|
|
38
|
+
function quoteIdent(name) {
|
|
39
|
+
return `"${name.replace(/"/g, "\"\"")}"`;
|
|
40
|
+
}
|
|
41
|
+
/** Quote a Postgres string literal for safe interpolation into SQL. */
|
|
42
|
+
function quoteLiteral(value) {
|
|
43
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Dynamically import `pg` (an optional peer), surfacing an actionable error
|
|
47
|
+
* instead of a raw module-not-found when database isolation is requested
|
|
48
|
+
* without the dependency installed.
|
|
49
|
+
*/
|
|
50
|
+
async function importPg() {
|
|
51
|
+
try {
|
|
52
|
+
const { default: pg } = await import("pg");
|
|
53
|
+
return pg;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
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 });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Assert a derived identifier fits within Postgres' {@link MAX_IDENTIFIER_LENGTH}
|
|
60
|
+
* limit. Throws a clear, actionable error instead of letting the server
|
|
61
|
+
* silently truncate and collide names.
|
|
62
|
+
*/
|
|
63
|
+
function assertIdentifierLength(name, kind) {
|
|
64
|
+
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.`);
|
|
65
|
+
}
|
|
66
|
+
/** Parse the database name out of a Postgres connection URL. */
|
|
67
|
+
function databaseNameOf(connectionString) {
|
|
68
|
+
return new URL(connectionString).pathname.replace(/^\//, "") || "postgres";
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Derive an admin connection string pointing at the `postgres` maintenance
|
|
72
|
+
* database. `CREATE`/`DROP DATABASE` cannot run on a connection bound to the
|
|
73
|
+
* target database, so administration always goes through `postgres`.
|
|
74
|
+
*/
|
|
75
|
+
function deriveAdminConnectionString(connectionString) {
|
|
76
|
+
const url = new URL(connectionString);
|
|
77
|
+
url.pathname = "/postgres";
|
|
78
|
+
return url.toString();
|
|
79
|
+
}
|
|
80
|
+
/** Build a connection string identical to `base` but pointing at `dbName`. */
|
|
81
|
+
function buildConnectionString(base, dbName) {
|
|
82
|
+
const url = new URL(base);
|
|
83
|
+
url.pathname = `/${dbName}`;
|
|
84
|
+
return url.toString();
|
|
85
|
+
}
|
|
86
|
+
/** Length of the random token appended by {@link deriveDbName}. */
|
|
87
|
+
const DB_NAME_TOKEN_LENGTH = 12;
|
|
88
|
+
/** The shared prefix for per-file databases, used as the leak-sweep key. */
|
|
89
|
+
function databasePrefix(base) {
|
|
90
|
+
const prefix = `${databaseNameOf(base).replace(/[^a-z0-9_]/gi, "_")}_t_`;
|
|
91
|
+
assertIdentifierLength(`${prefix}${"x".repeat(DB_NAME_TOKEN_LENGTH)}`, "per-file database name");
|
|
92
|
+
return prefix;
|
|
93
|
+
}
|
|
94
|
+
/** Name of the migrated template database cloned per file. */
|
|
95
|
+
function deriveTemplateName(base) {
|
|
96
|
+
const name = `${databaseNameOf(base).replace(/[^a-z0-9_]/gi, "_")}_template`;
|
|
97
|
+
assertIdentifierLength(name, "template database name");
|
|
98
|
+
return name;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Generate a unique per-`compile()` database name. Random (not path-based) so
|
|
102
|
+
* it is collision-free across concurrent isolates and multiple modules in the
|
|
103
|
+
* same file, and stays well within Postgres' 63-char identifier limit.
|
|
104
|
+
*/
|
|
105
|
+
function deriveDbName(base) {
|
|
106
|
+
const token = randomUUID().replace(/-/g, "").slice(0, DB_NAME_TOKEN_LENGTH);
|
|
107
|
+
return `${databasePrefix(base)}${token}`;
|
|
108
|
+
}
|
|
109
|
+
async function withAdminClient(adminConn, fn) {
|
|
110
|
+
const client = new (await (importPg())).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
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
119
|
+
/** True for transient errors worth retrying a `CREATE DATABASE ... TEMPLATE`. */
|
|
120
|
+
function isTemplateBusy(error) {
|
|
121
|
+
const e = error;
|
|
122
|
+
return e?.code === "55006" || /is being accessed by other users/i.test(e?.message ?? "");
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Terminate every backend connected to `dbName` except the caller's own. Used
|
|
126
|
+
* to evict lingering sessions on the template database so `CREATE DATABASE ...
|
|
127
|
+
* TEMPLATE` (which requires the source to have no other connections) succeeds.
|
|
128
|
+
*/
|
|
129
|
+
async function terminateConnections(query, dbName) {
|
|
130
|
+
await query(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = ${quoteLiteral(dbName)} AND pid <> pg_backend_pid()`);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Clone the template database into `dbName`. `CREATE DATABASE ... TEMPLATE`
|
|
134
|
+
* fails (SQLSTATE 55006) if any session is connected to the template, so we
|
|
135
|
+
* proactively terminate lingering template backends before each attempt and
|
|
136
|
+
* retry with exponential backoff while a concurrent clone briefly locks it.
|
|
137
|
+
*/
|
|
138
|
+
async function createDatabaseFromTemplate(adminConn, dbName, template, attempts = 10) {
|
|
139
|
+
const sql = `CREATE DATABASE ${quoteIdent(dbName)} TEMPLATE ${quoteIdent(template)}`;
|
|
140
|
+
for (let attempt = 0; attempt < attempts; attempt++) try {
|
|
141
|
+
await withAdminClient(adminConn, async (query) => {
|
|
142
|
+
await terminateConnections(query, template);
|
|
143
|
+
await query(sql);
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (!isTemplateBusy(error) || attempt === attempts - 1) throw error;
|
|
148
|
+
const jitter = parseInt(randomUUID().slice(0, 2), 16);
|
|
149
|
+
await sleep(Math.min(50 * 2 ** attempt, 2e3) + jitter);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Drop a database, terminating any lingering connections. */
|
|
153
|
+
async function dropDatabase(adminConn, dbName) {
|
|
154
|
+
await withAdminClient(adminConn, (query) => query(`DROP DATABASE IF EXISTS ${quoteIdent(dbName)} WITH (FORCE)`));
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Drop leaked per-file databases matching the prefix while leaving a concurrent
|
|
158
|
+
* process's **live** databases intact.
|
|
159
|
+
*
|
|
160
|
+
* Multiple setups can run at once (CI sharding, several e2e projects). A blanket
|
|
161
|
+
* "drop everything matching the prefix" sweep would delete a sibling process's
|
|
162
|
+
* in-flight per-file databases. So we only drop databases that currently have
|
|
163
|
+
* **no active backend connections** — i.e. true leaks from a crashed prior run.
|
|
164
|
+
* A live per-file database always has the test worker's pool connected, so it is
|
|
165
|
+
* skipped. The `WITH (FORCE)` covers the narrow race where a connection appears
|
|
166
|
+
* between the check and the drop.
|
|
167
|
+
*/
|
|
168
|
+
async function sweepStaleDatabases(adminConn, prefix) {
|
|
169
|
+
const likePrefix = prefix.replace(/'/g, "''").replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
170
|
+
await withAdminClient(adminConn, async (query) => {
|
|
171
|
+
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)`);
|
|
172
|
+
for (const { datname } of rows) await query(`DROP DATABASE IF EXISTS ${quoteIdent(datname)} WITH (FORCE)`);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Run `fn` while holding a session-level Postgres advisory lock keyed to
|
|
177
|
+
* `lockKey`, serializing template setup across concurrent processes (CI
|
|
178
|
+
* sharding, multiple e2e projects) so they don't drop/recreate the template out
|
|
179
|
+
* from under each other. The lock is released in `finally`.
|
|
180
|
+
*/
|
|
181
|
+
async function withAdvisoryLock(adminConn, lockKey, fn) {
|
|
182
|
+
return withAdminClient(adminConn, async (query) => {
|
|
183
|
+
await query(`SELECT pg_advisory_lock(hashtext(${quoteLiteral(lockKey)}))`);
|
|
184
|
+
try {
|
|
185
|
+
return await fn();
|
|
186
|
+
} finally {
|
|
187
|
+
await query(`SELECT pg_advisory_unlock(hashtext(${quoteLiteral(lockKey)}))`);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/** Schema source file extensions hashed into the template fingerprint. */
|
|
192
|
+
const SCHEMA_FILE_RE = /\.(zmodel|prisma|sql)$/;
|
|
193
|
+
/** Matches ZModel `import "..."` / `import '...'` statements. */
|
|
194
|
+
const ZMODEL_IMPORT_RE = /^\s*import\s+['"]([^'"]+)['"]/gm;
|
|
195
|
+
/**
|
|
196
|
+
* Follow a ZModel file's `import` graph, collecting the root plus every
|
|
197
|
+
* transitively imported `.zmodel` file — ZenStack supports multi-file schemas,
|
|
198
|
+
* so editing an imported file must invalidate the fingerprint. Import paths
|
|
199
|
+
* resolve relative to the importing file; the `.zmodel` extension is optional.
|
|
200
|
+
* A missing import target is skipped (migration surfaces the real error).
|
|
201
|
+
*/
|
|
202
|
+
function collectZmodelImports(file, seen) {
|
|
203
|
+
if (seen.has(file)) return;
|
|
204
|
+
let content;
|
|
205
|
+
try {
|
|
206
|
+
content = readFileSync(file, "utf8");
|
|
207
|
+
} catch {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
seen.add(file);
|
|
211
|
+
for (const [, importPath] of content.matchAll(ZMODEL_IMPORT_RE)) {
|
|
212
|
+
const target = resolve(dirname(file), importPath);
|
|
213
|
+
collectZmodelImports(target.endsWith(".zmodel") ? target : `${target}.zmodel`, seen);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Expand a schema path into the concrete files to hash. A directory contributes
|
|
218
|
+
* every schema file in its tree; a single `.zmodel` file contributes its whole
|
|
219
|
+
* `import` graph; any other file contributes itself.
|
|
220
|
+
*/
|
|
221
|
+
function collectSchemaFiles(path) {
|
|
222
|
+
if (statSync(path).isDirectory()) {
|
|
223
|
+
const out = [];
|
|
224
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
225
|
+
const full = join(path, entry.name);
|
|
226
|
+
if (entry.isDirectory()) out.push(...collectSchemaFiles(full));
|
|
227
|
+
else if (SCHEMA_FILE_RE.test(entry.name)) out.push(full);
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
if (path.endsWith(".zmodel")) {
|
|
232
|
+
const seen = /* @__PURE__ */ new Set();
|
|
233
|
+
collectZmodelImports(path, seen);
|
|
234
|
+
return [...seen];
|
|
235
|
+
}
|
|
236
|
+
return [path];
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* A content-derived fingerprint of the schema source(s) plus the migrate
|
|
240
|
+
* routine. The template is reused across runs while this is unchanged; any edit
|
|
241
|
+
* to a schema file — or to how migration runs — changes it and forces a rebuild.
|
|
242
|
+
* Uses file basenames (not absolute paths) so it is stable across checkouts.
|
|
243
|
+
*/
|
|
244
|
+
function computeSchemaFingerprint(schema, migrate) {
|
|
245
|
+
const roots = Array.isArray(schema) ? schema : [schema];
|
|
246
|
+
const files = roots.flatMap(collectSchemaFiles).sort();
|
|
247
|
+
if (files.length === 0) throw new Error(`[stratal-testing] No schema files found for fingerprinting under: ${roots.join(", ")}`);
|
|
248
|
+
const hash = createHash("sha256");
|
|
249
|
+
for (const file of files) {
|
|
250
|
+
hash.update(basename(file));
|
|
251
|
+
hash.update("\0");
|
|
252
|
+
hash.update(readFileSync(file));
|
|
253
|
+
hash.update("\0");
|
|
254
|
+
}
|
|
255
|
+
hash.update(migrate.toString());
|
|
256
|
+
return hash.digest("hex");
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Read the template database's stored schema fingerprint (kept as the database
|
|
260
|
+
* COMMENT). Returns `null` when the template does not exist or carries no
|
|
261
|
+
* fingerprint. Database comments are NOT copied by `CREATE DATABASE ...
|
|
262
|
+
* TEMPLATE`, so per-file clones never inherit it.
|
|
263
|
+
*/
|
|
264
|
+
async function readTemplateFingerprint(query, template) {
|
|
265
|
+
const { rows } = await query(`SELECT shobj_description(oid, 'pg_database') AS fingerprint FROM pg_database WHERE datname = ${quoteLiteral(template)}`);
|
|
266
|
+
return rows.length === 0 ? null : rows[0].fingerprint;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Build a Vitest `globalSetup` default export that prepares the test database.
|
|
270
|
+
*
|
|
271
|
+
* - `'shared'` (default): migrates the base database in place (no isolation).
|
|
272
|
+
* - `'database'`: under a Postgres advisory lock (so concurrent setups across
|
|
273
|
+
* CI shards / multiple e2e projects don't clobber each other), sweeps leaked
|
|
274
|
+
* per-file databases, then ensures a migrated template exists — ready to be
|
|
275
|
+
* cloned per test file.
|
|
276
|
+
*
|
|
277
|
+
* **Template reuse.** The template is fingerprinted from the `schema` source(s)
|
|
278
|
+
* + the `migrate` routine and the fingerprint is stored as the template's
|
|
279
|
+
* database COMMENT. On each run, a matching fingerprint means the schema is
|
|
280
|
+
* unchanged and the existing template is reused as-is — `migrate` runs **only**
|
|
281
|
+
* on the first run after a schema edit (or on a fresh database). The fingerprint
|
|
282
|
+
* is stamped only after a successful migrate, so a match always implies a
|
|
283
|
+
* complete template; there is no force/skip flag — reuse is purely fingerprint-
|
|
284
|
+
* driven.
|
|
285
|
+
*
|
|
286
|
+
* **Concurrency model.** The reuse check + rebuild runs under
|
|
287
|
+
* `pg_advisory_lock(hashtext(<template>))`, so only one process rebuilds the
|
|
288
|
+
* template at a time. The stale-database sweep only drops per-file databases
|
|
289
|
+
* with **no active connections**, leaving a sibling process's live databases
|
|
290
|
+
* intact. Teardown deliberately does **not** sweep or drop the template: a
|
|
291
|
+
* concurrent process may still be using both, and the next run's setup sweep is
|
|
292
|
+
* the backstop for any leak.
|
|
293
|
+
*
|
|
294
|
+
* @example
|
|
295
|
+
* ```ts
|
|
296
|
+
* // test/global-setup.ts
|
|
297
|
+
* import { createTestDatabaseGlobalSetup } from '@stratal/testing/database'
|
|
298
|
+
*
|
|
299
|
+
* export default createTestDatabaseGlobalSetup({
|
|
300
|
+
* schema: schemaPath, // file or directory — reused-when-unchanged fingerprint
|
|
301
|
+
* migrate: (conn) => execFileSync(zenstackBin, ['db', 'push', '--force-reset', `--schema=${schemaPath}`, '--accept-data-loss'],
|
|
302
|
+
* { env: { ...process.env, DATABASE_URL: conn }, stdio: 'inherit' }),
|
|
303
|
+
* })
|
|
304
|
+
* ```
|
|
305
|
+
*/
|
|
306
|
+
function createTestDatabaseGlobalSetup(opts) {
|
|
307
|
+
return async () => {
|
|
308
|
+
const base = opts.connectionString ?? process.env.DATABASE_URL;
|
|
309
|
+
if (!base) throw new Error("[stratal-testing] No connection string for test database setup. Set process.env.DATABASE_URL or pass `connectionString`.");
|
|
310
|
+
if (normalizeIsolation(opts.isolation ?? process.env["STRATAL_TEST_DB_ISOLATION"]) === "shared") {
|
|
311
|
+
await opts.migrate(base);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (!opts.schema) throw new Error("[stratal-testing] `schema` is required for database isolation. Pass the schema file(s) or directory so the migrated template can be reused across runs when unchanged (and rebuilt when it changes).");
|
|
315
|
+
const adminConn = deriveAdminConnectionString(base);
|
|
316
|
+
const template = opts.templateName ?? deriveTemplateName(base);
|
|
317
|
+
const prefix = databasePrefix(base);
|
|
318
|
+
const fingerprint = computeSchemaFingerprint(opts.schema, opts.migrate);
|
|
319
|
+
await withAdvisoryLock(adminConn, template, async () => {
|
|
320
|
+
await sweepStaleDatabases(adminConn, prefix);
|
|
321
|
+
if (await withAdminClient(adminConn, (query) => readTemplateFingerprint(query, template)) === fingerprint) return;
|
|
322
|
+
await withAdminClient(adminConn, async (query) => {
|
|
323
|
+
await query(`DROP DATABASE IF EXISTS ${quoteIdent(template)} WITH (FORCE)`);
|
|
324
|
+
await query(`CREATE DATABASE ${quoteIdent(template)}`);
|
|
325
|
+
});
|
|
326
|
+
await opts.migrate(buildConnectionString(base, template));
|
|
327
|
+
await withAdminClient(adminConn, (query) => query(`COMMENT ON DATABASE ${quoteIdent(template)} IS ${quoteLiteral(fingerprint)}`));
|
|
328
|
+
});
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
//#endregion
|
|
332
|
+
export { createDatabaseFromTemplate as a, deriveAdminConnectionString as c, dropDatabase as d, normalizeIsolation as f, buildConnectionString as i, deriveDbName as l, DEFAULT_DB_BINDING as n, createTestDatabaseGlobalSetup as o, ISOLATION_ENV_VAR as r, databasePrefix as s, BINDING_ENV_VAR as t, deriveTemplateName as u };
|
|
333
|
+
|
|
334
|
+
//# sourceMappingURL=database-B02eYKhE.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"database-B02eYKhE.mjs","names":[],"sources":["../src/database/test-database.ts"],"sourcesContent":["/**\n * Test database isolation helpers.\n *\n * Implements database-per-test-file isolation for parallel e2e runs against\n * Postgres. A migrated **template** database is built once in global setup;\n * each test file clones it instantly via `CREATE DATABASE ... TEMPLATE` and\n * drops it on teardown. 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, randomUUID } 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/** Postgres isolation mode for tests. */\nexport type DatabaseIsolation = 'shared' | 'database'\n\n/** Env var that selects the isolation mode (single source of truth). */\nexport const ISOLATION_ENV_VAR = 'STRATAL_TEST_DB_ISOLATION'\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 * Normalize an isolation value (from an option or env var) to a mode.\n * Anything other than the literal `'database'` resolves to `'shared'` — the\n * default — so parallel isolation is strictly opt-in.\n */\nexport function normalizeIsolation(value: string | undefined): DatabaseIsolation {\n return value === 'database' ? 'database' : 'shared'\n}\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/** Length of the random token appended by {@link deriveDbName}. */\nconst DB_NAME_TOKEN_LENGTH = 12\n\n/** The shared prefix for per-file databases, used as the leak-sweep key. */\nexport function databasePrefix(base: string): string {\n const baseName = databaseNameOf(base).replace(/[^a-z0-9_]/gi, '_')\n const prefix = `${baseName}_t_`\n // The per-file name is `${prefix}${12-char token}`; assert the full budget so\n // long base names fail loudly here instead of silently truncating + colliding.\n assertIdentifierLength(`${prefix}${'x'.repeat(DB_NAME_TOKEN_LENGTH)}`, 'per-file database name')\n return prefix\n}\n\n/** Name of the migrated template database cloned per file. */\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 * Generate a unique per-`compile()` database name. Random (not path-based) so\n * it is collision-free across concurrent isolates and multiple modules in the\n * same file, and stays well within Postgres' 63-char identifier limit.\n */\nexport function deriveDbName(base: string): string {\n const token = randomUUID().replace(/-/g, '').slice(0, DB_NAME_TOKEN_LENGTH)\n return `${databasePrefix(base)}${token}`\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\nconst sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))\n\n/** True for transient errors worth retrying a `CREATE DATABASE ... TEMPLATE`. */\nfunction isTemplateBusy(error: unknown): boolean {\n const e = error as { code?: string; message?: string }\n return e?.code === '55006' || /is being accessed by other users/i.test(e?.message ?? '')\n}\n\n/**\n * Terminate every backend connected to `dbName` except the caller's own. Used\n * to evict lingering sessions on the template database so `CREATE DATABASE ...\n * TEMPLATE` (which requires the source to have no other connections) succeeds.\n */\nasync function terminateConnections(query: (sql: string) => Promise<unknown>, dbName: string): Promise<void> {\n await query(\n `SELECT pg_terminate_backend(pid) FROM pg_stat_activity ` +\n `WHERE datname = ${quoteLiteral(dbName)} AND pid <> pg_backend_pid()`,\n )\n}\n\n/**\n * Clone the template database into `dbName`. `CREATE DATABASE ... TEMPLATE`\n * fails (SQLSTATE 55006) if any session is connected to the template, so we\n * proactively terminate lingering template backends before each attempt and\n * retry with exponential backoff while a concurrent clone briefly locks it.\n */\nexport async function createDatabaseFromTemplate(\n adminConn: string,\n dbName: string,\n template: string,\n attempts = 10,\n): Promise<void> {\n const sql = `CREATE DATABASE ${quoteIdent(dbName)} TEMPLATE ${quoteIdent(template)}`\n for (let attempt = 0; attempt < attempts; attempt++) {\n try {\n await withAdminClient(adminConn, async (query) => {\n // Evict any lingering sessions on the template; otherwise the clone\n // below fails with 55006 (\"source database is being accessed by other\n // users\"). Our own admin connection is excluded by pid.\n await terminateConnections(query, template)\n await query(sql)\n })\n return\n } catch (error) {\n if (!isTemplateBusy(error) || attempt === attempts - 1) throw error\n const jitter = parseInt(randomUUID().slice(0, 2), 16)\n await sleep(Math.min(50 * 2 ** attempt, 2000) + jitter)\n }\n }\n}\n\n/** Drop a database, terminating any lingering connections. */\nexport async function dropDatabase(adminConn: string, dbName: string): Promise<void> {\n await withAdminClient(adminConn, (query) => query(`DROP DATABASE IF EXISTS ${quoteIdent(dbName)} WITH (FORCE)`))\n}\n\n/**\n * Drop leaked per-file databases matching the prefix while leaving a concurrent\n * process's **live** databases intact.\n *\n * Multiple setups can run at once (CI sharding, several e2e projects). A blanket\n * \"drop everything matching the prefix\" sweep would delete a sibling process's\n * in-flight per-file databases. So we only drop databases that currently have\n * **no active backend connections** — i.e. true leaks from a crashed prior run.\n * A live per-file database always has the test worker's pool connected, so it is\n * skipped. The `WITH (FORCE)` covers the narrow race where a connection appears\n * between the check and the drop.\n */\nasync function sweepStaleDatabases(adminConn: string, prefix: 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 await query(`DROP DATABASE IF EXISTS ${quoteIdent(datname)} WITH (FORCE)`)\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\n * routine. The template is reused across runs while this is unchanged; any edit\n * to a schema file — or to how migration runs — changes it and forces a rebuild.\n * Uses file basenames (not absolute paths) so it is stable across checkouts.\n */\nfunction computeSchemaFingerprint(\n schema: string | string[],\n migrate: TestDatabaseGlobalSetupOptions['migrate'],\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 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 per-file 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 /**\n * Run migrations against the given connection string. In `'database'` mode\n * the string points at the template database; in `'shared'` mode at the base\n * database. Framework consumers typically run `zenstack db push` here.\n */\n migrate: (connectionString: string) => void | Promise<void>\n /**\n * Schema source(s) — a file or directory path, or a list of them. Their\n * contents (plus the `migrate` routine) are hashed into a fingerprint; the\n * template is reused across runs while the fingerprint is unchanged and\n * rebuilt + re-migrated when it changes, so only the first run after a schema\n * edit pays the migration cost. **Required** for `'database'` isolation.\n *\n * For a ZenStack multi-file schema, pass the **root `.zmodel`** — its `import`\n * graph is followed, so editing any imported file invalidates the fingerprint.\n * A directory path hashes every `.zmodel`/`.prisma`/`.sql` file in its tree.\n */\n schema?: string | string[]\n /** Isolation mode. Defaults to {@link ISOLATION_ENV_VAR} or `'shared'`. */\n isolation?: DatabaseIsolation\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 * - `'shared'` (default): migrates the base database in place (no isolation).\n * - `'database'`: under a Postgres advisory lock (so concurrent setups across\n * CI shards / multiple e2e projects don't clobber each other), sweeps leaked\n * per-file databases, then ensures a migrated template exists — ready to be\n * cloned per test file.\n *\n * **Template reuse.** The template is fingerprinted from the `schema` source(s)\n * + the `migrate` routine and the fingerprint is stored as the template's\n * database COMMENT. On each run, a matching fingerprint means the schema is\n * unchanged and the existing template is reused as-is — `migrate` runs **only**\n * on the first run after a schema edit (or on a fresh database). The fingerprint\n * is stamped only after a successful migrate, so a match always implies a\n * complete template; there is no force/skip flag — reuse is purely fingerprint-\n * 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 stale-database sweep only drops per-file databases\n * with **no active connections**, leaving a sibling process's live databases\n * intact. Teardown deliberately does **not** sweep or drop the template: a\n * concurrent process may still be using both, and the next run's setup sweep is\n * the backstop for any leak.\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<void | (() => 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 isolation = normalizeIsolation(opts.isolation ?? process.env[ISOLATION_ENV_VAR])\n if (isolation === 'shared') {\n await opts.migrate(base)\n return\n }\n\n if (!opts.schema) {\n throw new Error(\n '[stratal-testing] `schema` is required for database isolation. Pass the schema ' +\n 'file(s) or directory so the migrated template can be reused across runs when ' +\n 'unchanged (and rebuilt when it changes).',\n )\n }\n\n const adminConn = deriveAdminConnectionString(base)\n const template = opts.templateName ?? deriveTemplateName(base)\n const prefix = databasePrefix(base)\n const fingerprint = computeSchemaFingerprint(opts.schema, opts.migrate)\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)\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 await opts.migrate(buildConnectionString(base, template))\n // Stamp the fingerprint only after a successful migrate, so a matching\n // fingerprint always implies a complete, ready template. Database COMMENTs\n // are not copied by CREATE DATABASE ... TEMPLATE, so clones stay clean.\n await withAdminClient(adminConn, (query) =>\n query(`COMMENT ON DATABASE ${quoteIdent(template)} IS ${quoteLiteral(fingerprint)}`),\n )\n })\n\n // No teardown hook: anything destructive here (sweeping per-file databases\n // or dropping the template) could clobber a concurrent process that is still\n // running. The next run's setup sweep (connection-guarded) reclaims leaks.\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
//#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorate.js
|
|
2
|
+
function __decorate(decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
}
|
|
8
|
+
//#endregion
|
|
9
|
+
export { __decorate as t };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { t as __decorate } from "./decorate-B7nr7eBl.mjs";
|
|
2
|
+
import { Singleton } from "stratal/di";
|
|
3
|
+
//#region src/feature-flags/fake-feature-flag.service.ts
|
|
4
|
+
/**
|
|
5
|
+
* Global DI token for the feature-flags service.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors `FEATURE_FLAG_TOKENS.FeatureFlagService` from `@stratal/feature-flags`
|
|
8
|
+
* — `Symbol.for(...)` resolves to the same symbol across packages via the global
|
|
9
|
+
* registry. Declared here so `@stratal/testing` needs no dependency on the
|
|
10
|
+
* optional feature-flags package. Keep this string in sync with
|
|
11
|
+
* `packages/feature-flags/src/feature-flags.tokens.ts`.
|
|
12
|
+
*/
|
|
13
|
+
const FEATURE_FLAG_SERVICE_TOKEN = Symbol.for("stratal:feature-flags:service");
|
|
14
|
+
let FakeFeatureFlagService = class FakeFeatureFlagService {
|
|
15
|
+
flags = /* @__PURE__ */ new Map();
|
|
16
|
+
/** Set a single flag value. */
|
|
17
|
+
set(flagKey, value) {
|
|
18
|
+
this.flags.set(flagKey, value);
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
/** Replace all configured flags with the given map. */
|
|
22
|
+
setAll(flags) {
|
|
23
|
+
this.reset();
|
|
24
|
+
for (const [key, value] of Object.entries(flags)) this.flags.set(key, value);
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
/** Clear every configured flag. */
|
|
28
|
+
reset() {
|
|
29
|
+
this.flags.clear();
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
async get(flagKey, defaultValue) {
|
|
33
|
+
return Promise.resolve(this.flags.has(flagKey) ? this.flags.get(flagKey) : defaultValue);
|
|
34
|
+
}
|
|
35
|
+
async getBooleanValue(flagKey, defaultValue = false) {
|
|
36
|
+
return this.resolve(flagKey, defaultValue);
|
|
37
|
+
}
|
|
38
|
+
async getStringValue(flagKey, defaultValue = "") {
|
|
39
|
+
return this.resolve(flagKey, defaultValue);
|
|
40
|
+
}
|
|
41
|
+
async getNumberValue(flagKey, defaultValue = 0) {
|
|
42
|
+
return this.resolve(flagKey, defaultValue);
|
|
43
|
+
}
|
|
44
|
+
async getObjectValue(flagKey, defaultValue = {}) {
|
|
45
|
+
return this.resolve(flagKey, defaultValue);
|
|
46
|
+
}
|
|
47
|
+
async getBooleanDetails(flagKey, defaultValue = false) {
|
|
48
|
+
return this.details(flagKey, await this.getBooleanValue(flagKey, defaultValue));
|
|
49
|
+
}
|
|
50
|
+
async getStringDetails(flagKey, defaultValue = "") {
|
|
51
|
+
return this.details(flagKey, await this.getStringValue(flagKey, defaultValue));
|
|
52
|
+
}
|
|
53
|
+
async getNumberDetails(flagKey, defaultValue = 0) {
|
|
54
|
+
return this.details(flagKey, await this.getNumberValue(flagKey, defaultValue));
|
|
55
|
+
}
|
|
56
|
+
async getObjectDetails(flagKey, defaultValue = {}) {
|
|
57
|
+
return this.details(flagKey, await this.getObjectValue(flagKey, defaultValue));
|
|
58
|
+
}
|
|
59
|
+
/** Returns every configured flag as a `{ key: value }` map. */
|
|
60
|
+
async all() {
|
|
61
|
+
return Promise.resolve(Object.fromEntries(this.flags));
|
|
62
|
+
}
|
|
63
|
+
/** Switching Flagship apps is a no-op in the fake. */
|
|
64
|
+
use() {
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
/** The binding name this instance targets. */
|
|
68
|
+
get app() {
|
|
69
|
+
return "fake";
|
|
70
|
+
}
|
|
71
|
+
resolve(flagKey, defaultValue) {
|
|
72
|
+
return Promise.resolve(this.flags.has(flagKey) ? this.flags.get(flagKey) : defaultValue);
|
|
73
|
+
}
|
|
74
|
+
details(flagKey, value) {
|
|
75
|
+
return {
|
|
76
|
+
flagKey,
|
|
77
|
+
value,
|
|
78
|
+
reason: "fake"
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
FakeFeatureFlagService = __decorate([Singleton(FEATURE_FLAG_SERVICE_TOKEN)], FakeFeatureFlagService);
|
|
83
|
+
//#endregion
|
|
84
|
+
export { FakeFeatureFlagService as n, FEATURE_FLAG_SERVICE_TOKEN as t };
|
|
85
|
+
|
|
86
|
+
//# sourceMappingURL=feature-flags-BiLhfSGh.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"feature-flags-BiLhfSGh.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,IAAA,yBAAA,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;qCAxFC,UAAU,0BAA0B,CAAA,GAAA,sBAAA"}
|