cabloy 5.1.203 → 5.1.205

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 (127) hide show
  1. package/.cabloy-version +1 -1
  2. package/.gitignore +2 -0
  3. package/CHANGELOG.md +21 -0
  4. package/lint-staged.config.mjs +2 -0
  5. package/oxfmt.config.ts +2 -0
  6. package/oxlint.config.ts +2 -0
  7. package/package.json +6 -5
  8. package/repo-agent-governance/managed-assets.json +9 -9
  9. package/repo-agent-governance/policies/repository.md +5 -2
  10. package/repo-agent-governance/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +2 -2
  11. package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +2 -0
  12. package/repo-docs/backend/migration-and-changes.md +13 -12
  13. package/repo-docs/backend/orm-select-guide.md +6 -0
  14. package/repo-docs/backend/runtime-and-flavors.md +2 -1
  15. package/repo-docs/backend/transaction-guide.md +12 -0
  16. package/repo-docs/backend/unit-testing.md +12 -8
  17. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/cover-en-v1.png +0 -0
  18. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/completion-summary.jpg +0 -0
  19. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/multitasking-draft.jpg +0 -0
  20. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/ssr-refresh.jpg +0 -0
  21. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/index.md +216 -0
  22. package/repo-docs/blogs/index.md +15 -0
  23. package/repo-docs/frontend/scripts.md +1 -1
  24. package/repo-docs/fullstack/parallel-worktree-environment.md +6 -6
  25. package/repo-docs/fullstack/ssr-entry-modes.md +19 -0
  26. package/repo-docs/index.md +2 -0
  27. package/repo-docs/reference/repo-scripts.md +22 -1
  28. package/repo-e2e/config/playwright.config.ts +32 -14
  29. package/repo-e2e/docs/specs/blogs-index.spec.ts +23 -6
  30. package/repo-e2e/scripts/e2e.test.ts +39 -0
  31. package/repo-e2e/scripts/e2e.ts +19 -1
  32. package/repo-e2e/scripts/managedTest.test.ts +389 -0
  33. package/repo-e2e/scripts/managedTest.ts +210 -0
  34. package/repo-e2e/scripts/managedTestImports.test.ts +74 -0
  35. package/repo-e2e/scripts/managedTestImports.ts +110 -0
  36. package/repo-e2e/scripts/runE2e.ts +12 -1
  37. package/repo-e2e/scripts/startE2eVona.ts +37 -5
  38. package/repo-e2e/specs/a-commerce.spec.ts +1 -1
  39. package/repo-e2e/specs/cabloy-basic.spec.ts +1 -1
  40. package/repo-e2e/specs/controller-boundary.spec.ts +1 -1
  41. package/repo-e2e/specs/home-user-account.spec.ts +2 -1
  42. package/repo-e2e/specs/markdown.spec.ts +1 -1
  43. package/repo-e2e/specs/routed-dialog.spec.ts +1 -1
  44. package/scripts/runIsolatedTest.test.ts +426 -0
  45. package/scripts/runIsolatedTest.ts +454 -0
  46. package/scripts/testClaimCleanup.test.ts +346 -0
  47. package/scripts/testClaimCleanup.ts +375 -0
  48. package/scripts/testClaimProcesses.test.ts +106 -0
  49. package/scripts/testClaimProcesses.ts +129 -0
  50. package/scripts/testProcessGuardian.ts +163 -0
  51. package/scripts/upgrade.ts +1 -16
  52. package/vona/oxfmt.config.ts +2 -0
  53. package/vona/oxlint.config.ts +2 -0
  54. package/vona/package.original.json +2 -2
  55. package/vona/packages-cli/cli/package.json +1 -1
  56. package/vona/packages-cli/cli/src/play.test.ts +37 -0
  57. package/vona/packages-cli/cli/src/play.ts +23 -1
  58. package/vona/packages-cli/cli-set-api/package.json +1 -1
  59. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dbReset.ts +16 -7
  60. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +13 -7
  61. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.play.ts +5 -1
  62. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.test.ts +12 -7
  63. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/configUtils.ts +21 -2
  64. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/generateVonaMeta.ts +10 -0
  65. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.test.ts +153 -0
  66. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.ts +141 -0
  67. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.test.ts +148 -0
  68. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.ts +127 -0
  69. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.test.ts +47 -0
  70. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.ts +139 -0
  71. package/vona/packages-cli/cli-set-api/src/lib/utils.ts +2 -3
  72. package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_project.json +1 -0
  73. package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_source.json +1 -0
  74. package/vona/packages-cli/cli-set-api/test/binDevMode.test.ts +18 -0
  75. package/vona/packages-cli/cli-set-api/toolsIsolate/dbReset.ts +15 -2
  76. package/vona/packages-cli/cli-set-api/tsconfig.build.json +2 -1
  77. package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
  78. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.test.ts +16 -0
  79. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.ts +3 -2
  80. package/vona/packages-vona/vona/package.json +1 -1
  81. package/vona/packages-vona/vona-core/package.json +1 -1
  82. package/vona/packages-vona/vona-core/src/lib/addon/sqlite3.ts +1 -1
  83. package/vona/packages-vona/vona-core/src/lib/core/config.ts +44 -6
  84. package/vona/packages-vona/vona-core/src/lib/core/configCleanup.test.ts +40 -0
  85. package/vona/packages-vona/vona-core/src/lib/core/index.ts +3 -0
  86. package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.test.ts +301 -0
  87. package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.ts +312 -0
  88. package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +577 -0
  89. package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +985 -0
  90. package/vona/packages-vona/vona-core/src/lib/core/testIsolation.ts +129 -0
  91. package/vona/packages-vona/vona-core/src/lib/framework/createApp.ts +2 -0
  92. package/vona/packages-vona/vona-core/src/lib/framework/start.ts +4 -0
  93. package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +5 -1
  94. package/vona/packages-vona/vona-core/src/types/application/app.ts +2 -0
  95. package/vona/packages-vona/vona-core/src/types/utils/env.ts +1 -0
  96. package/vona/packages-vona/vona-mock/package.json +1 -1
  97. package/vona/pnpm-lock.yaml +40 -40
  98. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentIndexes.test.ts +73 -10
  99. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  100. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  101. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  102. package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
  103. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +15 -1
  104. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +24 -0
  105. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +11 -0
  106. package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
  107. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +15 -0
  108. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  109. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/extend/schemaBuilder.ts +11 -4
  110. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/databaseClient_.ts +6 -1
  111. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/package.json +1 -1
  112. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.betterSqlite3.ts +41 -1
  113. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.mysql.ts +4 -1
  114. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.pg.ts +2 -1
  115. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  116. package/vona/src/suite-vendor/a-vona/modules/a-redis/package.json +1 -1
  117. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/lib/redis.ts +8 -0
  118. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redis.ts +23 -6
  119. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redisClient_.ts +18 -1
  120. package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
  121. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +43 -15
  122. package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
  123. package/vona/src/suite-vendor/a-vona/modules/a-version/src/lib/databaseTestName.ts +3 -37
  124. package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +93 -15
  125. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  126. package/vona/tsconfig.json +1 -0
  127. package/scripts/initTestData.ts +0 -25
@@ -0,0 +1,301 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { test } from 'node:test';
6
+
7
+ import type { VonaApplication } from './application.ts';
8
+
9
+ import {
10
+ assertIsolatedTestConfig,
11
+ assertIsolatedTestDatabaseClient,
12
+ withIsolatedTestDatabaseDiscovery,
13
+ } from './isolationGuard.ts';
14
+ import {
15
+ getDatabaseTestPrefix,
16
+ getTestAppName,
17
+ resolveTestRunId,
18
+ resolveVonaLocalPaths,
19
+ TEST_RUN_OWNER_FILE,
20
+ } from './testIsolation.ts';
21
+
22
+ const runId = '20261014010203-a1b2c3d4';
23
+ const appName = `cabtest${runId}`;
24
+ const timestamp = '20261012-123456';
25
+
26
+ type Client = {
27
+ client: string;
28
+ connection: Record<string, unknown>;
29
+ connectionPool?: unknown;
30
+ };
31
+
32
+ function createFixture() {
33
+ const root = mkdtempSync(path.join(realpathSync(tmpdir()), 'cabloy-orm-isolation-'));
34
+ const projectPath = path.join(root, 'vona');
35
+ const paths = resolveVonaLocalPaths(projectPath, 'test', runId);
36
+ for (const ownedRoot of [paths.dataRoot, paths.generatedRoot]) {
37
+ mkdirSync(ownedRoot, { recursive: true });
38
+ writeFileSync(path.join(ownedRoot, TEST_RUN_OWNER_FILE), `${runId}\n`);
39
+ }
40
+ const client: Client = {
41
+ client: 'pg',
42
+ connection: { database: 'postgres', host: '127.0.0.1', port: 5432, user: 'isolated' },
43
+ };
44
+ const config = {
45
+ server: {
46
+ publicDir: paths.publicRoot,
47
+ workers: 1,
48
+ listen: { hostname: '127.0.0.1', port: 42178 },
49
+ serve: { protocol: 'http', host: '127.0.0.1:42178' },
50
+ },
51
+ logger: { baseDir: paths.logsRoot },
52
+ database: {
53
+ testDatabase: true,
54
+ base: {} as { connection?: unknown; connectionPool?: unknown },
55
+ defaultClient: 'pg',
56
+ clients: { pg: client } as Record<string, Client>,
57
+ },
58
+ instance: {
59
+ instances: {} as Record<string, false | { isolate: boolean; isolateClient: string }>,
60
+ },
61
+ redis: { base: {}, clients: { default: { keyPrefix: true } } },
62
+ };
63
+ const app = {
64
+ projectPath,
65
+ name: appName,
66
+ meta: {
67
+ appReady: false,
68
+ env: { META_MODE: 'test', VONA_TEST_RUN_ID: runId, SERVER_LISTEN_PORT: '42178' },
69
+ },
70
+ config,
71
+ } as unknown as VonaApplication;
72
+ return { root, paths, app, config, client };
73
+ }
74
+
75
+ function disposable(scope = 'share') {
76
+ return `${getDatabaseTestPrefix(appName, scope)}${timestamp}`;
77
+ }
78
+
79
+ test('run IDs require a real UTC second and isolate resources with the random suffix', () => {
80
+ const siblingId = '20261014010203-d4e5f6a7';
81
+ const first = resolveVonaLocalPaths('/project/vona', 'test', runId);
82
+ const second = resolveVonaLocalPaths('/project/vona', 'test', siblingId);
83
+ assert.equal(first.dataDir, '.app-test-20261014010203-a1b2c3d4');
84
+ assert.equal(first.generatedDir, '.vona-test-20261014010203-a1b2c3d4');
85
+ assert.notEqual(first.dataDir, second.dataDir);
86
+ assert.notEqual(getTestAppName(runId), getTestAppName(siblingId));
87
+ assert.notEqual(
88
+ getDatabaseTestPrefix(getTestAppName(runId), 'share'),
89
+ getDatabaseTestPrefix(getTestAppName(siblingId), 'share'),
90
+ );
91
+ for (const invalid of [
92
+ '0123456789abcdef',
93
+ '20261014010203',
94
+ '20260230010203-a1b2c3d4',
95
+ '20261014250203-a1b2c3d4',
96
+ '20261014010203-A1B2C3D4',
97
+ '20261014010203-a1b2c3',
98
+ ]) {
99
+ assert.throws(() => resolveTestRunId('test', invalid), /UTC timestamp plus eight hex digits/);
100
+ }
101
+ assert.throws(() => resolveTestRunId('dev', runId), /requires test mode/);
102
+ assert.equal(resolveTestRunId('test', runId), runId);
103
+ });
104
+
105
+ test('administrative discovery accepts only the configured client and target', async () => {
106
+ const fixture = createFixture();
107
+ try {
108
+ const { app, client } = fixture;
109
+ assertIsolatedTestConfig(app);
110
+ assert.throws(() => assertIsolatedTestDatabaseClient(app, 'pg', client), /namespace/);
111
+ await withIsolatedTestDatabaseDiscovery(app, 'pg', client, async () => {
112
+ assert.doesNotThrow(() => assertIsolatedTestDatabaseClient(app, 'pg', client));
113
+ const target = { ...client, connection: { ...client.connection, database: disposable() } };
114
+ assert.doesNotThrow(() => assertIsolatedTestDatabaseClient(app, 'pg', target));
115
+ for (const connection of [
116
+ { ...client.connection, host: 'untrusted.example' },
117
+ { ...client.connection, port: 5433 },
118
+ { ...client.connection, user: 'another-user' },
119
+ { ...client.connection, database: 'development' },
120
+ ]) {
121
+ assert.throws(
122
+ () => assertIsolatedTestDatabaseClient(app, 'pg', { ...client, connection }),
123
+ /namespace/,
124
+ );
125
+ }
126
+ assert.throws(() => assertIsolatedTestDatabaseClient(app, 'dynamic_1', client), /namespace/);
127
+ assert.throws(
128
+ () =>
129
+ assertIsolatedTestDatabaseClient(app, 'pg', {
130
+ ...client,
131
+ connection: {
132
+ ...client.connection,
133
+ connectionString: 'postgres://untrusted.example/dev',
134
+ },
135
+ }),
136
+ /connection URL/,
137
+ );
138
+ assert.throws(
139
+ () =>
140
+ assertIsolatedTestDatabaseClient(app, 'pg', {
141
+ ...client,
142
+ connectionPool: {},
143
+ }),
144
+ /external pool/,
145
+ );
146
+ assert.throws(
147
+ () =>
148
+ assertIsolatedTestDatabaseClient(app, 'pg', {
149
+ ...client,
150
+ connection: () => ({ database: 'postgres' }),
151
+ }),
152
+ /explicit connection target/,
153
+ );
154
+ });
155
+ assert.throws(() => assertIsolatedTestDatabaseClient(app, 'pg', client), /namespace/);
156
+ assert.throws(
157
+ () =>
158
+ assertIsolatedTestDatabaseClient(app, 'pg', {
159
+ ...client,
160
+ connection: { database: 'vona-test-unowned-s-20261012-123456' },
161
+ }),
162
+ /namespace/,
163
+ );
164
+ assert.doesNotThrow(() =>
165
+ assertIsolatedTestDatabaseClient(app, 'dynamic_1', {
166
+ client: 'pg',
167
+ connection: { database: disposable() },
168
+ }),
169
+ );
170
+ } finally {
171
+ rmSync(fixture.root, { recursive: true, force: true });
172
+ }
173
+ });
174
+
175
+ test('discovery rejects unconfigured clients and pinning changes', async () => {
176
+ const fixture = createFixture();
177
+ try {
178
+ const { app, client, config } = fixture;
179
+ config.database.clients.unused = { client: 'mysql2', connection: { database: 'mysql' } };
180
+ await assert.rejects(
181
+ withIsolatedTestDatabaseDiscovery(
182
+ app,
183
+ 'unused',
184
+ config.database.clients.unused,
185
+ async () => {},
186
+ ),
187
+ /unconfigured database client/,
188
+ );
189
+ await assert.rejects(
190
+ withIsolatedTestDatabaseDiscovery(
191
+ app,
192
+ 'pg',
193
+ {
194
+ ...client,
195
+ connection: { ...client.connection, host: 'untrusted.example' },
196
+ },
197
+ async () => {},
198
+ ),
199
+ /unconfigured database client/,
200
+ );
201
+ await withIsolatedTestDatabaseDiscovery(app, 'pg', client, async () => {
202
+ const originalConnection = client.connection;
203
+ config.database.clients.pg.connection = { ...client.connection, host: 'untrusted.example' };
204
+ assert.throws(
205
+ () =>
206
+ assertIsolatedTestDatabaseClient(app, 'pg', {
207
+ ...client,
208
+ connection: originalConnection,
209
+ }),
210
+ /namespace/,
211
+ );
212
+ config.database.clients.pg.connection = originalConnection;
213
+ });
214
+ (app.meta as { appReady: boolean }).appReady = true;
215
+ await assert.rejects(
216
+ withIsolatedTestDatabaseDiscovery(app, 'pg', client, async () => {}),
217
+ /after startup/,
218
+ );
219
+ } finally {
220
+ rmSync(fixture.root, { recursive: true, force: true });
221
+ }
222
+ });
223
+
224
+ test('unused configured clients, base overrides, and URL targets fail before cleanup', () => {
225
+ const fixture = createFixture();
226
+ try {
227
+ const { app, config } = fixture;
228
+ for (const connection of [
229
+ 'postgres://untrusted.example/dev',
230
+ () => ({ database: 'postgres' }),
231
+ { connectionString: 'postgres://untrusted.example/dev' },
232
+ ]) {
233
+ config.database.base.connection = connection;
234
+ assert.throws(() => assertIsolatedTestConfig(app), /base connection target or pool/);
235
+ }
236
+ delete config.database.base.connection;
237
+ config.database.base.connectionPool = {};
238
+ assert.throws(() => assertIsolatedTestConfig(app), /base connection target or pool/);
239
+ delete config.database.base.connectionPool;
240
+ config.database.clients.unused = { client: 'pg', connection: { database: 'postgres' } };
241
+ assert.doesNotThrow(() => assertIsolatedTestConfig(app));
242
+ for (const connection of [
243
+ { database: 'postgres', connectionString: 'postgres://untrusted.example/dev' },
244
+ { database: 'mysql', uri: 'mysql://untrusted.example/dev' },
245
+ () => ({ database: 'postgres' }),
246
+ ]) {
247
+ config.database.clients.unused.connection = connection as Record<string, unknown>;
248
+ assert.throws(() => assertIsolatedTestConfig(app), /external target or pool: unused/);
249
+ }
250
+ config.database.clients.unused.connection = { database: 'postgres' };
251
+ config.database.clients.unused.connectionPool = {};
252
+ assert.throws(() => assertIsolatedTestConfig(app), /external target or pool: unused/);
253
+ delete config.database.clients.unused.connectionPool;
254
+ assert.doesNotThrow(() => assertIsolatedTestConfig(app));
255
+ } finally {
256
+ rmSync(fixture.root, { recursive: true, force: true });
257
+ }
258
+ });
259
+
260
+ test('mysql URL overrides and SQLite escapes are rejected at construction', () => {
261
+ const fixture = createFixture();
262
+ try {
263
+ const { app, paths } = fixture;
264
+ assert.throws(
265
+ () =>
266
+ assertIsolatedTestDatabaseClient(app, 'dynamic_mysql', {
267
+ client: 'mysql2',
268
+ connection: { database: disposable(), uri: 'mysql://untrusted.example/dev' },
269
+ }),
270
+ /connection URL/,
271
+ );
272
+ const filename = path.join(paths.sqliteRoot, `${disposable()}.db`);
273
+ assert.doesNotThrow(() =>
274
+ assertIsolatedTestDatabaseClient(app, 'dynamic_sqlite', {
275
+ client: 'better-sqlite3',
276
+ connection: { filename },
277
+ }),
278
+ );
279
+ assert.throws(
280
+ () =>
281
+ assertIsolatedTestDatabaseClient(app, 'dynamic_sqlite', {
282
+ client: 'better-sqlite3',
283
+ connection: { filename: path.join(fixture.root, `${disposable()}.db`) },
284
+ }),
285
+ /path escapes/,
286
+ );
287
+ mkdirSync(paths.sqliteRoot, { recursive: true });
288
+ const linked = path.join(paths.sqliteRoot, `${disposable()}.db`);
289
+ symlinkSync(path.join(fixture.root, 'development.db'), linked);
290
+ assert.throws(
291
+ () =>
292
+ assertIsolatedTestDatabaseClient(app, 'dynamic_sqlite', {
293
+ client: 'better-sqlite3',
294
+ connection: { filename: linked },
295
+ }),
296
+ /symlink/,
297
+ );
298
+ } finally {
299
+ rmSync(fixture.root, { recursive: true, force: true });
300
+ }
301
+ });
@@ -0,0 +1,312 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { lstatSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+
6
+ import type { VonaApplication } from './application.ts';
7
+
8
+ import {
9
+ assertDisposableDatabaseName,
10
+ assertTestAppIdentity,
11
+ assertTestRunOwner,
12
+ getDatabaseTestPrefix,
13
+ resolveVonaLocalPaths,
14
+ } from './testIsolation.ts';
15
+
16
+ const databaseDiscovery = new AsyncLocalStorage<{
17
+ app: VonaApplication;
18
+ clientName: string;
19
+ client: IsolatedDatabaseClient;
20
+ }>();
21
+
22
+ type IsolatedDatabaseConnection = {
23
+ database?: string;
24
+ filename?: string;
25
+ connectionString?: string;
26
+ uri?: string;
27
+ };
28
+
29
+ type IsolatedDatabaseClient = {
30
+ client: string;
31
+ connection?: unknown;
32
+ connectionPool?: unknown;
33
+ };
34
+
35
+ export async function withIsolatedTestDatabaseDiscovery<T>(
36
+ app: VonaApplication,
37
+ clientName: string,
38
+ client: IsolatedDatabaseClient,
39
+ fn: () => Promise<T>,
40
+ ): Promise<T> {
41
+ if (!app.meta.env.VONA_TEST_RUN_ID) return fn();
42
+ if (app.meta.appReady) throw new Error('Isolated test cannot discover databases after startup');
43
+ const config = app.config as typeof app.config & {
44
+ database: { defaultClient: string; clients: Record<string, IsolatedDatabaseClient> };
45
+ instance: { instances: Record<string, false | { isolate?: boolean; isolateClient?: string }> };
46
+ };
47
+ const instanceClients = Object.values(config.instance.instances).flatMap(instance =>
48
+ instance && instance.isolate && instance.isolateClient ? [instance.isolateClient] : [],
49
+ );
50
+ if (
51
+ !client ||
52
+ (clientName !== config.database.defaultClient && !instanceClients.includes(clientName)) ||
53
+ config.database.clients[clientName]?.client !== client.client ||
54
+ !isDeepStrictEqual(client.connection, config.database.clients[clientName].connection) ||
55
+ !client.connection ||
56
+ typeof client.connection !== 'object' ||
57
+ Array.isArray(client.connection)
58
+ ) {
59
+ throw new Error(`Isolated test cannot discover an unconfigured database client: ${clientName}`);
60
+ }
61
+ return databaseDiscovery.run({ app, clientName, client }, async () => {
62
+ assertIsolatedTestDatabaseClient(app, clientName, client);
63
+ return await fn();
64
+ });
65
+ }
66
+
67
+ export function assertIsolatedTestDatabaseClient(
68
+ app: VonaApplication,
69
+ clientName: string,
70
+ client: IsolatedDatabaseClient,
71
+ ) {
72
+ const id = assertTestAppIdentity(app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID, app.name);
73
+ if (!id) return;
74
+ const paths = resolveVonaLocalPaths(app.projectPath, 'test', id);
75
+ assertTestRunOwner(paths.dataRoot, id);
76
+ if (client.connectionPool !== undefined) {
77
+ throw new Error(`Isolated test database cannot use an external pool: ${clientName}`);
78
+ }
79
+ if (
80
+ !client.connection ||
81
+ typeof client.connection !== 'object' ||
82
+ Array.isArray(client.connection)
83
+ ) {
84
+ throw new Error(`Isolated test database requires an explicit connection target: ${clientName}`);
85
+ }
86
+ const connection = client.connection as IsolatedDatabaseConnection;
87
+ if (connection.connectionString !== undefined || connection.uri !== undefined) {
88
+ throw new Error(`Isolated test database cannot use a connection URL: ${clientName}`);
89
+ }
90
+ if (!['better-sqlite3', 'pg', 'mysql', 'mysql2'].includes(client.client)) {
91
+ throw new Error(`Isolated test database client is unsupported: ${clientName}`);
92
+ }
93
+ if (
94
+ client.client === 'better-sqlite3'
95
+ ? connection.database !== undefined
96
+ : connection.filename !== undefined
97
+ ) {
98
+ throw new Error(`Isolated test database has conflicting connection targets: ${clientName}`);
99
+ }
100
+ const config = app.config as typeof app.config & {
101
+ database: { clients: Record<string, IsolatedDatabaseClient>; defaultClient: string };
102
+ instance: { instances: Record<string, false | { isolate?: boolean; isolateClient?: string }> };
103
+ };
104
+ const configured = config.database;
105
+ const discovering = databaseDiscovery.getStore();
106
+ const target = client.client === 'better-sqlite3' ? connection.filename : connection.database;
107
+ if (
108
+ discovering?.app === app &&
109
+ discovering.clientName === clientName &&
110
+ !app.meta.appReady &&
111
+ configured.clients[clientName]?.client === client.client &&
112
+ client.client === discovering.client.client &&
113
+ isDeepStrictEqual(connection, discovering.client.connection) &&
114
+ isDeepStrictEqual(connection, configured.clients[clientName].connection)
115
+ ) {
116
+ const discoveryTarget =
117
+ client.client === 'better-sqlite3' ? '' : client.client === 'pg' ? 'postgres' : 'mysql';
118
+ if (
119
+ target === discoveryTarget ||
120
+ (client.client === 'better-sqlite3' && target === ':memory:')
121
+ ) {
122
+ return;
123
+ }
124
+ }
125
+ const scopes = new Set(['share']);
126
+ for (const [instanceName, instance] of Object.entries(config.instance.instances)) {
127
+ if (instance && instance.isolate && instance.isolateClient) {
128
+ scopes.add(`isolate-${instanceName}`);
129
+ }
130
+ }
131
+ if (typeof target !== 'string') {
132
+ throw new TypeError(`Isolated test database requires a disposable target: ${clientName}`);
133
+ }
134
+ let matched = false;
135
+ for (const scope of scopes) {
136
+ const prefix = getDatabaseTestPrefix(app.name, scope);
137
+ const databaseName = client.client === 'better-sqlite3' ? path.basename(target, '.db') : target;
138
+ try {
139
+ assertDisposableDatabaseName(databaseName, prefix);
140
+ matched = true;
141
+ break;
142
+ } catch {
143
+ // Try the next owned namespace.
144
+ }
145
+ }
146
+ if (!matched) throw new Error(`Isolated test database is outside its namespace: ${clientName}`);
147
+ if (client.client === 'better-sqlite3') {
148
+ if (!path.isAbsolute(target) || path.extname(target) !== '.db') {
149
+ throw new Error(`Isolated test SQLite target must be an owned .db file: ${clientName}`);
150
+ }
151
+ assertIsolatedTestPath(target, path.join(paths.sqliteRoot, path.basename(target)));
152
+ }
153
+ }
154
+
155
+ export function assertIsolatedTestPath(actual: string, expected: string) {
156
+ if (path.resolve(actual) !== path.resolve(expected)) {
157
+ throw new Error(`Isolated test path escapes its owned root: ${actual}`);
158
+ }
159
+ let current = path.resolve(actual);
160
+ while (true) {
161
+ try {
162
+ if (lstatSync(current).isSymbolicLink()) {
163
+ throw new Error(`Isolated test path traverses a symlink: ${current}`);
164
+ }
165
+ } catch (error) {
166
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
167
+ }
168
+ const parent = path.dirname(current);
169
+ if (parent === current) break;
170
+ current = parent;
171
+ }
172
+ }
173
+
174
+ export function assertIsolatedTestConfig(app: VonaApplication) {
175
+ const env = app.meta.env;
176
+ const id = assertTestAppIdentity(env.META_MODE, env.VONA_TEST_RUN_ID, app.name);
177
+ if (!id) return;
178
+ const paths = resolveVonaLocalPaths(app.projectPath, env.META_MODE, id);
179
+ assertTestRunOwner(paths.dataRoot, id);
180
+ assertTestRunOwner(paths.generatedRoot, id);
181
+ assertIsolatedTestPath(app.config.server.publicDir, paths.publicRoot);
182
+ assertIsolatedTestPath(app.config.logger.baseDir, paths.logsRoot);
183
+ assertIsolatedTestPath(paths.runtimeRoot, paths.runtimeRoot);
184
+ assertIsolatedTestPath(paths.sqliteRoot, paths.sqliteRoot);
185
+ if (app.config.server.workers !== 1) throw new Error('Isolated test requires one Vona worker');
186
+ const config = app.config as typeof app.config & {
187
+ database: {
188
+ testDatabase: boolean;
189
+ base: {
190
+ connection?: unknown;
191
+ connectionPool?: unknown;
192
+ };
193
+ defaultClient: string | ((...args: unknown[]) => string);
194
+ clients: Record<
195
+ string,
196
+ {
197
+ client: string;
198
+ connection?: unknown;
199
+ connectionPool?: unknown;
200
+ }
201
+ >;
202
+ };
203
+ instance: { instances: Record<string, false | { isolate?: boolean; isolateClient?: string }> };
204
+ redis: {
205
+ base: { keyPrefix?: string };
206
+ clients: Record<string, { keyPrefix?: string | true; sentinels?: unknown; nodes?: unknown }>;
207
+ };
208
+ };
209
+ if (process.env.VONA_DB_RESET !== undefined) {
210
+ throw new Error('Isolated test cannot run a development database reset');
211
+ }
212
+ if (!config.database.testDatabase) {
213
+ throw new Error('Isolated test requires disposable test database configuration');
214
+ }
215
+ if (typeof config.database.defaultClient !== 'string') {
216
+ throw new TypeError('Isolated test requires a static default database client');
217
+ }
218
+ if (
219
+ config.database.base.connection !== undefined ||
220
+ config.database.base.connectionPool !== undefined
221
+ ) {
222
+ throw new Error('Isolated test cannot inherit a database base connection target or pool');
223
+ }
224
+ const clientNames = new Set<string>([config.database.defaultClient]);
225
+ const instances = config.instance.instances;
226
+ for (const instance of Object.values(instances)) {
227
+ if (!instance || !instance.isolate) continue;
228
+ if (!instance.isolateClient)
229
+ throw new Error('Isolated test instance requires an isolateClient');
230
+ clientNames.add(instance.isolateClient);
231
+ }
232
+ for (const clientName of clientNames) {
233
+ if (!config.database.clients[clientName]) {
234
+ throw new Error(`Isolated test has no configured database client: ${clientName}`);
235
+ }
236
+ }
237
+ for (const [clientName, client] of Object.entries(config.database.clients)) {
238
+ if (client.connectionPool !== undefined) {
239
+ throw new Error(
240
+ `Isolated test database client has an external target or pool: ${clientName}`,
241
+ );
242
+ }
243
+ // Optional, unused clients may intentionally remain empty in local configuration.
244
+ // Any configured connection is still validated, even when that client is not selected.
245
+ if (client.connection === undefined && !clientNames.has(clientName)) continue;
246
+ if (
247
+ typeof client.connection !== 'object' ||
248
+ client.connection === null ||
249
+ Array.isArray(client.connection) ||
250
+ (client.connection as IsolatedDatabaseConnection).connectionString !== undefined ||
251
+ (client.connection as IsolatedDatabaseConnection).uri !== undefined
252
+ ) {
253
+ throw new Error(
254
+ `Isolated test database client has an external target or pool: ${clientName}`,
255
+ );
256
+ }
257
+ if (!clientNames.has(clientName)) continue;
258
+ if (!['better-sqlite3', 'pg', 'mysql', 'mysql2'].includes(client.client)) {
259
+ throw new Error(`Isolated test has an unsupported database client: ${clientName}`);
260
+ }
261
+ const connection = client.connection as IsolatedDatabaseConnection;
262
+ const expected =
263
+ client.client === 'better-sqlite3' ? '' : client.client === 'pg' ? 'postgres' : 'mysql';
264
+ const target = client.client === 'better-sqlite3' ? connection.filename : connection.database;
265
+ if (target !== expected && !(client.client === 'better-sqlite3' && target === ':memory:')) {
266
+ throw new Error(
267
+ `Isolated test requires an administrative discovery connection: ${clientName}`,
268
+ );
269
+ }
270
+ if (
271
+ client.client === 'better-sqlite3'
272
+ ? connection.database !== undefined
273
+ : connection.filename !== undefined
274
+ ) {
275
+ throw new Error(`Isolated test discovery connection has conflicting targets: ${clientName}`);
276
+ }
277
+ }
278
+ const redis = config.redis;
279
+ if (redis.base.keyPrefix !== undefined) {
280
+ throw new Error('Isolated test cannot inherit a Redis base keyPrefix');
281
+ }
282
+ for (const [clientName, client] of Object.entries(redis.clients)) {
283
+ const special = ['queue', 'broadcast'].includes(clientName) || clientName.startsWith('redlock');
284
+ if (
285
+ (client as { sentinels?: unknown; nodes?: unknown }).sentinels ||
286
+ (client as { nodes?: unknown }).nodes
287
+ ) {
288
+ throw new Error(`Isolated test cannot use a Redis sentinel or cluster client: ${clientName}`);
289
+ }
290
+ if (special ? client.keyPrefix !== undefined : client.keyPrefix !== true) {
291
+ throw new Error(`Isolated test requires a framework-managed Redis prefix: ${clientName}`);
292
+ }
293
+ }
294
+ const expectedPort = Number(env.SERVER_LISTEN_PORT);
295
+ const requiresServerOrigin = process.env.VONA_TEST_SERVER_ORIGIN === 'true';
296
+ if (
297
+ !Number.isInteger(expectedPort) ||
298
+ expectedPort <= 0 ||
299
+ expectedPort > 65535 ||
300
+ app.config.server.listen.port !== expectedPort ||
301
+ app.config.server.listen.hostname !== '127.0.0.1' ||
302
+ (requiresServerOrigin &&
303
+ (app.config.server.serve.protocol !== 'http' ||
304
+ app.config.server.serve.host !== `127.0.0.1:${expectedPort}`))
305
+ ) {
306
+ throw new Error(
307
+ requiresServerOrigin
308
+ ? 'Isolated test listener and server origin must match the assigned loopback port'
309
+ : 'Isolated test listener must match the assigned loopback port',
310
+ );
311
+ }
312
+ }