vona-core 5.1.41 → 5.1.44

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 (43) hide show
  1. package/dist/index.js +1021 -22
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/core/application.d.ts.map +1 -1
  4. package/dist/lib/core/config.d.ts.map +1 -1
  5. package/dist/lib/core/configCleanup.test.d.ts +2 -0
  6. package/dist/lib/core/configCleanup.test.d.ts.map +1 -0
  7. package/dist/lib/core/index.d.ts +3 -0
  8. package/dist/lib/core/index.d.ts.map +1 -1
  9. package/dist/lib/core/isolationGuard.d.ts +12 -0
  10. package/dist/lib/core/isolationGuard.d.ts.map +1 -0
  11. package/dist/lib/core/isolationGuard.test.d.ts +2 -0
  12. package/dist/lib/core/isolationGuard.test.d.ts.map +1 -0
  13. package/dist/lib/core/testClaim.d.ts +165 -0
  14. package/dist/lib/core/testClaim.d.ts.map +1 -0
  15. package/dist/lib/core/testClaim.test.d.ts +2 -0
  16. package/dist/lib/core/testClaim.test.d.ts.map +1 -0
  17. package/dist/lib/core/testIsolation.d.ts +21 -0
  18. package/dist/lib/core/testIsolation.d.ts.map +1 -0
  19. package/dist/lib/framework/createApp.d.ts.map +1 -1
  20. package/dist/lib/framework/start.d.ts.map +1 -1
  21. package/dist/lib/framework/useApp.d.ts.map +1 -1
  22. package/dist/lib/utils/util.d.ts.map +1 -1
  23. package/dist/types/application/app.d.ts +2 -0
  24. package/dist/types/application/app.d.ts.map +1 -1
  25. package/dist/types/utils/env.d.ts +1 -0
  26. package/dist/types/utils/env.d.ts.map +1 -1
  27. package/package.json +2 -2
  28. package/src/lib/addon/sqlite3.ts +1 -1
  29. package/src/lib/core/application.ts +5 -6
  30. package/src/lib/core/config.ts +44 -6
  31. package/src/lib/core/configCleanup.test.ts +40 -0
  32. package/src/lib/core/index.ts +3 -0
  33. package/src/lib/core/isolationGuard.test.ts +301 -0
  34. package/src/lib/core/isolationGuard.ts +312 -0
  35. package/src/lib/core/testClaim.test.ts +577 -0
  36. package/src/lib/core/testClaim.ts +985 -0
  37. package/src/lib/core/testIsolation.ts +129 -0
  38. package/src/lib/framework/createApp.ts +2 -0
  39. package/src/lib/framework/start.ts +4 -0
  40. package/src/lib/framework/useApp.ts +5 -1
  41. package/src/lib/utils/util.ts +7 -2
  42. package/src/types/application/app.ts +2 -0
  43. package/src/types/utils/env.ts +1 -0
package/dist/index.js CHANGED
@@ -2,16 +2,18 @@ import '@cabloy/json5';
2
2
  import { combineApiPathControllerAndActionRaw, combineApiPathControllerAndAction, combineApiPath, combineResourceName, forEach, forEachSync, sleep, isUndefined, isClass, isNilOrEmptyString, evaluateExpressions, catchErrorSync, isEmptyObject, catchError, hashkey } from '@cabloy/utils';
3
3
  import { toLowerCaseFirstChar, splitWords } from '@cabloy/word-utils';
4
4
  import path from 'node:path';
5
+ import fse from 'fs-extra';
6
+ import crypto, { createHash as createHash$1, randomBytes, timingSafeEqual } from 'node:crypto';
7
+ import fs, { lstatSync, constants } from 'node:fs';
5
8
  import { compose as compose$1 } from '@cabloy/compose';
6
9
  import { extend } from '@cabloy/extend';
7
- import fse from 'fs-extra';
8
- import crypto from 'node:crypto';
9
10
  import { createRequire } from 'node:module';
10
11
  import { pathToFileURL } from 'node:url';
11
12
  import * as uuid from 'uuid';
12
- import fs from 'node:fs';
13
13
  import { getPlatformArch as getPlatformArch$1 } from 'vona';
14
14
  import os from 'node:os';
15
+ import { AsyncLocalStorage } from 'node:async_hooks';
16
+ import { isDeepStrictEqual } from 'node:util';
15
17
  import { setLocaleErrors, setLocaleAdapter, translateError } from '@cabloy/zod-errors-custom';
16
18
  import { ZodMetadata } from '@cabloy/zod-openapi';
17
19
  import { setParseAdapter } from '@cabloy/zod-query';
@@ -20,11 +22,12 @@ import { parseModuleName as parseModuleName$1, parseModuleFile as parseModuleFil
20
22
  import * as localeutil from '@cabloy/localeutil';
21
23
  import { getOnionMetasMeta } from '@cabloy/module-info';
22
24
  import KoaApplication from 'koa';
23
- import { AsyncLocalStorage } from 'node:async_hooks';
24
25
  import chalk from 'chalk';
25
26
  import * as Winston from 'winston';
26
27
  import DailyRotateFile from 'winston-daily-rotate-file';
27
28
  import { MESSAGE, LEVEL } from 'triple-beam';
29
+ import { mkdir, open, rename, unlink } from 'node:fs/promises';
30
+ import { createServer } from 'node:http';
28
31
  import cluster from 'node:cluster';
29
32
  import React from 'react';
30
33
  import { extendZodWithOpenApi } from '@cabloy/zod-to-openapi';
@@ -48,6 +51,92 @@ class BeanSimple {
48
51
  }
49
52
  }
50
53
 
54
+ const TEST_RUN_ID_ENV = 'VONA_TEST_RUN_ID';
55
+ const TEST_RUN_OWNER_FILE = '.vona-test-owner';
56
+ const DATABASE_NAME_MAX_BYTES = 63;
57
+ const APP_NAME_MAX_BYTES = 13;
58
+ const HASH_LENGTH = 10;
59
+ const TIMESTAMP_LENGTH = 15;
60
+ function getDatabaseTestPrefix(appName, scope) {
61
+ const readablePrefix = scope === 'share' ? `vona-test-${appName}-s-` : `vona-test-${appName}-i-${scope.slice('isolate-'.length)}-`;
62
+ if (Buffer.byteLength(readablePrefix, 'utf8') + TIMESTAMP_LENGTH <= DATABASE_NAME_MAX_BYTES) {
63
+ return readablePrefix;
64
+ }
65
+ let appHead = '';
66
+ for (const char of appName) {
67
+ if (Buffer.byteLength(`${appHead}${char}`, 'utf8') > APP_NAME_MAX_BYTES) break;
68
+ appHead += char;
69
+ }
70
+ const scopeKind = scope === 'share' ? 's' : 'i';
71
+ const namespaceHash = createHash$1('sha256').update(`${appName}\0${scope}`).digest('base64url').slice(0, HASH_LENGTH);
72
+ return `vona-test-${appHead}-${scopeKind}-${namespaceHash}-`;
73
+ }
74
+ function assertDisposableDatabaseName(databaseName, databasePrefix) {
75
+ if (typeof databaseName !== 'string' || !databaseName.startsWith(databasePrefix) || !/^\d{8}-\d{6}$/.test(databaseName.slice(databasePrefix.length)) || Buffer.byteLength(databaseName, 'utf8') > DATABASE_NAME_MAX_BYTES) {
76
+ throw new Error(`Database is outside the disposable namespace: ${databaseName}`);
77
+ }
78
+ }
79
+ function resolveTestRunId(mode, runId = process.env[TEST_RUN_ID_ENV]) {
80
+ if (runId === undefined) return undefined;
81
+ const match = /^(\d{14})-[a-f0-9]{8}$/.exec(runId);
82
+ const timestamp = match?.[1];
83
+ const date = timestamp ? new Date(`${timestamp.slice(0, 4)}-${timestamp.slice(4, 6)}-${timestamp.slice(6, 8)}T${timestamp.slice(8, 10)}:${timestamp.slice(10, 12)}:${timestamp.slice(12, 14)}Z`) : undefined;
84
+ if (mode !== 'test' || !timestamp || !date || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 19).replace(/[-:T]/g, '') !== timestamp) {
85
+ throw new Error(`${TEST_RUN_ID_ENV} requires test mode and a UTC timestamp plus eight hex digits`);
86
+ }
87
+ return runId;
88
+ }
89
+ function getTestAppName(runId) {
90
+ resolveTestRunId('test', runId);
91
+ return `cabtest${runId}`;
92
+ }
93
+ function assertTestAppIdentity(mode, runId, appName) {
94
+ const id = resolveTestRunId(mode, runId);
95
+ if (id && appName !== getTestAppName(id)) {
96
+ throw new Error('Isolated test APP_NAME does not match its run ID');
97
+ }
98
+ return id;
99
+ }
100
+ function getTestRunIdFromEnv(env) {
101
+ return resolveTestRunId(env.META_MODE, env[TEST_RUN_ID_ENV]);
102
+ }
103
+ function assertTestRunOwner(root, runId) {
104
+ resolveTestRunId('test', runId);
105
+ let current = path.resolve(root);
106
+ while (true) {
107
+ try {
108
+ if (lstatSync(current).isSymbolicLink()) {
109
+ throw new Error(`Isolated test path traverses a symlink: ${current}`);
110
+ }
111
+ } catch (error) {
112
+ if (error.code !== 'ENOENT') throw error;
113
+ }
114
+ const parent = path.dirname(current);
115
+ if (parent === current) break;
116
+ current = parent;
117
+ }
118
+ const marker = path.join(root, TEST_RUN_OWNER_FILE);
119
+ if (!fse.lstatSync(marker).isFile() || fse.readFileSync(marker, 'utf8') !== `${runId}\n`) {
120
+ throw new Error(`Isolated test directory has no matching owner: ${root}`);
121
+ }
122
+ }
123
+ function resolveVonaLocalPaths(projectPath, mode, runId = process.env[TEST_RUN_ID_ENV]) {
124
+ const id = resolveTestRunId(mode, runId);
125
+ const generatedDir = id ? `.vona-test-${id}` : '.vona';
126
+ const dataDir = id ? `.app-test-${id}` : '.app';
127
+ return {
128
+ runId: id,
129
+ generatedDir,
130
+ dataDir,
131
+ generatedRoot: path.join(projectPath, generatedDir),
132
+ dataRoot: path.join(projectPath, dataDir),
133
+ publicRoot: path.join(projectPath, dataDir, 'public'),
134
+ runtimeRoot: path.join(projectPath, dataDir, 'runtime'),
135
+ logsRoot: path.join(projectPath, dataDir, 'logs'),
136
+ sqliteRoot: path.join(projectPath, dataDir, 'sqlite3')
137
+ };
138
+ }
139
+
51
140
  const EnumAppEvent = {
52
141
  AppStarted: 'eb:event:appStarted',
53
142
  AppStartError: 'eb:event:appStartError'
@@ -91,6 +180,183 @@ function _matchSharpLibvipsFileName(fileName) {
91
180
  return /\.so(?:\..+)?$/.test(fileName);
92
181
  }
93
182
 
183
+ const databaseDiscovery = new AsyncLocalStorage();
184
+ async function withIsolatedTestDatabaseDiscovery(app, clientName, client, fn) {
185
+ if (!app.meta.env.VONA_TEST_RUN_ID) return fn();
186
+ if (app.meta.appReady) throw new Error('Isolated test cannot discover databases after startup');
187
+ const config = app.config;
188
+ const instanceClients = Object.values(config.instance.instances).flatMap(instance => instance && instance.isolate && instance.isolateClient ? [instance.isolateClient] : []);
189
+ if (!client || clientName !== config.database.defaultClient && !instanceClients.includes(clientName) || config.database.clients[clientName]?.client !== client.client || !isDeepStrictEqual(client.connection, config.database.clients[clientName].connection) || !client.connection || typeof client.connection !== 'object' || Array.isArray(client.connection)) {
190
+ throw new Error(`Isolated test cannot discover an unconfigured database client: ${clientName}`);
191
+ }
192
+ return databaseDiscovery.run({
193
+ app,
194
+ clientName,
195
+ client
196
+ }, async () => {
197
+ assertIsolatedTestDatabaseClient(app, clientName, client);
198
+ return await fn();
199
+ });
200
+ }
201
+ function assertIsolatedTestDatabaseClient(app, clientName, client) {
202
+ const id = assertTestAppIdentity(app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID, app.name);
203
+ if (!id) return;
204
+ const paths = resolveVonaLocalPaths(app.projectPath, 'test', id);
205
+ assertTestRunOwner(paths.dataRoot, id);
206
+ if (client.connectionPool !== undefined) {
207
+ throw new Error(`Isolated test database cannot use an external pool: ${clientName}`);
208
+ }
209
+ if (!client.connection || typeof client.connection !== 'object' || Array.isArray(client.connection)) {
210
+ throw new Error(`Isolated test database requires an explicit connection target: ${clientName}`);
211
+ }
212
+ const connection = client.connection;
213
+ if (connection.connectionString !== undefined || connection.uri !== undefined) {
214
+ throw new Error(`Isolated test database cannot use a connection URL: ${clientName}`);
215
+ }
216
+ if (!['better-sqlite3', 'pg', 'mysql', 'mysql2'].includes(client.client)) {
217
+ throw new Error(`Isolated test database client is unsupported: ${clientName}`);
218
+ }
219
+ if (client.client === 'better-sqlite3' ? connection.database !== undefined : connection.filename !== undefined) {
220
+ throw new Error(`Isolated test database has conflicting connection targets: ${clientName}`);
221
+ }
222
+ const config = app.config;
223
+ const configured = config.database;
224
+ const discovering = databaseDiscovery.getStore();
225
+ const target = client.client === 'better-sqlite3' ? connection.filename : connection.database;
226
+ if (discovering?.app === app && discovering.clientName === clientName && !app.meta.appReady && configured.clients[clientName]?.client === client.client && client.client === discovering.client.client && isDeepStrictEqual(connection, discovering.client.connection) && isDeepStrictEqual(connection, configured.clients[clientName].connection)) {
227
+ const discoveryTarget = client.client === 'better-sqlite3' ? '' : client.client === 'pg' ? 'postgres' : 'mysql';
228
+ if (target === discoveryTarget || client.client === 'better-sqlite3' && target === ':memory:') {
229
+ return;
230
+ }
231
+ }
232
+ const scopes = new Set(['share']);
233
+ for (const [instanceName, instance] of Object.entries(config.instance.instances)) {
234
+ if (instance && instance.isolate && instance.isolateClient) {
235
+ scopes.add(`isolate-${instanceName}`);
236
+ }
237
+ }
238
+ if (typeof target !== 'string') {
239
+ throw new TypeError(`Isolated test database requires a disposable target: ${clientName}`);
240
+ }
241
+ let matched = false;
242
+ for (const scope of scopes) {
243
+ const prefix = getDatabaseTestPrefix(app.name, scope);
244
+ const databaseName = client.client === 'better-sqlite3' ? path.basename(target, '.db') : target;
245
+ try {
246
+ assertDisposableDatabaseName(databaseName, prefix);
247
+ matched = true;
248
+ break;
249
+ } catch {
250
+ // Try the next owned namespace.
251
+ }
252
+ }
253
+ if (!matched) throw new Error(`Isolated test database is outside its namespace: ${clientName}`);
254
+ if (client.client === 'better-sqlite3') {
255
+ if (!path.isAbsolute(target) || path.extname(target) !== '.db') {
256
+ throw new Error(`Isolated test SQLite target must be an owned .db file: ${clientName}`);
257
+ }
258
+ assertIsolatedTestPath(target, path.join(paths.sqliteRoot, path.basename(target)));
259
+ }
260
+ }
261
+ function assertIsolatedTestPath(actual, expected) {
262
+ if (path.resolve(actual) !== path.resolve(expected)) {
263
+ throw new Error(`Isolated test path escapes its owned root: ${actual}`);
264
+ }
265
+ let current = path.resolve(actual);
266
+ while (true) {
267
+ try {
268
+ if (lstatSync(current).isSymbolicLink()) {
269
+ throw new Error(`Isolated test path traverses a symlink: ${current}`);
270
+ }
271
+ } catch (error) {
272
+ if (error.code !== 'ENOENT') throw error;
273
+ }
274
+ const parent = path.dirname(current);
275
+ if (parent === current) break;
276
+ current = parent;
277
+ }
278
+ }
279
+ function assertIsolatedTestConfig(app) {
280
+ const env = app.meta.env;
281
+ const id = assertTestAppIdentity(env.META_MODE, env.VONA_TEST_RUN_ID, app.name);
282
+ if (!id) return;
283
+ const paths = resolveVonaLocalPaths(app.projectPath, env.META_MODE, id);
284
+ assertTestRunOwner(paths.dataRoot, id);
285
+ assertTestRunOwner(paths.generatedRoot, id);
286
+ assertIsolatedTestPath(app.config.server.publicDir, paths.publicRoot);
287
+ assertIsolatedTestPath(app.config.logger.baseDir, paths.logsRoot);
288
+ assertIsolatedTestPath(paths.runtimeRoot, paths.runtimeRoot);
289
+ assertIsolatedTestPath(paths.sqliteRoot, paths.sqliteRoot);
290
+ if (app.config.server.workers !== 1) throw new Error('Isolated test requires one Vona worker');
291
+ const config = app.config;
292
+ if (process.env.VONA_DB_RESET !== undefined) {
293
+ throw new Error('Isolated test cannot run a development database reset');
294
+ }
295
+ if (!config.database.testDatabase) {
296
+ throw new Error('Isolated test requires disposable test database configuration');
297
+ }
298
+ if (typeof config.database.defaultClient !== 'string') {
299
+ throw new TypeError('Isolated test requires a static default database client');
300
+ }
301
+ if (config.database.base.connection !== undefined || config.database.base.connectionPool !== undefined) {
302
+ throw new Error('Isolated test cannot inherit a database base connection target or pool');
303
+ }
304
+ const clientNames = new Set([config.database.defaultClient]);
305
+ const instances = config.instance.instances;
306
+ for (const instance of Object.values(instances)) {
307
+ if (!instance || !instance.isolate) continue;
308
+ if (!instance.isolateClient) throw new Error('Isolated test instance requires an isolateClient');
309
+ clientNames.add(instance.isolateClient);
310
+ }
311
+ for (const clientName of clientNames) {
312
+ if (!config.database.clients[clientName]) {
313
+ throw new Error(`Isolated test has no configured database client: ${clientName}`);
314
+ }
315
+ }
316
+ for (const [clientName, client] of Object.entries(config.database.clients)) {
317
+ if (client.connectionPool !== undefined) {
318
+ throw new Error(`Isolated test database client has an external target or pool: ${clientName}`);
319
+ }
320
+ // Optional, unused clients may intentionally remain empty in local configuration.
321
+ // Any configured connection is still validated, even when that client is not selected.
322
+ if (client.connection === undefined && !clientNames.has(clientName)) continue;
323
+ if (typeof client.connection !== 'object' || client.connection === null || Array.isArray(client.connection) || client.connection.connectionString !== undefined || client.connection.uri !== undefined) {
324
+ throw new Error(`Isolated test database client has an external target or pool: ${clientName}`);
325
+ }
326
+ if (!clientNames.has(clientName)) continue;
327
+ if (!['better-sqlite3', 'pg', 'mysql', 'mysql2'].includes(client.client)) {
328
+ throw new Error(`Isolated test has an unsupported database client: ${clientName}`);
329
+ }
330
+ const connection = client.connection;
331
+ const expected = client.client === 'better-sqlite3' ? '' : client.client === 'pg' ? 'postgres' : 'mysql';
332
+ const target = client.client === 'better-sqlite3' ? connection.filename : connection.database;
333
+ if (target !== expected && !(client.client === 'better-sqlite3' && target === ':memory:')) {
334
+ throw new Error(`Isolated test requires an administrative discovery connection: ${clientName}`);
335
+ }
336
+ if (client.client === 'better-sqlite3' ? connection.database !== undefined : connection.filename !== undefined) {
337
+ throw new Error(`Isolated test discovery connection has conflicting targets: ${clientName}`);
338
+ }
339
+ }
340
+ const redis = config.redis;
341
+ if (redis.base.keyPrefix !== undefined) {
342
+ throw new Error('Isolated test cannot inherit a Redis base keyPrefix');
343
+ }
344
+ for (const [clientName, client] of Object.entries(redis.clients)) {
345
+ const special = ['queue', 'broadcast'].includes(clientName) || clientName.startsWith('redlock');
346
+ if (client.sentinels || client.nodes) {
347
+ throw new Error(`Isolated test cannot use a Redis sentinel or cluster client: ${clientName}`);
348
+ }
349
+ if (special ? client.keyPrefix !== undefined : client.keyPrefix !== true) {
350
+ throw new Error(`Isolated test requires a framework-managed Redis prefix: ${clientName}`);
351
+ }
352
+ }
353
+ const expectedPort = Number(env.SERVER_LISTEN_PORT);
354
+ const requiresServerOrigin = process.env.VONA_TEST_SERVER_ORIGIN === 'true';
355
+ if (!Number.isInteger(expectedPort) || expectedPort <= 0 || expectedPort > 65535 || app.config.server.listen.port !== expectedPort || app.config.server.listen.hostname !== '127.0.0.1' || requiresServerOrigin && (app.config.server.serve.protocol !== 'http' || app.config.server.serve.host !== `127.0.0.1:${expectedPort}`)) {
356
+ throw new Error(requiresServerOrigin ? 'Isolated test listener and server origin must match the assigned loopback port' : 'Isolated test listener must match the assigned loopback port');
357
+ }
358
+ }
359
+
94
360
  async function combineConfigDefault(app, configDefault, configDev, configProd, configTest) {
95
361
  let config = await configDefault(app);
96
362
  const mode = app.meta.env.META_MODE;
@@ -107,35 +373,50 @@ function getLoggerPathPhysicalRoot(app) {
107
373
  const mode = app.meta.env.META_MODE;
108
374
  let loggerDir;
109
375
  if (mode === 'test' || mode === 'dev') {
110
- loggerDir = path.join(app.projectPath, '.app/logs');
376
+ loggerDir = resolveVonaLocalPaths(app.projectPath, mode, app.meta.env.VONA_TEST_RUN_ID).logsRoot;
111
377
  } else {
112
378
  loggerDir = path.join(getHomeVonaAppDir(app), 'logs');
113
379
  }
114
- fse.ensureDirSync(loggerDir);
380
+ if (!app.options.configResolutionOnly) {
381
+ assertOwnedLocalPath(app, loggerDir, 'logsRoot');
382
+ fse.ensureDirSync(loggerDir);
383
+ }
115
384
  return loggerDir;
116
385
  }
117
386
  function getPublicPathPhysicalRoot(app) {
118
387
  const mode = app.meta.env.META_MODE;
119
388
  let publicDir;
120
389
  if (mode === 'test' || mode === 'dev') {
121
- publicDir = path.join(app.projectPath, '.app/public');
390
+ publicDir = resolveVonaLocalPaths(app.projectPath, mode, app.meta.env.VONA_TEST_RUN_ID).publicRoot;
122
391
  } else {
123
392
  publicDir = path.join(getHomeVonaAppDir(app), 'public');
124
393
  }
125
- fse.ensureDirSync(publicDir);
394
+ if (!app.options.configResolutionOnly) {
395
+ assertOwnedLocalPath(app, publicDir, 'publicRoot');
396
+ fse.ensureDirSync(publicDir);
397
+ }
126
398
  return publicDir;
127
399
  }
128
400
  function getRuntimePathPhysicalRoot(app) {
129
401
  const mode = app.meta.env.META_MODE;
130
402
  let runtimeDir;
131
403
  if (mode === 'test' || mode === 'dev') {
132
- runtimeDir = path.join(app.options.projectPath, '.app/runtime');
404
+ runtimeDir = resolveVonaLocalPaths(app.options.projectPath, mode, app.meta.env.VONA_TEST_RUN_ID).runtimeRoot;
133
405
  } else {
134
406
  runtimeDir = path.join(getHomeVonaAppDir(app), 'runtime');
135
407
  }
136
- fse.ensureDirSync(runtimeDir);
408
+ if (!app.options.configResolutionOnly) {
409
+ assertOwnedLocalPath(app, runtimeDir, 'runtimeRoot');
410
+ fse.ensureDirSync(runtimeDir);
411
+ }
137
412
  return runtimeDir;
138
413
  }
414
+ function assertOwnedLocalPath(app, actual, leaf) {
415
+ const paths = resolveVonaLocalPaths(app.projectPath, app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID);
416
+ if (!paths.runId) return;
417
+ assertTestRunOwner(paths.dataRoot, paths.runId);
418
+ assertIsolatedTestPath(actual, paths[leaf]);
419
+ }
139
420
  function getHomeVonaAppDir(app) {
140
421
  return path.join(os.homedir(), '.vona', app.name);
141
422
  }
@@ -144,7 +425,7 @@ function getSqlite3DatabaseNameDefault(app) {
144
425
  const mode = app.meta.env.META_MODE;
145
426
  if (mode !== 'prod') return '';
146
427
  const dbPath = path.join(getHomeVonaAppDir(app), 'sqlite3');
147
- fse.ensureDirSync(dbPath);
428
+ if (!app.options.configResolutionOnly) fse.ensureDirSync(dbPath);
148
429
  return path.join(dbPath, `${app.name}.db`);
149
430
  }
150
431
  function resolverSqlite3(projectPath) {
@@ -234,11 +515,16 @@ class AppUtil extends BeanSimple {
234
515
  }
235
516
  get protocol() {
236
517
  const config = this.ctx ? this.ctx.config : this.app.config;
237
- return config.server.serve.protocol || this.ctx?.protocol;
518
+ return config.server.serve.protocol || this.ctx?.protocol || 'http';
238
519
  }
239
520
  get host() {
240
521
  const config = this.ctx ? this.ctx.config : this.app.config;
241
- return config.server.serve.host || this.ctx?.host;
522
+ let host = config.server.serve.host || this.ctx?.host;
523
+ if (!host) {
524
+ const port = this.app.meta.env.SERVER_LISTEN_PORT;
525
+ host = port ? `localhost:${port}` : 'localhost';
526
+ }
527
+ return host;
242
528
  }
243
529
  getAbsoluteUrl(path) {
244
530
  const prefix = this.host ? `${this.protocol}://${this.host}` : '';
@@ -529,7 +815,7 @@ async function createGeneralApp(projectPath, envRuntime) {
529
815
  const testInstance = await import(pathToHref(testFile));
530
816
  return await testInstance.appBootstrap;
531
817
  } else {
532
- const testFile = path.join(projectPath, '.vona/app.ts');
818
+ const testFile = path.join(resolveVonaLocalPaths(projectPath, process.env.META_MODE).generatedRoot, 'app.ts');
533
819
  const testInstance = await import(pathToHref(testFile));
534
820
  return await testInstance.createSingleApp();
535
821
  }
@@ -1915,7 +2201,7 @@ function $makeLocaleMagic(str, ...args) {
1915
2201
  }
1916
2202
 
1917
2203
  // not use : ILocaleMagic
1918
- function text(strings, ...expressions) {
2204
+ function text$1(strings, ...expressions) {
1919
2205
  return {
1920
2206
  toString() {
1921
2207
  let result = '';
@@ -2678,10 +2964,9 @@ class VonaApplication extends KoaApplication {
2678
2964
  if (req) {
2679
2965
  request = req;
2680
2966
  } else {
2681
- const port = this.config?.server.listen.port ?? this.meta.env.SERVER_LISTEN_PORT;
2682
- const hostFallback = port ? `localhost:${port}` : 'localhost';
2683
- const protocol = (this.config ? this.util.protocol : undefined) || 'http';
2684
- const host = (this.config ? this.util.host : undefined) || hostFallback;
2967
+ const protocol = 'http';
2968
+ const port = this.meta.env.SERVER_LISTEN_PORT;
2969
+ const host = port ? `localhost:${port}` : 'localhost';
2685
2970
  const hostname = new URL(`${protocol}://${host}`).hostname;
2686
2971
  request = {
2687
2972
  headers: {
@@ -2695,12 +2980,12 @@ class VonaApplication extends KoaApplication {
2695
2980
  host,
2696
2981
  hostname,
2697
2982
  protocol,
2698
- secure: protocol === 'https',
2983
+ secure: false,
2699
2984
  method: 'POST',
2700
2985
  url: '',
2701
2986
  path: '',
2702
2987
  socket: {
2703
- encrypted: protocol === 'https',
2988
+ encrypted: false,
2704
2989
  remoteAddress: '127.0.0.1',
2705
2990
  remotePort: 7001
2706
2991
  }
@@ -2891,6 +3176,717 @@ function __formatLoggerFilterCheckInfo(info) {
2891
3176
  return info;
2892
3177
  }
2893
3178
 
3179
+ const TEST_CLAIM_URL_ENV = 'VONA_TEST_CLAIM_URL';
3180
+ const TEST_CLAIM_TOKEN_ENV = 'VONA_TEST_CLAIM_TOKEN';
3181
+ const TEST_CLAIM_DIRECTORY = '.vona-test-claim';
3182
+ function getTestClaimPaths(projectPath, runId) {
3183
+ resolveTestRunId('test', runId);
3184
+ const project = path.resolve(projectPath);
3185
+ const local = resolveVonaLocalPaths(project, 'test', runId);
3186
+ const claimDirectory = path.join(project, TEST_CLAIM_DIRECTORY);
3187
+ return {
3188
+ ...local,
3189
+ claimDirectory,
3190
+ claimPath: path.join(claimDirectory, `${runId}.json`)
3191
+ };
3192
+ }
3193
+ function getTestClaimCreatedAt(runId) {
3194
+ resolveTestRunId('test', runId);
3195
+ const s = runId.slice(0, 14);
3196
+ return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(8, 10)}:${s.slice(10, 12)}:${s.slice(12, 14)}.000Z`;
3197
+ }
3198
+ function assertTestClaimSafePath(file, allowMissing = true) {
3199
+ let current = path.resolve(file);
3200
+ while (true) {
3201
+ try {
3202
+ const stat = lstatSync(current);
3203
+ if (stat.isSymbolicLink() || current !== path.resolve(file) && !stat.isDirectory()) {
3204
+ throw new Error('Unsafe test claim path');
3205
+ }
3206
+ } catch (error) {
3207
+ if (!allowMissing || error.code !== 'ENOENT') throw error;
3208
+ }
3209
+ const parent = path.dirname(current);
3210
+ if (parent === current) break;
3211
+ current = parent;
3212
+ }
3213
+ }
3214
+ function record(value) {
3215
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid test claim object');
3216
+ }
3217
+ function keys(value, allowed) {
3218
+ record(value);
3219
+ if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error('Unknown test claim field');
3220
+ }
3221
+ function text(value) {
3222
+ if (typeof value !== 'string' || !value || value.length > 4096 || [...value].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) {
3223
+ throw new Error('Invalid test claim text');
3224
+ }
3225
+ }
3226
+ function label(value) {
3227
+ text(value);
3228
+ if (!/^[\w.-]+$/.test(value)) throw new Error('Invalid test claim label');
3229
+ }
3230
+ function integer(value, min, max = Number.MAX_SAFE_INTEGER) {
3231
+ if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error('Invalid test claim integer');
3232
+ }
3233
+ function oneOf(value, values) {
3234
+ if (!values.includes(value)) throw new Error('Invalid test claim state');
3235
+ }
3236
+ function bool(value) {
3237
+ if (typeof value !== 'boolean') throw new Error('Invalid test claim boolean');
3238
+ }
3239
+ function array(value) {
3240
+ if (!Array.isArray(value) || value.length > 10000) throw new Error('Invalid test claim array');
3241
+ }
3242
+ function unique(values) {
3243
+ if (new Set(values).size !== values.length) throw new Error('Duplicate test claim resource');
3244
+ }
3245
+ function validateEndpoint(value, redis) {
3246
+ keys(value, redis ? ['transport', 'host', 'port', 'socketPath', 'tls', 'db'] : ['dialect', 'transport', 'host', 'port', 'socketPath', 'tls', 'adminDatabase']);
3247
+ const endpoint = value;
3248
+ bool(endpoint.tls);
3249
+ oneOf(endpoint.transport, redis ? ['tcp', 'socket'] : ['tcp', 'socket', 'file']);
3250
+ if (redis) integer(endpoint.db, 0);else oneOf(endpoint.dialect, ['pg', 'mysql', 'mysql2', 'better-sqlite3']);
3251
+ if (endpoint.transport === 'tcp') {
3252
+ text(endpoint.host);
3253
+ if (/[\s/@?#\\]/.test(endpoint.host)) throw new Error('Invalid test claim host');
3254
+ integer(endpoint.port, 1, 65535);
3255
+ if (endpoint.socketPath !== undefined) throw new Error('Conflicting test claim endpoint');
3256
+ } else {
3257
+ text(endpoint.socketPath);
3258
+ if (!path.isAbsolute(endpoint.socketPath) || path.resolve(endpoint.socketPath) !== endpoint.socketPath || endpoint.host !== undefined) {
3259
+ throw new Error('Invalid test claim socket');
3260
+ }
3261
+ if (!redis && endpoint.dialect === 'pg' && endpoint.transport === 'socket') integer(endpoint.port, 1, 65535);else if (endpoint.port !== undefined) throw new Error('Invalid test claim socket port');
3262
+ }
3263
+ if (!redis) {
3264
+ if (endpoint.dialect === 'better-sqlite3') {
3265
+ if (endpoint.transport !== 'file' || endpoint.tls || endpoint.adminDatabase !== undefined) throw new Error('Invalid SQLite claim endpoint');
3266
+ } else if (endpoint.transport === 'file' || endpoint.adminDatabase !== (endpoint.dialect === 'pg' ? 'postgres' : 'mysql')) {
3267
+ throw new Error('Invalid database administration endpoint');
3268
+ }
3269
+ }
3270
+ }
3271
+ function validateTestClaim(value, claimPath) {
3272
+ keys(value, ['version', 'runId', 'createdAt', 'projectPath', 'kind', 'owner', 'status', 'locals', 'databases', 'redis', 'processes', 'cleanup']);
3273
+ const m = value;
3274
+ if (m.version !== 1) throw new Error('Unsupported test claim version');
3275
+ resolveTestRunId('test', m.runId);
3276
+ if (m.createdAt !== getTestClaimCreatedAt(m.runId)) throw new Error('Invalid test claim creation time');
3277
+ text(m.projectPath);
3278
+ if (!path.isAbsolute(m.projectPath) || path.resolve(m.projectPath) !== m.projectPath) throw new Error('Invalid test claim checkout');
3279
+ const p = getTestClaimPaths(m.projectPath, m.runId);
3280
+ if (claimPath && path.resolve(claimPath) !== p.claimPath) throw new Error('Test claim filename or checkout mismatch');
3281
+ oneOf(m.kind, ['unit', 'cov', 'e2e']);
3282
+ keys(m.owner, ['pid', 'startTime', 'host']);
3283
+ integer(m.owner.pid, 1);
3284
+ text(m.owner.startTime);
3285
+ text(m.owner.host);
3286
+ oneOf(m.status, ['allocated', 'running', 'failed', 'cleaning']);
3287
+ array(m.locals);
3288
+ unique(m.locals.map(item => item.kind));
3289
+ for (const item of m.locals) {
3290
+ keys(item, ['kind', 'path', 'stage']);
3291
+ oneOf(item.kind, ['generated', 'data']);
3292
+ oneOf(item.stage, ['intent', 'created', 'owned', 'released']);
3293
+ const expected = item.kind === 'generated' ? p.generatedRoot : p.dataRoot;
3294
+ if (item.path !== expected) throw new Error('Invalid test claim local root');
3295
+ }
3296
+ if (m.locals.length !== 2 || !m.locals.some(item => item.kind === 'data') || !m.locals.some(item => item.kind === 'generated')) {
3297
+ throw new Error('Missing test claim local roots');
3298
+ }
3299
+ array(m.databases);
3300
+ unique(m.databases.map(item => `${item.clientName}\0${item.scope}`));
3301
+ for (const db of m.databases) {
3302
+ keys(db, ['clientName', 'scope', 'prefix', 'endpoint', 'creations']);
3303
+ label(db.clientName);
3304
+ text(db.scope);
3305
+ if (db.scope !== 'share' && !/^isolate-[\w-]+$/.test(db.scope)) throw new Error('Invalid test claim database scope');
3306
+ if (db.prefix !== getDatabaseTestPrefix(getTestAppName(m.runId), db.scope)) throw new Error('Invalid test claim database prefix');
3307
+ validateEndpoint(db.endpoint, false);
3308
+ if (db.endpoint.dialect === 'better-sqlite3' && db.endpoint.socketPath !== p.sqliteRoot) throw new Error('Invalid test claim SQLite root');
3309
+ array(db.creations);
3310
+ unique(db.creations.map(item => item.name));
3311
+ for (const creation of db.creations) {
3312
+ keys(creation, ['name', 'stage']);
3313
+ oneOf(creation.stage, ['intent', 'created', 'released']);
3314
+ text(creation.name);
3315
+ let name = creation.name;
3316
+ if (db.endpoint.dialect === 'better-sqlite3') {
3317
+ if (name !== path.join(p.sqliteRoot, path.basename(name)) || path.extname(name) !== '.db') throw new Error('Invalid test claim SQLite target');
3318
+ name = path.basename(name, '.db');
3319
+ }
3320
+ assertDisposableDatabaseName(name, db.prefix);
3321
+ }
3322
+ }
3323
+ array(m.redis);
3324
+ unique(m.redis.map(item => item.clientName));
3325
+ for (const item of m.redis) {
3326
+ keys(item, ['clientName', 'kind', 'prefix', 'endpoint', 'released']);
3327
+ label(item.clientName);
3328
+ oneOf(item.kind, ['client', 'queue', 'redlock']);
3329
+ const expected = getTestClaimRedisPrefix(m.runId, item.clientName);
3330
+ if (!expected || item.kind !== expected.kind || item.prefix !== expected.prefix) throw new Error('Invalid test claim Redis prefix');
3331
+ validateEndpoint(item.endpoint, true);
3332
+ bool(item.released);
3333
+ }
3334
+ array(m.processes);
3335
+ unique(m.processes.map(item => item.id));
3336
+ for (const proc of m.processes) {
3337
+ keys(proc, ['id', 'role', 'stage', 'pid', 'pgid', 'startTime', 'host', 'profilePath']);
3338
+ label(proc.id);
3339
+ oneOf(proc.role, ['guardian', 'browser', 'cleanup']);
3340
+ if (proc.host !== undefined) text(proc.host);
3341
+ oneOf(proc.stage, ['pending', 'registered', 'drained']);
3342
+ const hasIdentity = proc.pid !== undefined || proc.pgid !== undefined || proc.startTime !== undefined;
3343
+ if (hasIdentity || proc.stage !== 'pending') {
3344
+ integer(proc.pid, 1);
3345
+ integer(proc.pgid, 1);
3346
+ text(proc.startTime);
3347
+ }
3348
+ if (proc.profilePath !== undefined) {
3349
+ text(proc.profilePath);
3350
+ if (path.resolve(proc.profilePath) !== proc.profilePath || !proc.profilePath.startsWith(`${p.dataRoot}${path.sep}`)) {
3351
+ throw new Error('Invalid test claim process profile');
3352
+ }
3353
+ }
3354
+ }
3355
+ keys(m.cleanup, ['processes', 'databases', 'redis', 'locals']);
3356
+ for (const key of ['processes', 'databases', 'redis', 'locals']) bool(m.cleanup[key]);
3357
+ return m;
3358
+ }
3359
+ function assertPrivateFile(file) {
3360
+ assertTestClaimSafePath(file, false);
3361
+ const stat = lstatSync(file);
3362
+ if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || process.getuid && stat.uid !== process.getuid()) {
3363
+ throw new Error('Unsafe test claim file');
3364
+ }
3365
+ }
3366
+ async function loadTestClaim(claimPath) {
3367
+ assertPrivateFile(claimPath);
3368
+ const handle = await open(claimPath, constants.O_RDONLY | constants.O_NOFOLLOW);
3369
+ try {
3370
+ const stat = await handle.stat();
3371
+ if (!stat.isFile() || stat.size > 4 * 1024 * 1024) throw new Error('Invalid test claim file');
3372
+ return validateTestClaim(parseTestClaimJson(await handle.readFile('utf8')), claimPath);
3373
+ } finally {
3374
+ await handle.close();
3375
+ }
3376
+ }
3377
+ function parseTestClaimJson(source) {
3378
+ // Claim identities stay literal even when @cabloy/json5 revives ISO strings.
3379
+ return JSON.parse(source, function (key) {
3380
+ return this[key];
3381
+ });
3382
+ }
3383
+ async function syncDirectory(directory) {
3384
+ const handle = await open(directory, constants.O_RDONLY);
3385
+ try {
3386
+ await handle.sync();
3387
+ } finally {
3388
+ await handle.close();
3389
+ }
3390
+ }
3391
+ async function createTestClaim(projectPath, runId, kind, owner) {
3392
+ const p = getTestClaimPaths(projectPath, runId);
3393
+ assertTestClaimSafePath(p.claimDirectory);
3394
+ await mkdir(p.claimDirectory, {
3395
+ mode: 0o700
3396
+ }).catch(error => {
3397
+ if (error.code !== 'EEXIST') throw error;
3398
+ });
3399
+ // The directory is shared across runs; an existing directory is valid, never an existing claim.
3400
+ return createClaimFile(p, runId, kind, owner);
3401
+ }
3402
+ async function createClaimFile(p, runId, kind, owner) {
3403
+ assertTestClaimSafePath(p.claimDirectory, false);
3404
+ const directoryStat = lstatSync(p.claimDirectory);
3405
+ if (!directoryStat.isDirectory() || (directoryStat.mode & 0o077) !== 0 || process.getuid && directoryStat.uid !== process.getuid()) {
3406
+ throw new Error('Unsafe shared test claim directory');
3407
+ }
3408
+ const manifest = {
3409
+ version: 1,
3410
+ runId,
3411
+ createdAt: getTestClaimCreatedAt(runId),
3412
+ projectPath: path.dirname(p.claimDirectory),
3413
+ kind,
3414
+ owner,
3415
+ status: 'allocated',
3416
+ locals: [{
3417
+ kind: 'generated',
3418
+ path: p.generatedRoot,
3419
+ stage: 'intent'
3420
+ }, {
3421
+ kind: 'data',
3422
+ path: p.dataRoot,
3423
+ stage: 'intent'
3424
+ }],
3425
+ databases: [],
3426
+ redis: [],
3427
+ processes: [],
3428
+ cleanup: {
3429
+ processes: false,
3430
+ databases: false,
3431
+ redis: false,
3432
+ locals: false
3433
+ }
3434
+ };
3435
+ validateTestClaim(manifest, p.claimPath);
3436
+ const handle = await open(p.claimPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
3437
+ try {
3438
+ await handle.writeFile(`${JSON.stringify(manifest, null, 2)}\n`);
3439
+ await handle.sync();
3440
+ } finally {
3441
+ await handle.close();
3442
+ }
3443
+ await syncDirectory(p.claimDirectory);
3444
+ return {
3445
+ claimPath: p.claimPath,
3446
+ manifest
3447
+ };
3448
+ }
3449
+
3450
+ // Only the live broker or a cleanup editor holding the exclusive cleanup lock may call this.
3451
+ async function writeTestClaim(claimPath, manifest) {
3452
+ validateTestClaim(manifest, claimPath);
3453
+ const previous = await loadTestClaim(claimPath);
3454
+ for (const key of ['version', 'runId', 'createdAt', 'projectPath', 'kind', 'owner']) {
3455
+ if (!isDeepStrictEqual(previous[key], manifest[key])) throw new Error('Test claim identity is immutable');
3456
+ }
3457
+ const temp = `${claimPath}.${randomBytes(12).toString('hex')}.tmp`;
3458
+ const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
3459
+ try {
3460
+ try {
3461
+ await handle.writeFile(`${JSON.stringify(manifest, null, 2)}\n`);
3462
+ await handle.sync();
3463
+ } finally {
3464
+ await handle.close();
3465
+ }
3466
+ assertPrivateFile(claimPath);
3467
+ await rename(temp, claimPath);
3468
+ await syncDirectory(path.dirname(claimPath));
3469
+ } finally {
3470
+ await unlink(temp).catch(error => {
3471
+ if (error.code !== 'ENOENT') throw error;
3472
+ });
3473
+ }
3474
+ }
3475
+ function applyEvent(m, event) {
3476
+ record(event);
3477
+ switch (event.type) {
3478
+ case 'local':
3479
+ {
3480
+ keys(event, ['type', 'kind', 'stage']);
3481
+ const local = m.locals.find(item => item.kind === event.kind);
3482
+ if (!local) throw new Error('Unplanned test claim local root');
3483
+ const stages = ['intent', 'created', 'owned', 'released'];
3484
+ if (stages.indexOf(event.stage) < stages.indexOf(local.stage)) throw new Error('Test claim local stage regression');
3485
+ local.stage = event.stage;
3486
+ break;
3487
+ }
3488
+ case 'status':
3489
+ keys(event, ['type', 'status']);
3490
+ m.status = event.status;
3491
+ break;
3492
+ case 'process':
3493
+ {
3494
+ keys(event, ['type', 'process']);
3495
+ const old = m.processes.find(item => item.id === event.process.id);
3496
+ if (!old) {
3497
+ if (event.process.stage !== 'pending') throw new Error('Process needs a write-ahead intent');
3498
+ m.processes.push(event.process);
3499
+ } else {
3500
+ if (old.role !== event.process.role) throw new Error('Test claim process identity changed');
3501
+ if (event.process.profilePath !== undefined && old.profilePath !== event.process.profilePath) {
3502
+ if (old.stage !== 'pending' || event.process.stage !== 'registered' || !old.profilePath || !event.process.profilePath.startsWith(`${old.profilePath}${path.sep}`)) {
3503
+ throw new Error('Test claim process profile changed');
3504
+ }
3505
+ }
3506
+ if (old.stage === 'drained' && event.process.stage !== 'drained' || old.stage === 'registered' && event.process.stage === 'pending') {
3507
+ throw new Error('Test claim process stage regression');
3508
+ }
3509
+ if (old.stage === 'pending' && event.process.stage === 'drained') throw new Error('Pending process cannot establish drainage');
3510
+ for (const key of ['pid', 'pgid', 'startTime', 'host']) {
3511
+ if (old[key] !== undefined && event.process[key] !== undefined && old[key] !== event.process[key]) {
3512
+ throw new Error('Test claim process identity changed');
3513
+ }
3514
+ }
3515
+ Object.assign(old, event.process);
3516
+ }
3517
+ break;
3518
+ }
3519
+ case 'inventory':
3520
+ {
3521
+ keys(event, ['type', 'databases', 'redis']);
3522
+ for (const plan of event.databases) {
3523
+ keys(plan, ['clientName', 'scope', 'prefix', 'endpoint']);
3524
+ const old = m.databases.find(item => item.clientName === plan.clientName && item.scope === plan.scope);
3525
+ if (old) {
3526
+ const {
3527
+ creations: _,
3528
+ ...oldPlan
3529
+ } = old;
3530
+ if (!isDeepStrictEqual(oldPlan, plan)) throw new Error('Database inventory drift');
3531
+ } else {
3532
+ m.databases.push({
3533
+ ...plan,
3534
+ creations: []
3535
+ });
3536
+ }
3537
+ }
3538
+ for (const resource of event.redis) {
3539
+ const old = m.redis.find(item => item.clientName === resource.clientName);
3540
+ if (old && !isDeepStrictEqual(old, resource)) throw new Error('Redis inventory drift');
3541
+ if (!old) m.redis.push(resource);
3542
+ }
3543
+ break;
3544
+ }
3545
+ case 'database':
3546
+ {
3547
+ keys(event, ['type', 'database', 'name', 'stage']);
3548
+ keys(event.database, ['clientName', 'scope', 'prefix', 'endpoint']);
3549
+ const db = m.databases.find(item => item.clientName === event.database.clientName && item.scope === event.database.scope);
3550
+ if (!db) throw new Error('Unplanned database creation');
3551
+ const {
3552
+ creations: _,
3553
+ ...plan
3554
+ } = db;
3555
+ if (!isDeepStrictEqual(plan, event.database)) throw new Error('Database creation endpoint drift');
3556
+ const old = db.creations.find(item => item.name === event.name);
3557
+ if (!old) {
3558
+ if (event.stage !== 'intent') throw new Error('Database needs a write-ahead intent');
3559
+ db.creations.push({
3560
+ name: event.name,
3561
+ stage: event.stage
3562
+ });
3563
+ } else {
3564
+ if (['intent', 'created', 'released'].indexOf(event.stage) < ['intent', 'created', 'released'].indexOf(old.stage)) {
3565
+ throw new Error('Database creation stage regression');
3566
+ }
3567
+ old.stage = event.stage;
3568
+ }
3569
+ break;
3570
+ }
3571
+ case 'cleanup':
3572
+ keys(event, ['type', 'resource', 'complete']);
3573
+ if (!(event.resource in m.cleanup)) throw new Error('Unknown cleanup resource');
3574
+ m.cleanup[event.resource] = event.complete;
3575
+ break;
3576
+ default:
3577
+ throw new Error('Unknown test claim event');
3578
+ }
3579
+ validateTestClaim(m);
3580
+ }
3581
+ async function startTestClaimBroker(claimPath) {
3582
+ await loadTestClaim(claimPath);
3583
+ const token = randomBytes(32).toString('hex');
3584
+ let queue = Promise.resolve();
3585
+ let closing = false;
3586
+ const update = event => {
3587
+ if (closing) return Promise.reject(new Error('Test claim broker is closed'));
3588
+ const result = queue.then(async () => {
3589
+ const manifest = await loadTestClaim(claimPath);
3590
+ applyEvent(manifest, event);
3591
+ await writeTestClaim(claimPath, manifest);
3592
+ });
3593
+ queue = result.catch(() => {});
3594
+ return result;
3595
+ };
3596
+ const server = createServer(async (req, res) => {
3597
+ const auth = req.headers.authorization;
3598
+ const expected = `Bearer ${token}`;
3599
+ if (req.method !== 'POST' || req.url !== '/event' || typeof auth !== 'string' || Buffer.byteLength(auth) !== Buffer.byteLength(expected) || !timingSafeEqual(Buffer.from(auth), Buffer.from(expected))) {
3600
+ res.writeHead(403).end();
3601
+ req.resume();
3602
+ return;
3603
+ }
3604
+ try {
3605
+ let body = '';
3606
+ for await (const chunk of req) {
3607
+ body += chunk.toString('utf8');
3608
+ if (Buffer.byteLength(body) > 1024 * 1024) throw new Error('Test claim event too large');
3609
+ }
3610
+ await update(parseTestClaimJson(body));
3611
+ res.writeHead(204).end();
3612
+ } catch {
3613
+ res.writeHead(409).end('Test claim registration rejected');
3614
+ }
3615
+ });
3616
+ server.requestTimeout = 10000;
3617
+ server.headersTimeout = 10000;
3618
+ await new Promise((resolve, reject) => {
3619
+ server.once('error', reject);
3620
+ server.listen(0, '127.0.0.1', resolve);
3621
+ });
3622
+ const address = server.address();
3623
+ if (!address || typeof address === 'string') throw new Error('Cannot bind test claim broker');
3624
+ return {
3625
+ env: {
3626
+ [TEST_CLAIM_URL_ENV]: `http://127.0.0.1:${address.port}/event`,
3627
+ [TEST_CLAIM_TOKEN_ENV]: token
3628
+ },
3629
+ update,
3630
+ async close() {
3631
+ closing = true;
3632
+ await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
3633
+ await queue;
3634
+ }
3635
+ };
3636
+ }
3637
+ function hasTestClaimTransport(env = process.env) {
3638
+ const url = env[TEST_CLAIM_URL_ENV];
3639
+ const token = env[TEST_CLAIM_TOKEN_ENV];
3640
+ if (url === undefined && token === undefined) return false;
3641
+ if (!url || !token) throw new Error('Incomplete managed test claim transport');
3642
+ if (!resolveTestRunId(env.META_MODE, env.VONA_TEST_RUN_ID)) throw new Error('Managed test claim transport requires test identity');
3643
+ let parsed;
3644
+ try {
3645
+ parsed = new URL(url);
3646
+ } catch {
3647
+ throw new Error('Invalid managed test claim transport');
3648
+ }
3649
+ if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' || !parsed.port || parsed.pathname !== '/event' || parsed.username || parsed.password || parsed.search || parsed.hash || !/^[a-f0-9]{64}$/.test(token)) {
3650
+ throw new Error('Invalid managed test claim transport');
3651
+ }
3652
+ return true;
3653
+ }
3654
+ async function sendTestClaimEvent(env, event) {
3655
+ if (!hasTestClaimTransport(env)) return;
3656
+ try {
3657
+ const response = await fetch(env[TEST_CLAIM_URL_ENV], {
3658
+ method: 'POST',
3659
+ headers: {
3660
+ 'authorization': `Bearer ${env[TEST_CLAIM_TOKEN_ENV]}`,
3661
+ 'content-type': 'application/json'
3662
+ },
3663
+ body: JSON.stringify(event),
3664
+ signal: AbortSignal.timeout(10000),
3665
+ redirect: 'error'
3666
+ });
3667
+ if (response.status !== 204) throw new Error('Registration rejected');
3668
+ } catch {
3669
+ throw new Error('Managed test allocation registration failed');
3670
+ }
3671
+ }
3672
+ function getTestClaimRedisPrefix(runId, clientName) {
3673
+ const appName = getTestAppName(runId);
3674
+ if (clientName === 'broadcast') return undefined;
3675
+ if (clientName === 'queue') return {
3676
+ kind: 'queue',
3677
+ prefix: `bull_${appName}_local:queue:`
3678
+ };
3679
+ if (clientName.startsWith('redlock')) return {
3680
+ kind: 'redlock',
3681
+ prefix: `redlock_${appName}_local:`
3682
+ };
3683
+ return {
3684
+ kind: 'client',
3685
+ prefix: `${clientName}_${appName}_local:`
3686
+ };
3687
+ }
3688
+ function assertNoTlsTargetOverrides(tls) {
3689
+ if (!tls) return;
3690
+ if (typeof tls === 'string') throw new Error('Unsupported managed TLS profile');
3691
+ if (typeof tls === 'object') {
3692
+ for (const key of ['host', 'port', 'path', 'socketPath', 'socket', 'stream', 'lookup', 'createConnection', 'servername']) {
3693
+ if (tls[key] !== undefined) throw new Error('Unsupported managed TLS target override');
3694
+ }
3695
+ }
3696
+ }
3697
+ // Only explicit connection fields are projected. Credentials, URLs and option snapshots never enter the journal.
3698
+ function normalizeTestClaimDatabaseEndpoint(client, projectPath, runId) {
3699
+ const dialect = client.client;
3700
+ record(client.connection);
3701
+ const c = client.connection;
3702
+ assertNoTlsTargetOverrides(c.ssl);
3703
+ if (c.connectionString !== undefined || c.uri !== undefined || client.connectionPool !== undefined || c.stream !== undefined || c.lookup !== undefined || client.driver !== undefined || client.dialect !== undefined || c.beforeConnect !== undefined) {
3704
+ throw new Error('Unsupported managed database target');
3705
+ }
3706
+ let endpoint;
3707
+ if (dialect === 'better-sqlite3') {
3708
+ endpoint = {
3709
+ dialect,
3710
+ transport: 'file',
3711
+ socketPath: getTestClaimPaths(projectPath, runId).sqliteRoot,
3712
+ tls: false
3713
+ };
3714
+ } else {
3715
+ if (!['pg', 'mysql', 'mysql2'].includes(dialect)) throw new Error('Unsupported managed database dialect');
3716
+ const socket = c.socketPath ?? (dialect === 'pg' && typeof c.host === 'string' && c.host.startsWith('/') ? c.host : undefined);
3717
+ const adminDatabase = dialect === 'pg' ? 'postgres' : 'mysql';
3718
+ if (socket !== undefined) {
3719
+ endpoint = {
3720
+ dialect,
3721
+ transport: 'socket',
3722
+ socketPath: socket,
3723
+ ...(dialect === 'pg' ? {
3724
+ port: Number(c.port)
3725
+ } : {}),
3726
+ tls: Boolean(c.ssl),
3727
+ adminDatabase
3728
+ };
3729
+ } else {
3730
+ // pg otherwise consults mutable PGHOST/PGPORT environment defaults: fail closed.
3731
+ if (dialect === 'pg' && (c.host === undefined || c.port === undefined)) throw new Error('Managed PostgreSQL requires explicit host and port');
3732
+ endpoint = {
3733
+ dialect,
3734
+ transport: 'tcp',
3735
+ host: c.host ?? 'localhost',
3736
+ port: Number(c.port ?? 3306),
3737
+ tls: Boolean(c.ssl),
3738
+ adminDatabase
3739
+ };
3740
+ }
3741
+ }
3742
+ validateEndpoint(endpoint, false);
3743
+ return endpoint;
3744
+ }
3745
+ function mergeClaimOptions(...sources) {
3746
+ const result = {};
3747
+ for (const source of sources) {
3748
+ for (const [key, value] of Object.entries(source ?? {})) {
3749
+ if (['__proto__', 'constructor', 'prototype'].includes(key)) throw new Error('Unsupported managed option field');
3750
+ if (value === undefined) continue;
3751
+ if (value && Object.getPrototypeOf(value) === Object.prototype) {
3752
+ const previous = result[key];
3753
+ result[key] = mergeClaimOptions(previous && Object.getPrototypeOf(previous) === Object.prototype ? previous : {}, value);
3754
+ } else {
3755
+ result[key] = value;
3756
+ }
3757
+ }
3758
+ }
3759
+ return result;
3760
+ }
3761
+ function mergeTestClaimDatabaseOptions(base, dialect, client) {
3762
+ return mergeClaimOptions(base, dialect ?? {}, client);
3763
+ }
3764
+ function mergeTestClaimRedisOptions(base, client) {
3765
+ // Match extend(true, ...) for connection/TLS objects and undefined values without importing runtime code.
3766
+ return mergeClaimOptions(base, client);
3767
+ }
3768
+ function normalizeTestClaimRedisEndpoint(options) {
3769
+ assertNoTlsTargetOverrides(options.tls);
3770
+ if (options.sentinels || options.nodes || options.url || options.connectionString || options.Connector !== undefined || options.connector !== undefined || options.stream !== undefined || options.dnsLookup !== undefined || options.natMap !== undefined) {
3771
+ throw new Error('Unsupported managed Redis target');
3772
+ }
3773
+ const endpoint = options.path !== undefined ? {
3774
+ transport: 'socket',
3775
+ socketPath: options.path,
3776
+ tls: Boolean(options.tls),
3777
+ db: Number(options.db ?? 0)
3778
+ } : {
3779
+ transport: 'tcp',
3780
+ host: options.host ?? 'localhost',
3781
+ port: Number(options.port ?? 6379),
3782
+ tls: Boolean(options.tls),
3783
+ db: Number(options.db ?? 0)
3784
+ };
3785
+ validateEndpoint(endpoint, true);
3786
+ return endpoint;
3787
+ }
3788
+ function buildTestClaimResourceInventory(app) {
3789
+ const id = resolveTestRunId(app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID);
3790
+ if (!id || app.name !== getTestAppName(id)) throw new Error('Managed resource inventory requires test identity');
3791
+ const database = app.config.database;
3792
+ const scopes = [['share', database.defaultClient]];
3793
+ for (const [name, instance] of Object.entries(app.config.instance.instances)) {
3794
+ const item = instance;
3795
+ if (item && item.isolate) scopes.push([`isolate-${name}`, item.isolateClient]);
3796
+ }
3797
+ const databases = scopes.map(([scope, clientName]) => {
3798
+ const client = database.clients[clientName];
3799
+ if (!client) throw new Error('Managed database client is missing');
3800
+ const options = mergeTestClaimDatabaseOptions(database.base, undefined, client);
3801
+ return {
3802
+ clientName,
3803
+ scope,
3804
+ prefix: getDatabaseTestPrefix(app.name, scope),
3805
+ endpoint: normalizeTestClaimDatabaseEndpoint(options, app.projectPath, id)
3806
+ };
3807
+ });
3808
+ const redis = [];
3809
+ for (const [clientName, client] of Object.entries(app.config.redis.clients)) {
3810
+ const prefix = getTestClaimRedisPrefix(id, clientName);
3811
+ if (!prefix) continue;
3812
+ const options = mergeTestClaimRedisOptions(app.config.redis.base, client);
3813
+ redis.push({
3814
+ clientName,
3815
+ ...prefix,
3816
+ endpoint: normalizeTestClaimRedisEndpoint(options),
3817
+ released: false
3818
+ });
3819
+ }
3820
+ return {
3821
+ databases,
3822
+ redis
3823
+ };
3824
+ }
3825
+ const appInventories = new WeakMap();
3826
+ const appDatabaseIntents = new WeakMap();
3827
+ async function registerTestClaimResourceInventory(app) {
3828
+ if (!hasTestClaimTransport()) return;
3829
+ const inventory = buildTestClaimResourceInventory(app);
3830
+ await sendTestClaimEvent(process.env, {
3831
+ type: 'inventory',
3832
+ ...inventory
3833
+ });
3834
+ appInventories.set(app, inventory);
3835
+ appDatabaseIntents.set(app, new Set());
3836
+ }
3837
+ function assertTestClaimDatabaseClient(app, clientName, config) {
3838
+ if (!hasTestClaimTransport()) return;
3839
+ const inventory = appInventories.get(app);
3840
+ const id = resolveTestRunId(app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID);
3841
+ const endpoint = normalizeTestClaimDatabaseEndpoint(config, app.projectPath, id);
3842
+ const matches = inventory?.databases.filter(item => isDeepStrictEqual(item.endpoint, endpoint));
3843
+ if (!matches?.length) throw new Error('Unregistered managed database endpoint');
3844
+ const target = config.connection[endpoint.dialect === 'better-sqlite3' ? 'filename' : 'database'];
3845
+ const admin = endpoint.dialect === 'better-sqlite3' ? ['', ':memory:'].includes(target) : target === endpoint.adminDatabase;
3846
+ if (admin) {
3847
+ if (!matches.some(item => item.clientName === clientName)) throw new Error('Unregistered managed database endpoint');
3848
+ } else if (!matches.some(item => appDatabaseIntents.get(app)?.has(`${item.clientName}\0${target}`))) {
3849
+ throw new Error('Unregistered managed database allocation');
3850
+ }
3851
+ }
3852
+ async function registerTestClaimDatabaseCreation(app, config, databaseName, stage) {
3853
+ if (!hasTestClaimTransport()) return;
3854
+ const id = resolveTestRunId(app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID);
3855
+ const endpoint = normalizeTestClaimDatabaseEndpoint(config, app.projectPath, id);
3856
+ const plan = appInventories.get(app)?.databases.find(item => {
3857
+ if (!isDeepStrictEqual(item.endpoint, endpoint)) return false;
3858
+ try {
3859
+ assertDisposableDatabaseName(databaseName, item.prefix);
3860
+ return true;
3861
+ } catch {
3862
+ return false;
3863
+ }
3864
+ });
3865
+ if (!plan) throw new Error('Unregistered managed database creation endpoint');
3866
+ const name = endpoint.dialect === 'better-sqlite3' ? path.join(getTestClaimPaths(app.projectPath, id).sqliteRoot, `${databaseName}.db`) : databaseName;
3867
+ await sendTestClaimEvent(process.env, {
3868
+ type: 'database',
3869
+ database: plan,
3870
+ name,
3871
+ stage
3872
+ });
3873
+ appDatabaseIntents.get(app).add(`${plan.clientName}\0${name}`);
3874
+ }
3875
+ function assertTestClaimRedisClient(app, clientName, options) {
3876
+ if (!hasTestClaimTransport()) return;
3877
+ // Broadcast carries subscriptions only, but may not drift to an unregistered endpoint either.
3878
+ const inventory = appInventories.get(app);
3879
+ const endpoint = normalizeTestClaimRedisEndpoint(options);
3880
+ const plan = inventory?.redis.find(item => item.clientName === clientName);
3881
+ if (clientName === 'broadcast') {
3882
+ const current = normalizeTestClaimRedisEndpoint(mergeTestClaimRedisOptions(app.config.redis.base, app.config.redis.clients.broadcast));
3883
+ if (!inventory || !isDeepStrictEqual(current, endpoint)) throw new Error('Unregistered managed Redis broadcast endpoint');
3884
+ return;
3885
+ }
3886
+ if (!plan || !isDeepStrictEqual(plan.endpoint, endpoint)) throw new Error('Unregistered managed Redis endpoint');
3887
+ if (plan.kind === 'client' ? options.keyPrefix !== plan.prefix : options.keyPrefix !== undefined) throw new Error('Unregistered managed Redis key prefix');
3888
+ }
3889
+
2894
3890
  function jsxEnhance() {
2895
3891
  const createElementOriginal = React.createElement;
2896
3892
  React.createElement = function (...args) {
@@ -3154,6 +4150,8 @@ class Start {
3154
4150
  // config
3155
4151
  const appConfig = await __prepareConfig(app);
3156
4152
  this.app.config = appConfig;
4153
+ assertIsolatedTestConfig(app);
4154
+ await registerTestClaimResourceInventory(app);
3157
4155
  this.app.keys = appConfig.server.keys;
3158
4156
  this.app.proxy = appConfig.proxy.enable;
3159
4157
  this.app.subdomainOffset = appConfig.server.subdomainOffset;
@@ -3237,6 +4235,7 @@ function __createApp({
3237
4235
  jsxEnhance();
3238
4236
  // env
3239
4237
  const env2 = prepareEnv(env);
4238
+ assertTestAppIdentity(env2.META_MODE, env2.VONA_TEST_RUN_ID, env2.APP_NAME ?? '');
3240
4239
  // appInfo
3241
4240
  const appInfo = prepareAppInfo(env2);
3242
4241
  // options
@@ -3486,5 +4485,5 @@ async function retry(options, fn) {
3486
4485
 
3487
4486
  zodExtendOpenApi();
3488
4487
 
3489
- export { $Class, $localeScope, $makeLocaleMagic, $protocolKey, AppHmr, AppHmrDeps, AppLocale, AppLogger, AppMeta, AppMetadata, AppResource, AppUtil, BeanAopBase, BeanAopMethodBase, BeanBase, BeanBaseSimple, BeanContainer, BeanInfo, BeanScopeBase, BeanScopeContainer, BeanScopeError, BeanScopeErrorImpl, BeanScopeLocale, BeanScopeScene, BeanScopeUtil, BeanSimple, EnumAppEvent, ErrorClass, Global, LocaleModuleNameSeparator, PickClassInner, ProxyDisable, SymbolBeanContainerInstances, SymbolBeanFullName, SymbolBeanInstanceKey, SymbolBeanInstancePropsLazy, SymbolCacheAopChains, SymbolCacheAopChainsKey, SymbolDecoratorBeanFullName, SymbolDecoratorBeanInfo, SymbolDecoratorGlobal, SymbolDecoratorProxyDisable, SymbolDecoratorUse, SymbolDecoratorVirtual, SymbolHmrStateLoad, SymbolHmrStateSave, SymbolMappedClassMetadataKeys, SymbolModuleBelong, SymbolModuleName, Use, Virtual, VonaApplication, __prepareInjectSelectorInfo, appHmrDeps, appMetadata, appResource, beanFullNameFromOnionName, bootstrap, cast, closeApp, combineConfigDefault, combineFilePathSafe, compose, copyAddon, copyMetadataOfClasses, copyProperties, copyPropertiesOfClasses, createApp, createAppMaster, createBeanDecorator, createGeneralApp, createHash, deepExtend, disposeInstance, errorsInternal, filterHeaders, formatLoggerAxiosError, formatLoggerConsole, formatLoggerCtx, formatLoggerDummy, formatLoggerErrors, formatLoggerFilter, functionNoop, getHomeVonaAppDir, getLoggerPathPhysicalRoot, getMappedClassMetadataKeys, getPlatformArch, getPublicPathPhysicalRoot, getRuntimePathPhysicalRoot, getSqlite3DatabaseNameDefault, instanceDesp, isLocaleMagic, loadJSONFile, onionNameFromBeanFullName, pathToHref, polyfillDispose, prepareEnv, registerMappedClassMetadataKey, requireAddon, requireDynamic, resolveAddon, resolverSharp, resolverSharpLibvips, resolverSqlite3, retry, saveJSONFile, setMappedClassMetadataKeys, text, useApp, usePrepareArg, usePrepareArgs, uuidv4 };
4488
+ export { $Class, $localeScope, $makeLocaleMagic, $protocolKey, AppHmr, AppHmrDeps, AppLocale, AppLogger, AppMeta, AppMetadata, AppResource, AppUtil, BeanAopBase, BeanAopMethodBase, BeanBase, BeanBaseSimple, BeanContainer, BeanInfo, BeanScopeBase, BeanScopeContainer, BeanScopeError, BeanScopeErrorImpl, BeanScopeLocale, BeanScopeScene, BeanScopeUtil, BeanSimple, EnumAppEvent, ErrorClass, Global, LocaleModuleNameSeparator, PickClassInner, ProxyDisable, SymbolBeanContainerInstances, SymbolBeanFullName, SymbolBeanInstanceKey, SymbolBeanInstancePropsLazy, SymbolCacheAopChains, SymbolCacheAopChainsKey, SymbolDecoratorBeanFullName, SymbolDecoratorBeanInfo, SymbolDecoratorGlobal, SymbolDecoratorProxyDisable, SymbolDecoratorUse, SymbolDecoratorVirtual, SymbolHmrStateLoad, SymbolHmrStateSave, SymbolMappedClassMetadataKeys, SymbolModuleBelong, SymbolModuleName, TEST_CLAIM_DIRECTORY, TEST_CLAIM_TOKEN_ENV, TEST_CLAIM_URL_ENV, TEST_RUN_ID_ENV, TEST_RUN_OWNER_FILE, Use, Virtual, VonaApplication, __prepareInjectSelectorInfo, appHmrDeps, appMetadata, appResource, assertDisposableDatabaseName, assertIsolatedTestConfig, assertIsolatedTestDatabaseClient, assertIsolatedTestPath, assertTestAppIdentity, assertTestClaimDatabaseClient, assertTestClaimRedisClient, assertTestClaimSafePath, assertTestRunOwner, beanFullNameFromOnionName, bootstrap, buildTestClaimResourceInventory, cast, closeApp, combineConfigDefault, combineFilePathSafe, compose, copyAddon, copyMetadataOfClasses, copyProperties, copyPropertiesOfClasses, createApp, createAppMaster, createBeanDecorator, createGeneralApp, createHash, createTestClaim, deepExtend, disposeInstance, errorsInternal, filterHeaders, formatLoggerAxiosError, formatLoggerConsole, formatLoggerCtx, formatLoggerDummy, formatLoggerErrors, formatLoggerFilter, functionNoop, getDatabaseTestPrefix, getHomeVonaAppDir, getLoggerPathPhysicalRoot, getMappedClassMetadataKeys, getPlatformArch, getPublicPathPhysicalRoot, getRuntimePathPhysicalRoot, getSqlite3DatabaseNameDefault, getTestAppName, getTestClaimCreatedAt, getTestClaimPaths, getTestClaimRedisPrefix, getTestRunIdFromEnv, hasTestClaimTransport, instanceDesp, isLocaleMagic, loadJSONFile, loadTestClaim, mergeTestClaimDatabaseOptions, mergeTestClaimRedisOptions, normalizeTestClaimDatabaseEndpoint, normalizeTestClaimRedisEndpoint, onionNameFromBeanFullName, pathToHref, polyfillDispose, prepareEnv, registerMappedClassMetadataKey, registerTestClaimDatabaseCreation, registerTestClaimResourceInventory, requireAddon, requireDynamic, resolveAddon, resolveTestRunId, resolveVonaLocalPaths, resolverSharp, resolverSharpLibvips, resolverSqlite3, retry, saveJSONFile, sendTestClaimEvent, setMappedClassMetadataKeys, startTestClaimBroker, text$1 as text, useApp, usePrepareArg, usePrepareArgs, uuidv4, validateTestClaim, withIsolatedTestDatabaseDiscovery, writeTestClaim };
3490
4489
  //# sourceMappingURL=index.js.map