@stratal/testing 0.0.27 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +570 -0
  2. package/README.md +137 -36
  3. package/dist/database/index.d.mts +2 -2
  4. package/dist/database/index.mjs +2 -2
  5. package/dist/database-Rojs69r3.mjs +453 -0
  6. package/dist/database-Rojs69r3.mjs.map +1 -0
  7. package/dist/{decorate-B7nr7eBl.mjs → decorate-RQD1h28J.mjs} +1 -1
  8. package/dist/feature-flags/index.mjs +1 -1
  9. package/dist/{feature-flags-BiLhfSGh.mjs → feature-flags-CZ1a4M2g.mjs} +2 -2
  10. package/dist/{feature-flags-BiLhfSGh.mjs.map → feature-flags-CZ1a4M2g.mjs.map} +1 -1
  11. package/dist/index-B8Mw1Dxk.d.mts +175 -0
  12. package/dist/index-B8Mw1Dxk.d.mts.map +1 -0
  13. package/dist/{index-CrHzUDKX.d.mts → index-D7FM6EKp.d.mts} +21 -3
  14. package/dist/index-D7FM6EKp.d.mts.map +1 -0
  15. package/dist/index-qgWNJRdC.d.mts.map +1 -1
  16. package/dist/index.d.mts +105 -46
  17. package/dist/index.d.mts.map +1 -1
  18. package/dist/index.mjs +218 -108
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/mocks/cloudflare-workers.d.mts +24 -0
  21. package/dist/mocks/cloudflare-workers.d.mts.map +1 -0
  22. package/dist/mocks/cloudflare-workers.mjs +28 -0
  23. package/dist/mocks/cloudflare-workers.mjs.map +1 -0
  24. package/dist/mocks/index.d.mts +2 -2
  25. package/dist/mocks/index.mjs +2 -2
  26. package/dist/mocks/noop-rate-limiter-store.d.mts +1 -3
  27. package/dist/mocks/noop-rate-limiter-store.d.mts.map +1 -1
  28. package/dist/mocks/zenstack-language.d.mts +26 -29
  29. package/dist/mocks/zenstack-language.d.mts.map +1 -1
  30. package/dist/mocks/zenstack-language.mjs +1 -1
  31. package/dist/mocks/zenstack-language.mjs.map +1 -1
  32. package/dist/storage/index.d.mts +1 -1
  33. package/dist/storage/index.mjs +1 -1
  34. package/dist/{storage-DhoxWqyF.mjs → storage-BiGamMA8.mjs} +73 -6
  35. package/dist/storage-BiGamMA8.mjs.map +1 -0
  36. package/dist/test-worker-exports-dCRARYEc.d.mts +144 -0
  37. package/dist/test-worker-exports-dCRARYEc.d.mts.map +1 -0
  38. package/dist/test-workers-cache-D02Pt_dE.mjs +183 -0
  39. package/dist/test-workers-cache-D02Pt_dE.mjs.map +1 -0
  40. package/dist/vitest-plugin/index.d.mts +14 -28
  41. package/dist/vitest-plugin/index.d.mts.map +1 -1
  42. package/dist/vitest-plugin/index.mjs +32 -20
  43. package/dist/vitest-plugin/index.mjs.map +1 -1
  44. package/package.json +30 -21
  45. package/dist/database-B02eYKhE.mjs +0 -334
  46. package/dist/database-B02eYKhE.mjs.map +0 -1
  47. package/dist/index-BIr5nLof.d.mts +0 -122
  48. package/dist/index-BIr5nLof.d.mts.map +0 -1
  49. package/dist/index-CrHzUDKX.d.mts.map +0 -1
  50. package/dist/storage-DhoxWqyF.mjs.map +0 -1
  51. package/dist/test-email-provider-B7cjj97-.d.mts +0 -21
  52. package/dist/test-email-provider-B7cjj97-.d.mts.map +0 -1
  53. package/dist/test-email-provider-Dr-nhE0x.mjs +0 -34
  54. package/dist/test-email-provider-Dr-nhE0x.mjs.map +0 -1
@@ -1,334 +0,0 @@
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
@@ -1 +0,0 @@
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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"}
@@ -1,122 +0,0 @@
1
- //#region src/database/test-database.d.ts
2
- /**
3
- * Test database isolation helpers.
4
- *
5
- * Implements database-per-test-file isolation for parallel e2e runs against
6
- * Postgres. A migrated **template** database is built once in global setup;
7
- * each test file clones it instantly via `CREATE DATABASE ... TEMPLATE` and
8
- * drops it on teardown. See `@stratal/testing/database`.
9
- *
10
- * `pg` is imported dynamically so this module loads without it — consumers
11
- * that don't use a database never pay the dependency.
12
- */
13
- /** Postgres isolation mode for tests. */
14
- type DatabaseIsolation = 'shared' | 'database';
15
- /** Env var that selects the isolation mode (single source of truth). */
16
- declare const ISOLATION_ENV_VAR = "STRATAL_TEST_DB_ISOLATION";
17
- /** Env var carrying the name of the Hyperdrive binding to isolate. */
18
- declare const BINDING_ENV_VAR = "STRATAL_TEST_DB_BINDING";
19
- /** Default Hyperdrive binding name when none is configured. */
20
- declare const DEFAULT_DB_BINDING = "DB";
21
- /**
22
- * Normalize an isolation value (from an option or env var) to a mode.
23
- * Anything other than the literal `'database'` resolves to `'shared'` — the
24
- * default — so parallel isolation is strictly opt-in.
25
- */
26
- declare function normalizeIsolation(value: string | undefined): DatabaseIsolation;
27
- /**
28
- * Derive an admin connection string pointing at the `postgres` maintenance
29
- * database. `CREATE`/`DROP DATABASE` cannot run on a connection bound to the
30
- * target database, so administration always goes through `postgres`.
31
- */
32
- declare function deriveAdminConnectionString(connectionString: string): string;
33
- /** Build a connection string identical to `base` but pointing at `dbName`. */
34
- declare function buildConnectionString(base: string, dbName: string): string;
35
- /** The shared prefix for per-file databases, used as the leak-sweep key. */
36
- declare function databasePrefix(base: string): string;
37
- /** Name of the migrated template database cloned per file. */
38
- declare function deriveTemplateName(base: string): string;
39
- /**
40
- * Generate a unique per-`compile()` database name. Random (not path-based) so
41
- * it is collision-free across concurrent isolates and multiple modules in the
42
- * same file, and stays well within Postgres' 63-char identifier limit.
43
- */
44
- declare function deriveDbName(base: string): string;
45
- /**
46
- * Clone the template database into `dbName`. `CREATE DATABASE ... TEMPLATE`
47
- * fails (SQLSTATE 55006) if any session is connected to the template, so we
48
- * proactively terminate lingering template backends before each attempt and
49
- * retry with exponential backoff while a concurrent clone briefly locks it.
50
- */
51
- declare function createDatabaseFromTemplate(adminConn: string, dbName: string, template: string, attempts?: number): Promise<void>;
52
- /** Drop a database, terminating any lingering connections. */
53
- declare function dropDatabase(adminConn: string, dbName: string): Promise<void>;
54
- /** Options for {@link createTestDatabaseGlobalSetup}. */
55
- interface TestDatabaseGlobalSetupOptions {
56
- /**
57
- * Run migrations against the given connection string. In `'database'` mode
58
- * the string points at the template database; in `'shared'` mode at the base
59
- * database. Framework consumers typically run `zenstack db push` here.
60
- */
61
- migrate: (connectionString: string) => void | Promise<void>;
62
- /**
63
- * Schema source(s) — a file or directory path, or a list of them. Their
64
- * contents (plus the `migrate` routine) are hashed into a fingerprint; the
65
- * template is reused across runs while the fingerprint is unchanged and
66
- * rebuilt + re-migrated when it changes, so only the first run after a schema
67
- * edit pays the migration cost. **Required** for `'database'` isolation.
68
- *
69
- * For a ZenStack multi-file schema, pass the **root `.zmodel`** — its `import`
70
- * graph is followed, so editing any imported file invalidates the fingerprint.
71
- * A directory path hashes every `.zmodel`/`.prisma`/`.sql` file in its tree.
72
- */
73
- schema?: string | string[];
74
- /** Isolation mode. Defaults to {@link ISOLATION_ENV_VAR} or `'shared'`. */
75
- isolation?: DatabaseIsolation;
76
- /** Base/admin connection string. Defaults to `process.env.DATABASE_URL`. */
77
- connectionString?: string;
78
- /** Template database name. Defaults to `<baseDbName>_template`. */
79
- templateName?: string;
80
- }
81
- /**
82
- * Build a Vitest `globalSetup` default export that prepares the test database.
83
- *
84
- * - `'shared'` (default): migrates the base database in place (no isolation).
85
- * - `'database'`: under a Postgres advisory lock (so concurrent setups across
86
- * CI shards / multiple e2e projects don't clobber each other), sweeps leaked
87
- * per-file databases, then ensures a migrated template exists — ready to be
88
- * cloned per test file.
89
- *
90
- * **Template reuse.** The template is fingerprinted from the `schema` source(s)
91
- * + the `migrate` routine and the fingerprint is stored as the template's
92
- * database COMMENT. On each run, a matching fingerprint means the schema is
93
- * unchanged and the existing template is reused as-is — `migrate` runs **only**
94
- * on the first run after a schema edit (or on a fresh database). The fingerprint
95
- * is stamped only after a successful migrate, so a match always implies a
96
- * complete template; there is no force/skip flag — reuse is purely fingerprint-
97
- * driven.
98
- *
99
- * **Concurrency model.** The reuse check + rebuild runs under
100
- * `pg_advisory_lock(hashtext(<template>))`, so only one process rebuilds the
101
- * template at a time. The stale-database sweep only drops per-file databases
102
- * with **no active connections**, leaving a sibling process's live databases
103
- * intact. Teardown deliberately does **not** sweep or drop the template: a
104
- * concurrent process may still be using both, and the next run's setup sweep is
105
- * the backstop for any leak.
106
- *
107
- * @example
108
- * ```ts
109
- * // test/global-setup.ts
110
- * import { createTestDatabaseGlobalSetup } from '@stratal/testing/database'
111
- *
112
- * export default createTestDatabaseGlobalSetup({
113
- * schema: schemaPath, // file or directory — reused-when-unchanged fingerprint
114
- * migrate: (conn) => execFileSync(zenstackBin, ['db', 'push', '--force-reset', `--schema=${schemaPath}`, '--accept-data-loss'],
115
- * { env: { ...process.env, DATABASE_URL: conn }, stdio: 'inherit' }),
116
- * })
117
- * ```
118
- */
119
- declare function createTestDatabaseGlobalSetup(opts: TestDatabaseGlobalSetupOptions): () => Promise<void | (() => Promise<void>)>;
120
- //#endregion
121
- export { TestDatabaseGlobalSetupOptions as a, createTestDatabaseGlobalSetup as c, deriveDbName as d, deriveTemplateName as f, ISOLATION_ENV_VAR as i, databasePrefix as l, normalizeIsolation as m, DEFAULT_DB_BINDING as n, buildConnectionString as o, dropDatabase as p, DatabaseIsolation as r, createDatabaseFromTemplate as s, BINDING_ENV_VAR as t, deriveAdminConnectionString as u };
122
- //# sourceMappingURL=index-BIr5nLof.d.mts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index-BIr5nLof.d.mts","names":[],"sources":["../src/database/test-database.ts"],"mappings":";;AAmBA;;;;AAA6B;AAG7B;;;;AAA8B;AAG9B;AAAA,KANY,iBAAA;;cAGC,iBAAA;AAGe;AAAA,cAAf,eAAA;;cAGA,kBAAA;;AAAkB;AAO/B;;;iBAAgB,kBAAA,CAAmB,KAAA,uBAA4B,iBAAiB;AAAA;AAkEhF;;;;AAlEgF,iBAkEhE,2BAAA,CAA4B,gBAAwB;AAOpE;AAAA,iBAAgB,qBAAA,CAAsB,IAAA,UAAc,MAAc;;iBAUlD,cAAA,CAAe,IAAY;AAVuB;AAAA,iBAoBlD,kBAAA,CAAmB,IAAY;;;;AAVJ;AAU3C;iBAWgB,YAAA,CAAa,IAAY;;;AAXM;AAW/C;;;iBA0CsB,0BAAA,CACpB,SAAA,UACA,MAAA,UACA,QAAA,UACA,QAAA,YACC,OAAO;AA/C+B;AAAA,iBAoEnB,YAAA,CAAa,SAAA,UAAmB,MAAA,WAAiB,OAAO;;UAqJ7D,8BAAA;EA1KP;;;;;EAgLR,OAAA,GAAU,gBAAA,oBAAoC,OAAA;EAhLtC;AAAA;AAqBV;;;;;;;;AAA8E;EAuK5E,MAAA;EAlB6C;EAoB7C,SAAA,GAAY,iBAAiB;EAAA;EAE7B,gBAAA;EAhBU;EAkBV,YAAA;AAAA;;;;;;AAAY;AAyCd;;;;;;;;;;;;;AAEsC;;;;;;;;;;;;;;;;;;;iBAFtB,6BAAA,CACd,IAAA,EAAM,8BAAA,SACC,OAAA,eAAsB,OAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"index-CrHzUDKX.d.mts","names":[],"sources":["../src/storage/fake-storage.service.ts"],"mappings":";;;;;AAkBA;UAAiB,UAAA;EACf,OAAA,EAAS,UAAA;EACT,QAAA;EACA,IAAA;EACA,QAAA,GAAW,MAAA;EACX,UAAA,EAAY,IAAA;AAAA;;;;;;;;;;AAAI;AAoBlB;;;;;;;cACa,kBAAA,SAA2B,cAAA;EAAA,mBAKjB,cAAA,EAAgB,qBAAA;EAAA,mBAEhB,OAAA,EAAS,aAAA;EAAA,QANtB,KAAA;cAIa,cAAA,EAAgB,qBAAA,EAEhB,OAAA,EAAS,aAAA;EAsCN;;;EA9BlB,MAAA,CACJ,IAAA,EAAM,8BAAA,EACN,YAAA,UACA,OAAA,EAAS,aAAA,EACT,IAAA,YACC,OAAA,CAAQ,YAAA;EAoER;;;EA3CH,QAAA,CAAS,IAAA,WAAe,OAAA,CAAQ,cAAA;EA+D7B;;;EAtCH,MAAA,CAAO,IAAA,WAAe,OAAA;EAkDX;;;EA1CX,MAAA,CAAO,IAAA,WAAe,OAAA;EAyHC;;;EAlHvB,uBAAA,CACE,IAAA,UACA,SAAA,YACC,OAAA,CAAQ,kBAAA;EAxF2B;;;EA+FtC,qBAAA,CACE,IAAA,UACA,SAAA,YACC,OAAA,CAAQ,kBAAA;EA3FmB;;;EAkG9B,qBAAA,CACE,IAAA,UACA,SAAA,YACC,OAAA,CAAQ,kBAAA;EAvGU;;;EA8Gf,aAAA,CACJ,IAAA,EAAM,8BAAA,EACN,IAAA,UACA,OAAA,EAAS,IAAA,CAAK,aAAA;IAA2B,IAAA;EAAA,GACzC,IAAA,YACC,OAAA,CAAQ,YAAA;EAvGT;;;;;;EAwHF,YAAA,CAAa,IAAA;EA5FJ;;;;;;EAyGT,aAAA,CAAc,IAAA;EAxEP;;;;;EAoFP,WAAA;EA1EW;;;;;;EAuFX,WAAA,CAAY,KAAA;EArEV;;;EA+EF,cAAA,IAAkB,GAAA,SAAY,UAAA;EAtExB;;;EA6EN,cAAA;EA1EW;;;EAiFX,OAAA,CAAQ,IAAA,WAAe,UAAA;EAhFrB;;;EAuFF,KAAA;EAAA,QAQQ,kBAAA;EAAA,QAeM,gBAAA;AAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"storage-DhoxWqyF.mjs","names":[],"sources":["../src/storage/fake-storage.service.ts"],"sourcesContent":["import { Transient, inject } from 'stratal/di'\nimport {\n FileNotFoundError,\n STORAGE_TOKENS,\n type StorageManagerService,\n StorageService,\n type StreamingBlobPayloadInputTypes,\n type DownloadResult,\n type PresignedUrlResult,\n type StorageConfig,\n type UploadOptions,\n type UploadResult,\n} from 'stratal/storage'\nimport { expect } from 'vitest'\n\n/**\n * Stored file representation in memory\n */\nexport interface StoredFile {\n content: Uint8Array\n mimeType: string\n size: number\n metadata?: Record<string, string>\n uploadedAt: Date\n}\n\n/**\n * FakeStorageService\n *\n * In-memory storage implementation for testing.\n * Registered by default in TestingModuleBuilder.\n *\n * Similar to Laravel's Storage::fake() - stores files in memory\n * and provides assertion helpers for testing.\n *\n * @example\n * ```typescript\n * // Access via TestingModule\n * module.storage.assertExists('path/to/file.pdf')\n * module.storage.assertMissing('deleted/file.pdf')\n * module.storage.clear() // Reset between tests\n * ```\n */\n@Transient(STORAGE_TOKENS.StorageService)\nexport class FakeStorageService extends StorageService {\n private files = new Map<string, StoredFile>()\n\n constructor(\n @inject(STORAGE_TOKENS.StorageManager)\n protected readonly storageManager: StorageManagerService,\n @inject(STORAGE_TOKENS.Options)\n protected readonly options: StorageConfig\n ) {\n super(storageManager, options)\n }\n\n /**\n * Upload content to fake storage\n */\n async upload(\n body: StreamingBlobPayloadInputTypes,\n relativePath: string,\n options: UploadOptions,\n disk?: string\n ): Promise<UploadResult> {\n const content = await this.bodyToUint8Array(body)\n const diskName = this.resolveDisk(disk)\n\n this.files.set(relativePath, {\n content,\n mimeType: options.mimeType ?? 'application/octet-stream',\n size: options.size,\n metadata: options.metadata,\n uploadedAt: new Date(),\n })\n\n return {\n path: relativePath,\n disk: diskName,\n fullPath: relativePath,\n size: options.size,\n mimeType: options.mimeType ?? 'application/octet-stream',\n uploadedAt: new Date(),\n }\n }\n\n /**\n * Download a file from fake storage\n */\n download(path: string): Promise<DownloadResult> {\n const file = this.files.get(path)\n\n if (!file) {\n return Promise.reject(new FileNotFoundError(path))\n }\n\n return Promise.resolve({\n toStream: () => new ReadableStream({\n start(controller) {\n controller.enqueue(file.content)\n controller.close()\n },\n }),\n toString: () => Promise.resolve(new TextDecoder().decode(file.content)),\n toArrayBuffer: () => Promise.resolve(file.content),\n contentType: file.mimeType,\n size: file.size,\n metadata: file.metadata,\n })\n }\n\n /**\n * Delete a file from fake storage\n */\n delete(path: string): Promise<void> {\n this.files.delete(path)\n return Promise.resolve()\n }\n\n /**\n * Check if a file exists in fake storage\n */\n exists(path: string): Promise<boolean> {\n return Promise.resolve(this.files.has(path))\n }\n\n /**\n * Generate a fake presigned download URL\n */\n getPresignedDownloadUrl(\n path: string,\n expiresIn?: number\n ): Promise<PresignedUrlResult> {\n return Promise.resolve(this.createPresignedUrl(path, 'GET', expiresIn))\n }\n\n /**\n * Generate a fake presigned upload URL\n */\n getPresignedUploadUrl(\n path: string,\n expiresIn?: number\n ): Promise<PresignedUrlResult> {\n return Promise.resolve(this.createPresignedUrl(path, 'PUT', expiresIn))\n }\n\n /**\n * Generate a fake presigned delete URL\n */\n getPresignedDeleteUrl(\n path: string,\n expiresIn?: number\n ): Promise<PresignedUrlResult> {\n return Promise.resolve(this.createPresignedUrl(path, 'DELETE', expiresIn))\n }\n\n /**\n * Chunked upload (same as regular upload for fake)\n */\n async chunkedUpload(\n body: StreamingBlobPayloadInputTypes,\n path: string,\n options: Omit<UploadOptions, 'size'> & { size?: number },\n disk?: string\n ): Promise<UploadResult> {\n const content = await this.bodyToUint8Array(body)\n const size = options.size ?? content.length\n\n return this.upload(body, path, { ...options, size }, disk)\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Test Assertion Helpers\n // ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Assert that a file exists at the given path\n *\n * @param path - Path to check\n * @throws AssertionError if file does not exist\n */\n assertExists(path: string): void {\n expect(\n this.files.has(path),\n `Expected file to exist at: ${path}\\nStored files: ${this.getStoredPaths().join(', ') || '(none)'}`\n ).toBe(true)\n }\n\n /**\n * Assert that a file does NOT exist at the given path\n *\n * @param path - Path to check\n * @throws AssertionError if file exists\n */\n assertMissing(path: string): void {\n expect(\n this.files.has(path),\n `Expected file NOT to exist at: ${path}`\n ).toBe(false)\n }\n\n /**\n * Assert storage is empty\n *\n * @throws AssertionError if any files exist\n */\n assertEmpty(): void {\n expect(\n this.files.size,\n `Expected storage to be empty but found ${this.files.size} files: ${this.getStoredPaths().join(', ')}`\n ).toBe(0)\n }\n\n /**\n * Assert storage has exactly N files\n *\n * @param count - Expected number of files\n * @throws AssertionError if count doesn't match\n */\n assertCount(count: number): void {\n expect(\n this.files.size,\n `Expected ${count} files in storage but found ${this.files.size}`\n ).toBe(count)\n }\n\n /**\n * Get all stored files (for inspection)\n */\n getStoredFiles(): Map<string, StoredFile> {\n return new Map(this.files)\n }\n\n /**\n * Get all stored file paths\n */\n getStoredPaths(): string[] {\n return Array.from(this.files.keys())\n }\n\n /**\n * Get a specific file by path\n */\n getFile(path: string): StoredFile | undefined {\n return this.files.get(path)\n }\n\n /**\n * Clear all stored files (call in beforeEach for test isolation)\n */\n clear(): void {\n this.files.clear()\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Private Helpers\n // ─────────────────────────────────────────────────────────────────────────\n\n private createPresignedUrl(\n path: string,\n method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',\n expiresIn = 300\n ): PresignedUrlResult {\n const expiresAt = new Date(Date.now() + expiresIn * 1000)\n\n return {\n url: `https://fake-storage.test/${path}?method=${method}&expires=${expiresAt.toISOString()}`,\n expiresIn,\n expiresAt,\n method,\n }\n }\n\n private async bodyToUint8Array(body: StreamingBlobPayloadInputTypes | null | undefined): Promise<Uint8Array> {\n if (!body) {\n return new Uint8Array(0)\n }\n\n if (body instanceof Uint8Array) {\n return body\n }\n\n if (body instanceof ArrayBuffer) {\n return new Uint8Array(body)\n }\n\n if (typeof body === 'string') {\n return new TextEncoder().encode(body)\n }\n\n if (body instanceof Blob) {\n const buffer = await body.arrayBuffer()\n return new Uint8Array(buffer)\n }\n\n if (body instanceof ReadableStream) {\n return new Uint8Array(await new Response(body).arrayBuffer())\n }\n\n // FormData or URLSearchParams - convert via Response\n if (body instanceof FormData || body instanceof URLSearchParams) {\n return new Uint8Array(await new Response(body).arrayBuffer())\n }\n\n return new Uint8Array(0)\n }\n}\n"],"mappings":"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"}
@@ -1,21 +0,0 @@
1
- import { EmailBatchSendResult, EmailSendResult, IEmailProvider, ResolvedEmailMessage } from "stratal/email";
2
-
3
- //#region src/mocks/test-email-provider.d.ts
4
- /**
5
- * In-memory email provider for tests.
6
- *
7
- * The sync queue provider runs `EmailConsumer` inline on dispatch, which would
8
- * otherwise open a real SMTP connection from the test worker. The testing
9
- * module builder installs this provider by default (overridable via
10
- * `overrideProvider(EMAIL_TOKENS.EmailProviderFactory)`), recording every
11
- * message so tests can assert on what was sent.
12
- */
13
- declare class TestEmailProvider implements IEmailProvider {
14
- /** Every message handed to the provider, in send order. */
15
- readonly sent: ResolvedEmailMessage[];
16
- send(message: ResolvedEmailMessage): Promise<EmailSendResult>;
17
- sendBatch(messages: ResolvedEmailMessage[]): Promise<EmailBatchSendResult>;
18
- }
19
- //#endregion
20
- export { TestEmailProvider as t };
21
- //# sourceMappingURL=test-email-provider-B7cjj97-.d.mts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"test-email-provider-B7cjj97-.d.mts","names":[],"sources":["../src/mocks/test-email-provider.ts"],"mappings":";;;;;AAgBA;;;;;;;cAAa,iBAAA,YAA6B,cAAA;EASkB;EAAA,SAPlD,IAAA,EAAM,oBAAA;EAEf,IAAA,CAAK,OAAA,EAAS,oBAAA,GAAuB,OAAA,CAAQ,eAAA;EAKvC,SAAA,CAAU,QAAA,EAAU,oBAAA,KAAyB,OAAA,CAAQ,oBAAA;AAAA"}
@@ -1,34 +0,0 @@
1
- //#region src/mocks/test-email-provider.ts
2
- /**
3
- * In-memory email provider for tests.
4
- *
5
- * The sync queue provider runs `EmailConsumer` inline on dispatch, which would
6
- * otherwise open a real SMTP connection from the test worker. The testing
7
- * module builder installs this provider by default (overridable via
8
- * `overrideProvider(EMAIL_TOKENS.EmailProviderFactory)`), recording every
9
- * message so tests can assert on what was sent.
10
- */
11
- var TestEmailProvider = class {
12
- /** Every message handed to the provider, in send order. */
13
- sent = [];
14
- send(message) {
15
- this.sent.push(message);
16
- return Promise.resolve({
17
- messageId: `test-${this.sent.length}`,
18
- accepted: true
19
- });
20
- }
21
- async sendBatch(messages) {
22
- const results = await Promise.all(messages.map((message) => this.send(message)));
23
- return {
24
- total: results.length,
25
- successful: results.length,
26
- failed: 0,
27
- results
28
- };
29
- }
30
- };
31
- //#endregion
32
- export { TestEmailProvider as t };
33
-
34
- //# sourceMappingURL=test-email-provider-Dr-nhE0x.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"test-email-provider-Dr-nhE0x.mjs","names":[],"sources":["../src/mocks/test-email-provider.ts"],"sourcesContent":["import type {\n\tEmailBatchSendResult,\n\tEmailSendResult,\n\tIEmailProvider,\n\tResolvedEmailMessage,\n} from 'stratal/email'\n\n/**\n * In-memory email provider for tests.\n *\n * The sync queue provider runs `EmailConsumer` inline on dispatch, which would\n * otherwise open a real SMTP connection from the test worker. The testing\n * module builder installs this provider by default (overridable via\n * `overrideProvider(EMAIL_TOKENS.EmailProviderFactory)`), recording every\n * message so tests can assert on what was sent.\n */\nexport class TestEmailProvider implements IEmailProvider {\n\t/** Every message handed to the provider, in send order. */\n\treadonly sent: ResolvedEmailMessage[] = []\n\n\tsend(message: ResolvedEmailMessage): Promise<EmailSendResult> {\n\t\tthis.sent.push(message)\n\t\treturn Promise.resolve({ messageId: `test-${this.sent.length}`, accepted: true })\n\t}\n\n\tasync sendBatch(messages: ResolvedEmailMessage[]): Promise<EmailBatchSendResult> {\n\t\tconst results = await Promise.all(messages.map((message) => this.send(message)))\n\t\treturn {\n\t\t\ttotal: results.length,\n\t\t\tsuccessful: results.length,\n\t\t\tfailed: 0,\n\t\t\tresults,\n\t\t}\n\t}\n}\n"],"mappings":";;;;;;;;;;AAgBA,IAAa,oBAAb,MAAyD;;CAExD,OAAwC,CAAC;CAEzC,KAAK,SAAyD;EAC7D,KAAK,KAAK,KAAK,OAAO;EACtB,OAAO,QAAQ,QAAQ;GAAE,WAAW,QAAQ,KAAK,KAAK;GAAU,UAAU;EAAK,CAAC;CACjF;CAEA,MAAM,UAAU,UAAiE;EAChF,MAAM,UAAU,MAAM,QAAQ,IAAI,SAAS,KAAK,YAAY,KAAK,KAAK,OAAO,CAAC,CAAC;EAC/E,OAAO;GACN,OAAO,QAAQ;GACf,YAAY,QAAQ;GACpB,QAAQ;GACR;EACD;CACD;AACD"}