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.
- 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 +6 -5
- package/repo-agent-governance/managed-assets.json +9 -9
- package/repo-agent-governance/policies/repository.md +5 -2
- package/repo-agent-governance/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +2 -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/orm-select-guide.md +6 -0
- package/repo-docs/backend/runtime-and-flavors.md +2 -1
- package/repo-docs/backend/transaction-guide.md +12 -0
- package/repo-docs/backend/unit-testing.md +12 -8
- package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/cover-en-v1.png +0 -0
- package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/completion-summary.jpg +0 -0
- package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/multitasking-draft.jpg +0 -0
- package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/ssr-refresh.jpg +0 -0
- package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/index.md +216 -0
- package/repo-docs/blogs/index.md +15 -0
- 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/index.md +2 -0
- package/repo-docs/reference/repo-scripts.md +22 -1
- package/repo-e2e/config/playwright.config.ts +32 -14
- package/repo-e2e/docs/specs/blogs-index.spec.ts +23 -6
- 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 +426 -0
- package/scripts/runIsolatedTest.ts +454 -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 +40 -40
- 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,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]];
|
|
@@ -26,8 +26,9 @@ export class DatabaseDialectPg extends ServiceDatabaseDialectBase {
|
|
|
26
26
|
schemaBuilder: Knex.SchemaBuilder,
|
|
27
27
|
databasePrefix: string,
|
|
28
28
|
): Promise<IFetchDatabasesResultItem[]> {
|
|
29
|
+
const escapedPrefix = databasePrefix.replaceAll("'", "''");
|
|
29
30
|
const res: any = await schemaBuilder.raw(
|
|
30
|
-
`select datname from pg_database where datname
|
|
31
|
+
`select datname from pg_database where left(datname, ${databasePrefix.length}) = '${escapedPrefix}'`,
|
|
31
32
|
);
|
|
32
33
|
let dbs = res.rows;
|
|
33
34
|
dbs = dbs.map(db => {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { VonaApplication } from 'vona';
|
|
2
2
|
|
|
3
|
+
import { resolveTestRunId } from 'vona';
|
|
4
|
+
|
|
3
5
|
export function getRedisClientKeyPrefix(clientName: string, app: VonaApplication): string {
|
|
4
6
|
const mode = ['test', 'dev'].includes(app.config.meta.mode) ? '_local' : '';
|
|
5
7
|
return `${clientName}_${app.name}${mode}:`;
|
|
@@ -10,5 +12,11 @@ export function prepareRedisClientKeyPrefix(
|
|
|
10
12
|
clientName: string,
|
|
11
13
|
app: VonaApplication,
|
|
12
14
|
): string | undefined {
|
|
15
|
+
const isolated = resolveTestRunId(app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID);
|
|
16
|
+
const specialClient =
|
|
17
|
+
['queue', 'broadcast'].includes(clientName) || clientName.startsWith('redlock');
|
|
18
|
+
if (isolated && (specialClient ? keyPrefix !== undefined : keyPrefix !== true)) {
|
|
19
|
+
throw new Error(`Isolated test requires a framework-managed Redis prefix: ${clientName}`);
|
|
20
|
+
}
|
|
13
21
|
return keyPrefix === true ? getRedisClientKeyPrefix(clientName, app) : keyPrefix;
|
|
14
22
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Redis } from 'ioredis';
|
|
2
2
|
|
|
3
|
-
import { BeanBase } from 'vona';
|
|
3
|
+
import { BeanBase, resolveTestRunId } from 'vona';
|
|
4
4
|
import { Service } from 'vona-module-a-bean';
|
|
5
5
|
|
|
6
6
|
import type { IRedisClientRecord } from '../types/redis.ts';
|
|
@@ -14,7 +14,8 @@ export class ServiceRedis extends BeanBase {
|
|
|
14
14
|
// clear keys
|
|
15
15
|
await this._clearRedisKeys(
|
|
16
16
|
app.bean.redis.get('queue'),
|
|
17
|
-
|
|
17
|
+
getRedisClientKeyPrefix('bull', app),
|
|
18
|
+
true,
|
|
18
19
|
);
|
|
19
20
|
// broadcast channel has subscribed
|
|
20
21
|
// await _clearRedisKeys(app.redis.get('broadcast'), `${getRedisClientKeyPrefix('broadcast', app)}*`);
|
|
@@ -24,22 +25,38 @@ export class ServiceRedis extends BeanBase {
|
|
|
24
25
|
if (clientName.startsWith('redlock')) {
|
|
25
26
|
await this._clearRedisKeys(
|
|
26
27
|
app.bean.redis.get(clientName),
|
|
27
|
-
|
|
28
|
+
getRedisClientKeyPrefix('redlock', app),
|
|
29
|
+
true,
|
|
28
30
|
);
|
|
29
31
|
} else {
|
|
30
32
|
const client = app.config.redis.clients[clientName];
|
|
31
33
|
const keyPrefix = prepareRedisClientKeyPrefix(client.keyPrefix, clientName, this.app);
|
|
32
|
-
await this._clearRedisKeys(app.bean.redis.get(clientName),
|
|
34
|
+
await this._clearRedisKeys(app.bean.redis.get(clientName), keyPrefix!);
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
private async _clearRedisKeys(redis: Redis,
|
|
39
|
+
private async _clearRedisKeys(redis: Redis, expectedPrefix: string, unprefixedClient = false) {
|
|
38
40
|
if (!redis) return;
|
|
41
|
+
const isolated = resolveTestRunId(
|
|
42
|
+
this.app.meta.env.META_MODE,
|
|
43
|
+
this.app.meta.env.VONA_TEST_RUN_ID,
|
|
44
|
+
);
|
|
39
45
|
const keyPrefix = redis.options.keyPrefix;
|
|
40
|
-
|
|
46
|
+
if (isolated) {
|
|
47
|
+
if (!expectedPrefix.includes(this.app.name) || !expectedPrefix.endsWith(':')) {
|
|
48
|
+
throw new Error('Isolated test Redis cleanup prefix does not belong to this run');
|
|
49
|
+
}
|
|
50
|
+
if (unprefixedClient ? Boolean(keyPrefix) : keyPrefix !== expectedPrefix) {
|
|
51
|
+
throw new Error('Isolated test Redis cleanup uses an unexpected client prefix');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const keys = await redis.keys(`${expectedPrefix}*`);
|
|
41
55
|
const keysDel: string[] = [];
|
|
42
56
|
for (const fullKey of keys) {
|
|
57
|
+
if (isolated && !fullKey.startsWith(expectedPrefix)) {
|
|
58
|
+
throw new Error('Isolated test Redis lookup returned a key outside the owned prefix');
|
|
59
|
+
}
|
|
43
60
|
const key = keyPrefix ? fullKey.substring(keyPrefix.length) : fullKey;
|
|
44
61
|
keysDel.push(key);
|
|
45
62
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { RedisOptions } from 'ioredis';
|
|
2
2
|
|
|
3
3
|
import { Redis } from 'ioredis';
|
|
4
|
-
import { BeanBase, cast, deepExtend } from 'vona';
|
|
4
|
+
import { assertTestClaimRedisClient, BeanBase, cast, deepExtend, resolveTestRunId } from 'vona';
|
|
5
5
|
import { Service } from 'vona-module-a-bean';
|
|
6
6
|
|
|
7
7
|
import type { ConfigRedisCluster, IRedisClientRecord } from '../types/redis.ts';
|
|
@@ -29,6 +29,22 @@ export class ServiceRedisClient extends BeanBase {
|
|
|
29
29
|
const configRedis = this.app.config.redis;
|
|
30
30
|
const configClient = configRedis.clients[clientName];
|
|
31
31
|
if (!configClient) throw new Error(`redis client not found: ${clientName}`);
|
|
32
|
+
const isolated = resolveTestRunId(
|
|
33
|
+
this.app.meta.env.META_MODE,
|
|
34
|
+
this.app.meta.env.VONA_TEST_RUN_ID,
|
|
35
|
+
);
|
|
36
|
+
if (
|
|
37
|
+
isolated &&
|
|
38
|
+
(cast<RedisOptions>(configClient).sentinels || cast<ConfigRedisCluster>(configClient).nodes)
|
|
39
|
+
) {
|
|
40
|
+
throw new Error(`Isolated test cannot use a Redis sentinel or cluster client: ${clientName}`);
|
|
41
|
+
}
|
|
42
|
+
if (isolated) {
|
|
43
|
+
prepareRedisClientKeyPrefix(configClient.keyPrefix, clientName, this.app);
|
|
44
|
+
if (configRedis.base.keyPrefix !== undefined) {
|
|
45
|
+
throw new Error('Isolated test cannot inherit a Redis base keyPrefix');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
32
48
|
if (cast<RedisOptions>(configClient).sentinels) {
|
|
33
49
|
// sentinels
|
|
34
50
|
return new Redis(configClient as RedisOptions);
|
|
@@ -40,6 +56,7 @@ export class ServiceRedisClient extends BeanBase {
|
|
|
40
56
|
} else {
|
|
41
57
|
const keyPrefix = prepareRedisClientKeyPrefix(configClient.keyPrefix, clientName, this.app);
|
|
42
58
|
const configNode = deepExtend({}, configRedis.base, configClient, { keyPrefix });
|
|
59
|
+
assertTestClaimRedisClient(this.app, clientName, configNode);
|
|
43
60
|
return new Redis(configNode);
|
|
44
61
|
}
|
|
45
62
|
}
|
|
@@ -3,7 +3,13 @@ import type { IOnionSlice } from 'vona-module-a-onion';
|
|
|
3
3
|
|
|
4
4
|
import { isNil } from '@cabloy/utils';
|
|
5
5
|
import fse from 'fs-extra';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
assertIsolatedTestConfig,
|
|
8
|
+
BeanBase,
|
|
9
|
+
cast,
|
|
10
|
+
getRuntimePathPhysicalRoot,
|
|
11
|
+
withIsolatedTestDatabaseDiscovery,
|
|
12
|
+
} from 'vona';
|
|
7
13
|
import { Service } from 'vona-module-a-bean';
|
|
8
14
|
|
|
9
15
|
import type {
|
|
@@ -16,6 +22,10 @@ import type {
|
|
|
16
22
|
@Service()
|
|
17
23
|
export class ServiceStartup extends BeanBase {
|
|
18
24
|
async appStart() {
|
|
25
|
+
// db:reset is a one-shot data preparation command, not a development server.
|
|
26
|
+
if (this.app.meta.isDev && process.env.VONA_DB_RESET === 'true') {
|
|
27
|
+
this.app.config.server.listen.disable = true;
|
|
28
|
+
}
|
|
19
29
|
// clear keys
|
|
20
30
|
await this._clearResources();
|
|
21
31
|
|
|
@@ -23,7 +33,23 @@ export class ServiceStartup extends BeanBase {
|
|
|
23
33
|
for (const startup of this._startups) {
|
|
24
34
|
const startupOptions = startup.beanOptions.options;
|
|
25
35
|
if (!startupOptions?.instance && startupOptions?.after !== true) {
|
|
26
|
-
|
|
36
|
+
// A previous dev startup may have left databaseInit's debounce key in Redis.
|
|
37
|
+
// Only the explicit reset must bypass that key to guarantee recreation.
|
|
38
|
+
const force =
|
|
39
|
+
this.app.meta.isDev &&
|
|
40
|
+
process.env.VONA_DB_RESET === 'true' &&
|
|
41
|
+
startup.name === 'a-version:databaseInit';
|
|
42
|
+
const run = () => this.runStartup(startup.name, null, force ? { force: true } : undefined);
|
|
43
|
+
if (this.app.meta.env.VONA_TEST_RUN_ID && startup.name === 'a-version:databaseInit') {
|
|
44
|
+
const defaultClient = this.app.config.database.defaultClient;
|
|
45
|
+
if (typeof defaultClient !== 'string') {
|
|
46
|
+
throw new TypeError('Isolated test requires a static default database client');
|
|
47
|
+
}
|
|
48
|
+
const client = this.app.config.database.clients[defaultClient];
|
|
49
|
+
await withIsolatedTestDatabaseDiscovery(this.app, defaultClient, client, run);
|
|
50
|
+
} else {
|
|
51
|
+
await run();
|
|
52
|
+
}
|
|
27
53
|
}
|
|
28
54
|
}
|
|
29
55
|
}
|
|
@@ -52,24 +78,25 @@ export class ServiceStartup extends BeanBase {
|
|
|
52
78
|
}
|
|
53
79
|
}
|
|
54
80
|
|
|
55
|
-
//
|
|
56
|
-
if (this.app.meta.isTest) {
|
|
57
|
-
|
|
58
|
-
await this.bean.executor.newCtx(
|
|
59
|
-
async () => {
|
|
60
|
-
await this.$scope.version.service.version.__instanceSeed(instanceName);
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
dbInfo: { level: 1 },
|
|
64
|
-
instanceName,
|
|
65
|
-
},
|
|
66
|
-
);
|
|
81
|
+
// Seed test runs and explicit development resets only, after instance initialization.
|
|
82
|
+
if (this.app.meta.isTest || (this.app.meta.isDev && process.env.VONA_DB_RESET === 'true')) {
|
|
83
|
+
await this.seedDefaultInstance();
|
|
67
84
|
}
|
|
68
85
|
}
|
|
69
86
|
|
|
87
|
+
private async seedDefaultInstance() {
|
|
88
|
+
const instanceName = '';
|
|
89
|
+
await this.bean.executor.newCtx(
|
|
90
|
+
async () => {
|
|
91
|
+
await this.$scope.version.service.version.__instanceSeed(instanceName);
|
|
92
|
+
},
|
|
93
|
+
{ dbInfo: { level: 1 }, instanceName },
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
private async _appReadyInstanceStartup(instanceName: keyof IInstanceRecord, wait: boolean) {
|
|
71
98
|
const res = this.$scope.instance.service.instance.instanceStartupIsolate(instanceName, {
|
|
72
|
-
force:
|
|
99
|
+
force: this.app.meta.isDev && process.env.VONA_DB_RESET === 'true',
|
|
73
100
|
});
|
|
74
101
|
if (wait) {
|
|
75
102
|
await res;
|
|
@@ -172,6 +199,7 @@ export class ServiceStartup extends BeanBase {
|
|
|
172
199
|
|
|
173
200
|
private async _clearResources() {
|
|
174
201
|
if (!this.app.meta.isTest) return;
|
|
202
|
+
assertIsolatedTestConfig(this.app);
|
|
175
203
|
// redis
|
|
176
204
|
await this.$scope.redis.service.redis.clearAllData();
|
|
177
205
|
// .app/public
|
|
@@ -1,21 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getDatabaseTestPrefix } from 'vona';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
const APP_NAME_MAX_BYTES = 13;
|
|
5
|
-
const HASH_LENGTH = 10;
|
|
6
|
-
const TIMESTAMP_LENGTH = 15;
|
|
7
|
-
|
|
8
|
-
export function getDatabaseTestPrefix(appName: string, scope: string): string {
|
|
9
|
-
const readablePrefix = getReadablePrefix(appName, scope);
|
|
10
|
-
if (Buffer.byteLength(readablePrefix, 'utf8') + TIMESTAMP_LENGTH <= DATABASE_NAME_MAX_BYTES) {
|
|
11
|
-
return readablePrefix;
|
|
12
|
-
}
|
|
3
|
+
export { getDatabaseTestPrefix };
|
|
13
4
|
|
|
14
|
-
|
|
15
|
-
const scopeKind = scope === 'share' ? 's' : 'i';
|
|
16
|
-
const namespaceHash = hash(`${appName}\0${scope}`);
|
|
17
|
-
return `vona-test-${appHead}-${scopeKind}-${namespaceHash}-`;
|
|
18
|
-
}
|
|
5
|
+
const DATABASE_NAME_MAX_BYTES = 63;
|
|
19
6
|
|
|
20
7
|
export function createDatabaseTestName(databasePrefix: string, timestamp: string): string {
|
|
21
8
|
const databaseName = `${databasePrefix}${timestamp}`;
|
|
@@ -24,24 +11,3 @@ export function createDatabaseTestName(databasePrefix: string, timestamp: string
|
|
|
24
11
|
}
|
|
25
12
|
return databaseName;
|
|
26
13
|
}
|
|
27
|
-
|
|
28
|
-
function getReadablePrefix(appName: string, scope: string): string {
|
|
29
|
-
if (scope === 'share') return `vona-test-${appName}-s-`;
|
|
30
|
-
return `vona-test-${appName}-i-${scope.slice('isolate-'.length)}-`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function hash(value: string): string {
|
|
34
|
-
return createHash('sha256').update(value).digest('base64url').slice(0, HASH_LENGTH);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function truncateUtf8(value: string, maxBytes: number): string {
|
|
38
|
-
let result = '';
|
|
39
|
-
let bytes = 0;
|
|
40
|
-
for (const char of value) {
|
|
41
|
-
const charBytes = Buffer.byteLength(char, 'utf8');
|
|
42
|
-
if (bytes + charBytes > maxBytes) break;
|
|
43
|
-
result += char;
|
|
44
|
-
bytes += charBytes;
|
|
45
|
-
}
|
|
46
|
-
return result;
|
|
47
|
-
}
|