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.
- package/.cabloy-version +1 -1
- package/.gitignore +2 -0
- package/CHANGELOG.md +21 -0
- package/lint-staged.config.mjs +2 -0
- package/oxfmt.config.ts +2 -0
- package/oxlint.config.ts +2 -0
- package/package.json +2 -3
- package/repo-agent-governance/managed-assets.json +6 -6
- package/repo-agent-governance/policies/repository.md +3 -2
- package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +2 -0
- package/repo-docs/backend/migration-and-changes.md +13 -12
- package/repo-docs/backend/runtime-and-flavors.md +2 -1
- package/repo-docs/backend/unit-testing.md +12 -8
- package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/index.md +9 -9
- package/repo-docs/frontend/scripts.md +1 -1
- package/repo-docs/fullstack/parallel-worktree-environment.md +6 -6
- package/repo-docs/fullstack/ssr-entry-modes.md +19 -0
- package/repo-docs/reference/repo-scripts.md +22 -1
- package/repo-e2e/config/playwright.config.ts +32 -14
- package/repo-e2e/scripts/e2e.test.ts +39 -0
- package/repo-e2e/scripts/e2e.ts +19 -1
- package/repo-e2e/scripts/managedTest.test.ts +389 -0
- package/repo-e2e/scripts/managedTest.ts +210 -0
- package/repo-e2e/scripts/managedTestImports.test.ts +74 -0
- package/repo-e2e/scripts/managedTestImports.ts +110 -0
- package/repo-e2e/scripts/runE2e.ts +12 -1
- package/repo-e2e/scripts/startE2eVona.ts +37 -5
- package/repo-e2e/specs/a-commerce.spec.ts +1 -1
- package/repo-e2e/specs/cabloy-basic.spec.ts +1 -1
- package/repo-e2e/specs/controller-boundary.spec.ts +1 -1
- package/repo-e2e/specs/home-user-account.spec.ts +2 -1
- package/repo-e2e/specs/markdown.spec.ts +1 -1
- package/repo-e2e/specs/routed-dialog.spec.ts +1 -1
- package/scripts/runIsolatedTest.test.ts +447 -0
- package/scripts/runIsolatedTest.ts +462 -0
- package/scripts/testClaimCleanup.test.ts +346 -0
- package/scripts/testClaimCleanup.ts +375 -0
- package/scripts/testClaimProcesses.test.ts +106 -0
- package/scripts/testClaimProcesses.ts +129 -0
- package/scripts/testProcessGuardian.ts +163 -0
- package/scripts/upgrade.ts +1 -16
- package/vona/oxfmt.config.ts +2 -0
- package/vona/oxlint.config.ts +2 -0
- package/vona/package.original.json +2 -2
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli/src/play.test.ts +37 -0
- package/vona/packages-cli/cli/src/play.ts +23 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dbReset.ts +16 -7
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +13 -7
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.play.ts +5 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.test.ts +12 -7
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/configUtils.ts +21 -2
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/generateVonaMeta.ts +10 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.test.ts +153 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.ts +141 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.test.ts +148 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.ts +127 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.test.ts +47 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.ts +139 -0
- package/vona/packages-cli/cli-set-api/src/lib/utils.ts +2 -3
- package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_project.json +1 -0
- package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_source.json +1 -0
- package/vona/packages-cli/cli-set-api/test/binDevMode.test.ts +18 -0
- package/vona/packages-cli/cli-set-api/toolsIsolate/dbReset.ts +15 -2
- package/vona/packages-cli/cli-set-api/tsconfig.build.json +2 -1
- package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
- package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.test.ts +16 -0
- package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.ts +3 -2
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/addon/sqlite3.ts +1 -1
- package/vona/packages-vona/vona-core/src/lib/core/config.ts +44 -6
- package/vona/packages-vona/vona-core/src/lib/core/configCleanup.test.ts +40 -0
- package/vona/packages-vona/vona-core/src/lib/core/index.ts +3 -0
- package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.test.ts +301 -0
- package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.ts +312 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +577 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +985 -0
- package/vona/packages-vona/vona-core/src/lib/core/testIsolation.ts +129 -0
- package/vona/packages-vona/vona-core/src/lib/framework/createApp.ts +2 -0
- package/vona/packages-vona/vona-core/src/lib/framework/start.ts +4 -0
- package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +5 -1
- package/vona/packages-vona/vona-core/src/types/application/app.ts +2 -0
- package/vona/packages-vona/vona-core/src/types/utils/env.ts +1 -0
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentIndexes.test.ts +73 -10
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +15 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +24 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +11 -0
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +15 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/extend/schemaBuilder.ts +11 -4
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/databaseClient_.ts +6 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.betterSqlite3.ts +41 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.mysql.ts +4 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.pg.ts +2 -1
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-redis/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-redis/src/lib/redis.ts +8 -0
- package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redis.ts +23 -6
- package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redisClient_.ts +18 -1
- package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +43 -15
- package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-version/src/lib/databaseTestName.ts +3 -37
- package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +93 -15
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/vona/tsconfig.json +1 -0
- 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(
|
|
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
|
|
package/vona/pnpm-lock.yaml
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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-suite-a-cabloy",
|
|
3
|
-
"version": "5.1.
|
|
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.
|
|
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:^"
|
|
@@ -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
|
|
41
|
+
headers,
|
|
28
42
|
};
|
|
29
43
|
profiler.done({ level: 'http', message: JSON.stringify(res) });
|
|
30
44
|
}
|
package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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
|
|
14
|
-
|
|
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
|
-
|
|
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
|
package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.betterSqlite3.ts
CHANGED
|
@@ -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
|
|
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
|
|
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]];
|