cabloy 5.1.204 → 5.1.206

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 (117) 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 +2 -3
  8. package/repo-agent-governance/managed-assets.json +6 -6
  9. package/repo-agent-governance/policies/repository.md +3 -2
  10. package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +2 -0
  11. package/repo-docs/backend/migration-and-changes.md +13 -12
  12. package/repo-docs/backend/runtime-and-flavors.md +2 -1
  13. package/repo-docs/backend/unit-testing.md +12 -8
  14. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/index.md +9 -9
  15. package/repo-docs/frontend/scripts.md +1 -1
  16. package/repo-docs/fullstack/parallel-worktree-environment.md +6 -6
  17. package/repo-docs/fullstack/ssr-entry-modes.md +19 -0
  18. package/repo-docs/reference/repo-scripts.md +22 -1
  19. package/repo-e2e/config/playwright.config.ts +32 -14
  20. package/repo-e2e/scripts/e2e.test.ts +39 -0
  21. package/repo-e2e/scripts/e2e.ts +19 -1
  22. package/repo-e2e/scripts/managedTest.test.ts +389 -0
  23. package/repo-e2e/scripts/managedTest.ts +210 -0
  24. package/repo-e2e/scripts/managedTestImports.test.ts +74 -0
  25. package/repo-e2e/scripts/managedTestImports.ts +110 -0
  26. package/repo-e2e/scripts/runE2e.ts +12 -1
  27. package/repo-e2e/scripts/startE2eVona.ts +37 -5
  28. package/repo-e2e/specs/a-commerce.spec.ts +1 -1
  29. package/repo-e2e/specs/cabloy-basic.spec.ts +1 -1
  30. package/repo-e2e/specs/controller-boundary.spec.ts +1 -1
  31. package/repo-e2e/specs/home-user-account.spec.ts +2 -1
  32. package/repo-e2e/specs/markdown.spec.ts +1 -1
  33. package/repo-e2e/specs/routed-dialog.spec.ts +1 -1
  34. package/scripts/runIsolatedTest.test.ts +447 -0
  35. package/scripts/runIsolatedTest.ts +462 -0
  36. package/scripts/testClaimCleanup.test.ts +346 -0
  37. package/scripts/testClaimCleanup.ts +375 -0
  38. package/scripts/testClaimProcesses.test.ts +106 -0
  39. package/scripts/testClaimProcesses.ts +129 -0
  40. package/scripts/testProcessGuardian.ts +163 -0
  41. package/scripts/upgrade.ts +1 -16
  42. package/vona/oxfmt.config.ts +2 -0
  43. package/vona/oxlint.config.ts +2 -0
  44. package/vona/package.original.json +2 -2
  45. package/vona/packages-cli/cli/package.json +1 -1
  46. package/vona/packages-cli/cli/src/play.test.ts +37 -0
  47. package/vona/packages-cli/cli/src/play.ts +23 -1
  48. package/vona/packages-cli/cli-set-api/package.json +1 -1
  49. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dbReset.ts +16 -7
  50. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +13 -7
  51. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.play.ts +5 -1
  52. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.test.ts +12 -7
  53. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/configUtils.ts +21 -2
  54. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/generateVonaMeta.ts +10 -0
  55. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.test.ts +153 -0
  56. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.ts +141 -0
  57. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.test.ts +148 -0
  58. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.ts +127 -0
  59. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.test.ts +47 -0
  60. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.ts +139 -0
  61. package/vona/packages-cli/cli-set-api/src/lib/utils.ts +2 -3
  62. package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_project.json +1 -0
  63. package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_source.json +1 -0
  64. package/vona/packages-cli/cli-set-api/test/binDevMode.test.ts +18 -0
  65. package/vona/packages-cli/cli-set-api/toolsIsolate/dbReset.ts +15 -2
  66. package/vona/packages-cli/cli-set-api/tsconfig.build.json +2 -1
  67. package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
  68. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.test.ts +16 -0
  69. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.ts +3 -2
  70. package/vona/packages-vona/vona/package.json +1 -1
  71. package/vona/packages-vona/vona-core/package.json +1 -1
  72. package/vona/packages-vona/vona-core/src/lib/addon/sqlite3.ts +1 -1
  73. package/vona/packages-vona/vona-core/src/lib/core/config.ts +44 -6
  74. package/vona/packages-vona/vona-core/src/lib/core/configCleanup.test.ts +40 -0
  75. package/vona/packages-vona/vona-core/src/lib/core/index.ts +3 -0
  76. package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.test.ts +301 -0
  77. package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.ts +312 -0
  78. package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +577 -0
  79. package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +985 -0
  80. package/vona/packages-vona/vona-core/src/lib/core/testIsolation.ts +129 -0
  81. package/vona/packages-vona/vona-core/src/lib/framework/createApp.ts +2 -0
  82. package/vona/packages-vona/vona-core/src/lib/framework/start.ts +4 -0
  83. package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +5 -1
  84. package/vona/packages-vona/vona-core/src/types/application/app.ts +2 -0
  85. package/vona/packages-vona/vona-core/src/types/utils/env.ts +1 -0
  86. package/vona/packages-vona/vona-mock/package.json +1 -1
  87. package/vona/pnpm-lock.yaml +1 -1
  88. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentIndexes.test.ts +73 -10
  89. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  90. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  91. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  92. package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
  93. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +15 -1
  94. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +24 -0
  95. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +11 -0
  96. package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
  97. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +15 -0
  98. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  99. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/extend/schemaBuilder.ts +11 -4
  100. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/databaseClient_.ts +6 -1
  101. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/package.json +1 -1
  102. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.betterSqlite3.ts +41 -1
  103. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.mysql.ts +4 -1
  104. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.pg.ts +2 -1
  105. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  106. package/vona/src/suite-vendor/a-vona/modules/a-redis/package.json +1 -1
  107. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/lib/redis.ts +8 -0
  108. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redis.ts +23 -6
  109. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redisClient_.ts +18 -1
  110. package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
  111. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +43 -15
  112. package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
  113. package/vona/src/suite-vendor/a-vona/modules/a-version/src/lib/databaseTestName.ts +3 -37
  114. package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +93 -15
  115. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  116. package/vona/tsconfig.json +1 -0
  117. package/scripts/initTestData.ts +0 -25
@@ -0,0 +1,129 @@
1
+ import fse from 'fs-extra';
2
+ import { createHash } from 'node:crypto';
3
+ import { lstatSync } from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ export const TEST_RUN_ID_ENV = 'VONA_TEST_RUN_ID';
7
+ export const TEST_RUN_OWNER_FILE = '.vona-test-owner';
8
+
9
+ const DATABASE_NAME_MAX_BYTES = 63;
10
+ const APP_NAME_MAX_BYTES = 13;
11
+ const HASH_LENGTH = 10;
12
+ const TIMESTAMP_LENGTH = 15;
13
+
14
+ export function getDatabaseTestPrefix(appName: string, scope: string): string {
15
+ const readablePrefix =
16
+ scope === 'share'
17
+ ? `vona-test-${appName}-s-`
18
+ : `vona-test-${appName}-i-${scope.slice('isolate-'.length)}-`;
19
+ if (Buffer.byteLength(readablePrefix, 'utf8') + TIMESTAMP_LENGTH <= DATABASE_NAME_MAX_BYTES) {
20
+ return readablePrefix;
21
+ }
22
+ let appHead = '';
23
+ for (const char of appName) {
24
+ if (Buffer.byteLength(`${appHead}${char}`, 'utf8') > APP_NAME_MAX_BYTES) break;
25
+ appHead += char;
26
+ }
27
+ const scopeKind = scope === 'share' ? 's' : 'i';
28
+ const namespaceHash = createHash('sha256')
29
+ .update(`${appName}\0${scope}`)
30
+ .digest('base64url')
31
+ .slice(0, HASH_LENGTH);
32
+ return `vona-test-${appHead}-${scopeKind}-${namespaceHash}-`;
33
+ }
34
+
35
+ export function assertDisposableDatabaseName(databaseName: string, databasePrefix: string) {
36
+ if (
37
+ typeof databaseName !== 'string' ||
38
+ !databaseName.startsWith(databasePrefix) ||
39
+ !/^\d{8}-\d{6}$/.test(databaseName.slice(databasePrefix.length)) ||
40
+ Buffer.byteLength(databaseName, 'utf8') > DATABASE_NAME_MAX_BYTES
41
+ ) {
42
+ throw new Error(`Database is outside the disposable namespace: ${databaseName}`);
43
+ }
44
+ }
45
+
46
+ export function resolveTestRunId(
47
+ mode: string,
48
+ runId: string | undefined = process.env[TEST_RUN_ID_ENV],
49
+ ) {
50
+ if (runId === undefined) return undefined;
51
+ const match = /^(\d{14})-[a-f0-9]{8}$/.exec(runId);
52
+ const timestamp = match?.[1];
53
+ const date = timestamp
54
+ ? new Date(
55
+ `${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`,
56
+ )
57
+ : undefined;
58
+ if (
59
+ mode !== 'test' ||
60
+ !timestamp ||
61
+ !date ||
62
+ Number.isNaN(date.getTime()) ||
63
+ date.toISOString().slice(0, 19).replace(/[-:T]/g, '') !== timestamp
64
+ ) {
65
+ throw new Error(
66
+ `${TEST_RUN_ID_ENV} requires test mode and a UTC timestamp plus eight hex digits`,
67
+ );
68
+ }
69
+ return runId;
70
+ }
71
+
72
+ export function getTestAppName(runId: string) {
73
+ resolveTestRunId('test', runId);
74
+ return `cabtest${runId}`;
75
+ }
76
+
77
+ export function assertTestAppIdentity(mode: string, runId: string | undefined, appName: string) {
78
+ const id = resolveTestRunId(mode, runId);
79
+ if (id && appName !== getTestAppName(id)) {
80
+ throw new Error('Isolated test APP_NAME does not match its run ID');
81
+ }
82
+ return id;
83
+ }
84
+
85
+ export function getTestRunIdFromEnv(env: NodeJS.ProcessEnv) {
86
+ return resolveTestRunId(env.META_MODE, env[TEST_RUN_ID_ENV]);
87
+ }
88
+
89
+ export function assertTestRunOwner(root: string, runId: string) {
90
+ resolveTestRunId('test', runId);
91
+ let current = path.resolve(root);
92
+ while (true) {
93
+ try {
94
+ if (lstatSync(current).isSymbolicLink()) {
95
+ throw new Error(`Isolated test path traverses a symlink: ${current}`);
96
+ }
97
+ } catch (error) {
98
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
99
+ }
100
+ const parent = path.dirname(current);
101
+ if (parent === current) break;
102
+ current = parent;
103
+ }
104
+ const marker = path.join(root, TEST_RUN_OWNER_FILE);
105
+ if (!fse.lstatSync(marker).isFile() || fse.readFileSync(marker, 'utf8') !== `${runId}\n`) {
106
+ throw new Error(`Isolated test directory has no matching owner: ${root}`);
107
+ }
108
+ }
109
+
110
+ export function resolveVonaLocalPaths(
111
+ projectPath: string,
112
+ mode: string,
113
+ runId: string | undefined = process.env[TEST_RUN_ID_ENV],
114
+ ) {
115
+ const id = resolveTestRunId(mode, runId);
116
+ const generatedDir = id ? `.vona-test-${id}` : '.vona';
117
+ const dataDir = id ? `.app-test-${id}` : '.app';
118
+ return {
119
+ runId: id,
120
+ generatedDir,
121
+ dataDir,
122
+ generatedRoot: path.join(projectPath, generatedDir),
123
+ dataRoot: path.join(projectPath, dataDir),
124
+ publicRoot: path.join(projectPath, dataDir, 'public'),
125
+ runtimeRoot: path.join(projectPath, dataDir, 'runtime'),
126
+ logsRoot: path.join(projectPath, dataDir, 'logs'),
127
+ sqliteRoot: path.join(projectPath, dataDir, 'sqlite3'),
128
+ };
129
+ }
@@ -5,6 +5,7 @@ import type { BootstrapOptions } from '../../types/interface/bootstrap.ts';
5
5
  import type { VonaConfigEnv } from '../../types/utils/env.ts';
6
6
 
7
7
  import { VonaApplication } from '../core/application.ts';
8
+ import { assertTestAppIdentity } from '../core/testIsolation.ts';
8
9
  import { jsxEnhance } from '../utils/jsx-enhance.ts';
9
10
  import { prepareEnv } from '../utils/util.ts';
10
11
  import { zodEnhance } from '../utils/zod-enhance.ts';
@@ -54,6 +55,7 @@ function __createApp({ modulesMeta, locales, config, env, AppMonkey }: Bootstrap
54
55
  jsxEnhance();
55
56
  // env
56
57
  const env2 = prepareEnv(env);
58
+ assertTestAppIdentity(env2.META_MODE, env2.VONA_TEST_RUN_ID, env2.APP_NAME ?? '');
57
59
  // appInfo
58
60
  const appInfo = prepareAppInfo(env2);
59
61
  // options
@@ -2,6 +2,8 @@ import type { VonaConfig, VonaConfigOptional } from '../../types/index.ts';
2
2
  import type { VonaApplication } from '../core/application.ts';
3
3
 
4
4
  import { EnumAppEvent } from '../../types/index.ts';
5
+ import { assertIsolatedTestConfig } from '../core/isolationGuard.ts';
6
+ import { registerTestClaimResourceInventory } from '../core/testClaim.ts';
5
7
  import { ModuleLoader } from '../module/loader.ts';
6
8
  import { deepExtend } from '../utils/util.ts';
7
9
 
@@ -65,6 +67,8 @@ export class Start {
65
67
  // config
66
68
  const appConfig = await __prepareConfig(app);
67
69
  this.app.config = appConfig as VonaConfig;
70
+ assertIsolatedTestConfig(app);
71
+ await registerTestClaimResourceInventory(app);
68
72
  this.app.keys = appConfig.server!.keys!;
69
73
  this.app.proxy = appConfig.proxy!.enable!;
70
74
  this.app.subdomainOffset = appConfig.server!.subdomainOffset!;
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
 
4
4
  import type { VonaApplication } from '../core/application.ts';
5
5
 
6
+ import { resolveVonaLocalPaths } from '../core/testIsolation.ts';
6
7
  import { pathToHref } from '../utils/util.ts';
7
8
 
8
9
  export function useApp(): VonaApplication {
@@ -38,7 +39,10 @@ export async function createGeneralApp(
38
39
  const testInstance = await import(pathToHref(testFile));
39
40
  return await testInstance.appBootstrap;
40
41
  } else {
41
- const testFile = path.join(projectPath, '.vona/app.ts');
42
+ const testFile = path.join(
43
+ resolveVonaLocalPaths(projectPath, process.env.META_MODE).generatedRoot,
44
+ 'app.ts',
45
+ );
42
46
  const testInstance = await import(pathToHref(testFile));
43
47
  return await testInstance.createSingleApp();
44
48
  }
@@ -42,6 +42,8 @@ export interface VonaApplicationOptions {
42
42
  locales: TypeBootstrapOptionsLocales;
43
43
  config: TypeBootstrapOptionsConfig;
44
44
  env: VonaConfigEnv;
45
+ /** Internal cleanup-only source configuration resolution; never starts an application. */
46
+ configResolutionOnly?: boolean;
45
47
  AppMonkey?: AppMonkeyConstructable;
46
48
  }
47
49
 
@@ -4,6 +4,7 @@ export interface VonaConfigEnv {
4
4
  META_FLAVOR: VonaMetaFlavor;
5
5
  META_MODE: VonaMetaMode;
6
6
  APP_NAME: string | undefined;
7
+ VONA_TEST_RUN_ID: string | undefined;
7
8
  APP_TITLE: string | undefined;
8
9
  APP_VERSION: string | undefined;
9
10
  SERVER_KEYS: string | undefined;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-mock",
3
- "version": "6.1.42",
3
+ "version": "6.1.44",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona mock",
6
6
  "keywords": [
@@ -1652,7 +1652,7 @@ importers:
1652
1652
  specifier: ^5.0.3
1653
1653
  version: link:modules/a-markdown
1654
1654
  vona-module-a-rbac:
1655
- specifier: ^5.0.8
1655
+ specifier: ^5.0.10
1656
1656
  version: link:modules/a-rbac
1657
1657
  vona-module-a-socket:
1658
1658
  specifier: workspace:^
@@ -1,12 +1,20 @@
1
1
  import type { IMetaOptionsIndex } from 'vona-module-a-index';
2
2
 
3
+ import { DateTime } from 'luxon';
3
4
  import assert from 'node:assert/strict';
4
5
  import { randomUUID } from 'node:crypto';
5
- import { mkdtemp, rm } from 'node:fs/promises';
6
- import { tmpdir } from 'node:os';
6
+ import { lstat, open, rm } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
  import { describe, it } from 'node:test';
9
- import { appResource } from 'vona';
9
+ import {
10
+ appResource,
11
+ assertDisposableDatabaseName,
12
+ assertIsolatedTestPath,
13
+ assertTestRunOwner,
14
+ getDatabaseTestPrefix,
15
+ registerTestClaimDatabaseCreation,
16
+ resolveVonaLocalPaths,
17
+ } from 'vona';
10
18
  import { app } from 'vona-mock';
11
19
  import { ServiceDatabaseAsyncLocalStorage, ServiceDatabaseClient } from 'vona-module-a-orm';
12
20
 
@@ -24,6 +32,56 @@ const expectedIndexes = {
24
32
  ],
25
33
  };
26
34
 
35
+ const attemptedCopyNames = new Set<string>();
36
+
37
+ async function allocateOwnedSqliteCopy(source: ServiceDatabaseClient) {
38
+ const runId = app.meta.env.VONA_TEST_RUN_ID;
39
+ assert.ok(runId, 'the isolated SQLite copy requires a test run ID');
40
+ const paths = resolveVonaLocalPaths(app.projectPath, 'test', runId);
41
+ assertTestRunOwner(paths.dataRoot, runId);
42
+ const prefix = getDatabaseTestPrefix(app.name, 'share');
43
+
44
+ for (let offset = 0; offset < 3600; offset++) {
45
+ const timestamp = DateTime.now().plus({ seconds: offset }).toFormat('yyyyMMdd-HHmmss');
46
+ const databaseName = `${prefix}${timestamp}`;
47
+ assertDisposableDatabaseName(databaseName, prefix);
48
+ const filename = path.join(paths.sqliteRoot, `${databaseName}.db`);
49
+ if (attemptedCopyNames.has(filename)) continue;
50
+ const reservation = `${filename}.lock`;
51
+ assertIsolatedTestPath(filename, path.join(paths.sqliteRoot, path.basename(filename)));
52
+ assertIsolatedTestPath(reservation, path.join(paths.sqliteRoot, path.basename(reservation)));
53
+ try {
54
+ const reservationHandle = await open(reservation, 'wx');
55
+ await reservationHandle.close();
56
+ try {
57
+ const exists = await lstat(filename).then(
58
+ () => true,
59
+ error => {
60
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
61
+ throw error;
62
+ },
63
+ );
64
+ if (exists) {
65
+ await rm(reservation);
66
+ continue;
67
+ }
68
+ attemptedCopyNames.add(filename);
69
+ await registerTestClaimDatabaseCreation(app, source.clientConfig, databaseName, 'intent');
70
+ const databaseHandle = await open(filename, 'wx');
71
+ await databaseHandle.close();
72
+ await rm(filename);
73
+ return { filename, paths, reservation };
74
+ } catch (error) {
75
+ await rm(reservation, { force: true });
76
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
77
+ }
78
+ } catch (error) {
79
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
80
+ }
81
+ }
82
+ throw new Error('Unable to allocate an owned SQLite copy name');
83
+ }
84
+
27
85
  describe('paymentIndexes.test.ts', { concurrency: false }, () => {
28
86
  it('registers all payment indexes without overwriting table keys', () => {
29
87
  const options = appResource.getBean('commerce-payment.meta.index')
@@ -85,12 +143,17 @@ describe('paymentIndexes.test.ts', { concurrency: false }, () => {
85
143
  t.skip('SQLite-only independent database copy');
86
144
  return;
87
145
  }
88
- const directory = await mkdtemp(path.join(tmpdir(), 'commerce-index-recovery-'));
89
- const filename = path.join(directory, 'existing.db');
90
146
  const source = app.bean.database.current;
147
+ const { filename, paths, reservation } = await allocateOwnedSqliteCopy(source.client);
91
148
  let client: ServiceDatabaseClient | undefined;
92
149
  try {
93
150
  await source.connection.raw('VACUUM main INTO ?', [filename]);
151
+ await registerTestClaimDatabaseCreation(
152
+ app,
153
+ source.client.clientConfig,
154
+ path.basename(filename, '.db'),
155
+ 'created',
156
+ );
94
157
  const selector = `index-recovery-${randomUUID()}`;
95
158
  client = app.bean._newBean(ServiceDatabaseClient, selector, {
96
159
  client: 'better-sqlite3',
@@ -154,11 +217,11 @@ describe('paymentIndexes.test.ts', { concurrency: false }, () => {
154
217
  }
155
218
  });
156
219
  } finally {
157
- try {
158
- if (client) await client.connection.destroy();
159
- } finally {
160
- await rm(directory, { recursive: true, force: true });
161
- }
220
+ if (client) await client.connection.destroy();
221
+ assertTestRunOwner(paths.dataRoot, paths.runId!);
222
+ assertIsolatedTestPath(filename, filename);
223
+ await source.connection.schema.dropDatabase(filename);
224
+ await rm(reservation, { force: true });
162
225
  }
163
226
  });
164
227
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-rbac",
3
- "version": "5.0.8",
3
+ "version": "5.0.10",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-suite-a-cabloy",
3
- "version": "5.1.59",
3
+ "version": "5.1.61",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "author": "",
@@ -10,7 +10,7 @@
10
10
  "vona-module-a-cabloy": "workspace:^",
11
11
  "vona-module-a-layoutprofile": "^5.0.2",
12
12
  "vona-module-a-markdown": "^5.0.3",
13
- "vona-module-a-rbac": "^5.0.8",
13
+ "vona-module-a-rbac": "^5.0.10",
14
14
  "vona-module-a-socket": "workspace:^",
15
15
  "vona-module-a-ssr": "workspace:^",
16
16
  "vona-module-a-ssrhmr": "workspace:^"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-core",
3
- "version": "5.1.56",
3
+ "version": "5.1.58",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-logger",
3
- "version": "5.1.13",
3
+ "version": "5.1.15",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -8,6 +8,8 @@ import StdSerializers from 'pino-std-serializers';
8
8
  import { BeanBase } from 'vona';
9
9
  import { MiddlewareSystem } from 'vona-module-a-aspect';
10
10
 
11
+ import { redactIsolatedReadyHeader } from '../lib/redactIsolatedReadyHeader.ts';
12
+
11
13
  export interface IMiddlewareSystemOptionsHttpLog extends IDecoratorMiddlewareSystemOptions {}
12
14
 
13
15
  @MiddlewareSystem<IMiddlewareSystemOptionsHttpLog>()
@@ -16,15 +18,27 @@ export class MiddlewareSystemHttpLog extends BeanBase implements IMiddlewareSyst
16
18
  const ctx = this.ctx;
17
19
  // start
18
20
  const req = StdSerializers.req(ctx.req);
21
+ req.headers = redactIsolatedReadyHeader(
22
+ req.headers,
23
+ ctx.path,
24
+ this.app.meta.env.VONA_TEST_RUN_ID,
25
+ 'x-vona-test-challenge',
26
+ );
19
27
  this.$loggerChild('req').http(() => JSON.stringify(req));
20
28
  const profiler = this.$loggerChild('res').startTimer();
21
29
  // next
22
30
  await next();
23
31
  // end
32
+ const headers = redactIsolatedReadyHeader(
33
+ ctx.res.getHeaders(),
34
+ ctx.path,
35
+ this.app.meta.env.VONA_TEST_RUN_ID,
36
+ 'x-vona-test-proof',
37
+ );
24
38
  const res = {
25
39
  url: ctx.req.url,
26
40
  statusCode: ctx.res.statusCode,
27
- headers: ctx.res.getHeaders(),
41
+ headers,
28
42
  };
29
43
  profiler.done({ level: 'http', message: JSON.stringify(res) });
30
44
  }
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import { redactIsolatedReadyHeader } from './redactIsolatedReadyHeader.ts';
5
+
6
+ test('redacts isolated readiness challenge and proof without changing live headers', () => {
7
+ const runId = '20261014010203-a1b2c3d4';
8
+ const req = { 'x-vona-test-challenge': 'challenge-value', 'accept': 'application/json' };
9
+ const res = { 'x-vona-test-proof': 'proof-value', 'content-type': 'application/json' };
10
+ const loggedReq = redactIsolatedReadyHeader(req, '/health/ready', runId, 'x-vona-test-challenge');
11
+ const loggedRes = redactIsolatedReadyHeader(res, '/health/ready', runId, 'x-vona-test-proof');
12
+
13
+ assert.equal(JSON.stringify(loggedReq).includes('challenge-value'), false);
14
+ assert.equal(JSON.stringify(loggedRes).includes('proof-value'), false);
15
+ assert.equal(loggedReq.accept, 'application/json');
16
+ assert.equal(loggedRes['content-type'], 'application/json');
17
+ assert.equal(req['x-vona-test-challenge'], 'challenge-value');
18
+ assert.equal(res['x-vona-test-proof'], 'proof-value');
19
+ assert.equal(redactIsolatedReadyHeader(req, '/health/live', runId, 'x-vona-test-challenge'), req);
20
+ assert.equal(
21
+ redactIsolatedReadyHeader(req, '/health/ready', undefined, 'x-vona-test-challenge'),
22
+ req,
23
+ );
24
+ });
@@ -0,0 +1,11 @@
1
+ export function redactIsolatedReadyHeader<HEADERS extends Record<string, unknown>>(
2
+ headers: HEADERS,
3
+ path: string,
4
+ runId: string | undefined,
5
+ header: string,
6
+ ): HEADERS {
7
+ if (path !== '/health/ready' || !runId) return headers;
8
+ const redacted = { ...headers };
9
+ delete redacted[header];
10
+ return redacted;
11
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-metrics",
3
- "version": "5.0.2",
3
+ "version": "5.0.4",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -4,6 +4,7 @@ import type {
4
4
  IMiddlewareSystemExecute,
5
5
  } from 'vona-module-a-aspect';
6
6
 
7
+ import { createHmac } from 'node:crypto';
7
8
  import { BeanBase } from 'vona';
8
9
  import { MiddlewareSystem } from 'vona-module-a-aspect';
9
10
 
@@ -19,6 +20,20 @@ export class MiddlewareSystemHealth extends BeanBase implements IMiddlewareSyste
19
20
  const health = await this.$scope.metrics.service.health.status(path);
20
21
  this.ctx.status = health.code;
21
22
  this.ctx.type = 'application/json';
23
+ if (
24
+ path === '/health/ready' &&
25
+ this.app.meta.env.META_MODE === 'test' &&
26
+ this.app.meta.env.VONA_TEST_RUN_ID &&
27
+ process.env.VONA_TEST_READY_KEY
28
+ ) {
29
+ const challenge = this.ctx.get('x-vona-test-challenge');
30
+ if (/^[a-f0-9]{32}$/.test(challenge)) {
31
+ this.ctx.set(
32
+ 'x-vona-test-proof',
33
+ createHmac('sha256', process.env.VONA_TEST_READY_KEY).update(challenge).digest('hex'),
34
+ );
35
+ }
36
+ }
22
37
  this.ctx.body = { status: health.status };
23
38
  }
24
39
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-orm",
3
- "version": "5.1.32",
3
+ "version": "5.1.34",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "db",
6
6
  "keywords": [
@@ -2,7 +2,7 @@ import type { Knex } from 'knex';
2
2
  import type { VonaApplication } from 'vona';
3
3
 
4
4
  import knex from 'knex';
5
- import { cast } from 'vona';
5
+ import { cast, registerTestClaimDatabaseCreation } from 'vona';
6
6
 
7
7
  import type { IDatabaseClientDialectRecord } from '../types/database.ts';
8
8
  import type { IFetchDatabasesResultItem, IFetchIndexesResultItem } from '../types/dialect.ts';
@@ -10,10 +10,17 @@ import type { IFetchDatabasesResultItem, IFetchIndexesResultItem } from '../type
10
10
  export function ExtendSchemaBuilder(app: VonaApplication) {
11
11
  ['fetchDatabases', 'createDatabase', 'dropDatabase', 'fetchIndexes'].forEach(method => {
12
12
  knex.SchemaBuilder.extend(method, async function (...args) {
13
- const client = cast<Knex.Client>(cast(this).client).config
14
- .client as keyof IDatabaseClientDialectRecord;
13
+ const clientConfig = cast<Knex.Client>(cast(this).client).config;
14
+ const client = clientConfig.client as keyof IDatabaseClientDialectRecord;
15
15
  const dialect = app.bean.database.getDialect(client);
16
- return await dialect[method](this, ...args);
16
+ if (method === 'createDatabase') {
17
+ await registerTestClaimDatabaseCreation(app, clientConfig, args[0], 'intent');
18
+ }
19
+ const result = await dialect[method](this, ...args);
20
+ if (method === 'createDatabase') {
21
+ await registerTestClaimDatabaseCreation(app, clientConfig, args[0], 'created');
22
+ }
23
+ return result;
17
24
  });
18
25
  });
19
26
  }
@@ -2,7 +2,7 @@ import type { Knex } from 'knex';
2
2
 
3
3
  import { catchError } from '@cabloy/utils';
4
4
  import knex from 'knex';
5
- import { deepExtend } from 'vona';
5
+ import { assertIsolatedTestDatabaseClient, assertTestClaimDatabaseClient, deepExtend } from 'vona';
6
6
  import { Service } from 'vona-module-a-bean';
7
7
  import { BeanMutateBase } from 'vona-module-a-beanmutate';
8
8
 
@@ -71,6 +71,8 @@ export class ServiceDatabaseClient extends BeanMutateBase {
71
71
  this.clientName = dbInfo.clientName;
72
72
  // config: inited by bean.database.getClient
73
73
  this.clientConfig = clientConfig!;
74
+ assertIsolatedTestDatabaseClient(this.app, this.clientName, this.clientConfig);
75
+ assertTestClaimDatabaseClient(this.app, this.clientName, this.clientConfig);
74
76
  // knex
75
77
  this._knex = knex(deepExtend({}, this.clientConfig));
76
78
  }
@@ -89,6 +91,7 @@ export class ServiceDatabaseClient extends BeanMutateBase {
89
91
  this.clientName,
90
92
  clientConfig,
91
93
  );
94
+ assertIsolatedTestDatabaseClient(this.app, this.clientName, clientConfigReal);
92
95
  this.$mutate([this.clientNameSelector, clientConfigReal]);
93
96
  await this.__close();
94
97
  this.__load(this.clientNameSelector, clientConfigReal);
@@ -118,6 +121,8 @@ export class ServiceDatabaseClient extends BeanMutateBase {
118
121
  // * should not use this.clientConfig.connection, because password is hidden
119
122
  const config = this.scope.service.database.getClientConfig(this.clientName, undefined, true);
120
123
  config.connection = Object.assign({}, config.connection, connDatabaseName);
124
+ const effectiveConfig = this.scope.service.database.getClientConfig(this.clientName, config);
125
+ assertIsolatedTestDatabaseClient(this.app, this.clientName, effectiveConfig);
121
126
  // only used by startup, so no consider that workers broadcast
122
127
  this.configDatabase.clients[this.clientName] = config;
123
128
  // reload
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-ormdialect",
3
- "version": "5.1.18",
3
+ "version": "5.1.20",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -11,7 +11,9 @@ import type {
11
11
 
12
12
  import { ensureDir, remove } from 'fs-extra';
13
13
  import { globby } from 'globby';
14
+ import { lstat } from 'node:fs/promises';
14
15
  import path from 'node:path';
16
+ import { assertTestRunOwner, resolveVonaLocalPaths, resolveTestRunId } from 'vona';
15
17
  import { ServiceDatabaseDialectBase, DatabaseDialect } from 'vona-module-a-orm';
16
18
 
17
19
  export interface IDatabaseDialectOptionsBetterSqlite3 extends IDecoratorDatabaseDialectOptions {}
@@ -54,6 +56,30 @@ export class DatabaseDialectBetterSqlite3 extends ServiceDatabaseDialectBase {
54
56
  }
55
57
 
56
58
  async dropDatabase(_schemaBuilder: Knex.SchemaBuilder, databaseName: string): Promise<void> {
59
+ const isolated = resolveTestRunId(
60
+ this.app.meta.env.META_MODE,
61
+ this.app.meta.env.VONA_TEST_RUN_ID,
62
+ );
63
+ if (isolated) {
64
+ const paths = resolveVonaLocalPaths(this.app.projectPath, 'test', isolated);
65
+ assertTestRunOwner(paths.dataRoot, isolated);
66
+ const basename = path.basename(databaseName);
67
+ if (
68
+ path.resolve(databaseName) !== path.join(paths.sqliteRoot, basename) ||
69
+ !/^vona-test-[\w-]+-\d{8}-\d{6}\.db$/.test(basename)
70
+ ) {
71
+ throw new Error(
72
+ `Isolated test cannot remove SQLite database outside its owned root: ${databaseName}`,
73
+ );
74
+ }
75
+ await assertNotSymlink(paths.sqliteRoot);
76
+ for (const file of [
77
+ databaseName,
78
+ ...['shm', 'wal', 'journal'].map(part => `${databaseName}-${part}`),
79
+ ]) {
80
+ await assertNotSymlink(file);
81
+ }
82
+ }
57
83
  await remove(databaseName);
58
84
  for (const part of ['shm', 'wal', 'journal']) {
59
85
  await remove(`${databaseName}-${part}`);
@@ -93,7 +119,21 @@ export class DatabaseDialectBetterSqlite3 extends ServiceDatabaseDialectBase {
93
119
  }
94
120
 
95
121
  private _getDbDir() {
96
- return path.join(this.app.projectPath, '.app/sqlite3');
122
+ return resolveVonaLocalPaths(
123
+ this.app.projectPath,
124
+ this.app.meta.env.META_MODE,
125
+ this.app.meta.env.VONA_TEST_RUN_ID,
126
+ ).sqliteRoot;
127
+ }
128
+ }
129
+
130
+ async function assertNotSymlink(file: string) {
131
+ try {
132
+ if ((await lstat(file)).isSymbolicLink()) {
133
+ throw new Error(`Isolated test SQLite path is a symlink: ${file}`);
134
+ }
135
+ } catch (error) {
136
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
97
137
  }
98
138
  }
99
139
 
@@ -34,7 +34,10 @@ export class DatabaseDialectMysql extends ServiceDatabaseDialectBase {
34
34
  schemaBuilder: Knex.SchemaBuilder,
35
35
  databasePrefix: string,
36
36
  ): Promise<IFetchDatabasesResultItem[]> {
37
- const res = await schemaBuilder.raw(`show databases like '${databasePrefix}%'`);
37
+ const escapedPrefix = databasePrefix.replaceAll("'", "''");
38
+ const res = await schemaBuilder.raw(
39
+ `select schema_name from information_schema.schemata where left(schema_name, ${databasePrefix.length}) = '${escapedPrefix}'`,
40
+ );
38
41
  let dbs = res[0];
39
42
  dbs = dbs.map(db => {
40
43
  const name = db[Object.keys(db)[0]];