cabloy 5.1.203 → 5.1.205

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/.cabloy-version +1 -1
  2. package/.gitignore +2 -0
  3. package/CHANGELOG.md +21 -0
  4. package/lint-staged.config.mjs +2 -0
  5. package/oxfmt.config.ts +2 -0
  6. package/oxlint.config.ts +2 -0
  7. package/package.json +6 -5
  8. package/repo-agent-governance/managed-assets.json +9 -9
  9. package/repo-agent-governance/policies/repository.md +5 -2
  10. package/repo-agent-governance/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +2 -2
  11. package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +2 -0
  12. package/repo-docs/backend/migration-and-changes.md +13 -12
  13. package/repo-docs/backend/orm-select-guide.md +6 -0
  14. package/repo-docs/backend/runtime-and-flavors.md +2 -1
  15. package/repo-docs/backend/transaction-guide.md +12 -0
  16. package/repo-docs/backend/unit-testing.md +12 -8
  17. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/cover-en-v1.png +0 -0
  18. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/completion-summary.jpg +0 -0
  19. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/multitasking-draft.jpg +0 -0
  20. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/ssr-refresh.jpg +0 -0
  21. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/index.md +216 -0
  22. package/repo-docs/blogs/index.md +15 -0
  23. package/repo-docs/frontend/scripts.md +1 -1
  24. package/repo-docs/fullstack/parallel-worktree-environment.md +6 -6
  25. package/repo-docs/fullstack/ssr-entry-modes.md +19 -0
  26. package/repo-docs/index.md +2 -0
  27. package/repo-docs/reference/repo-scripts.md +22 -1
  28. package/repo-e2e/config/playwright.config.ts +32 -14
  29. package/repo-e2e/docs/specs/blogs-index.spec.ts +23 -6
  30. package/repo-e2e/scripts/e2e.test.ts +39 -0
  31. package/repo-e2e/scripts/e2e.ts +19 -1
  32. package/repo-e2e/scripts/managedTest.test.ts +389 -0
  33. package/repo-e2e/scripts/managedTest.ts +210 -0
  34. package/repo-e2e/scripts/managedTestImports.test.ts +74 -0
  35. package/repo-e2e/scripts/managedTestImports.ts +110 -0
  36. package/repo-e2e/scripts/runE2e.ts +12 -1
  37. package/repo-e2e/scripts/startE2eVona.ts +37 -5
  38. package/repo-e2e/specs/a-commerce.spec.ts +1 -1
  39. package/repo-e2e/specs/cabloy-basic.spec.ts +1 -1
  40. package/repo-e2e/specs/controller-boundary.spec.ts +1 -1
  41. package/repo-e2e/specs/home-user-account.spec.ts +2 -1
  42. package/repo-e2e/specs/markdown.spec.ts +1 -1
  43. package/repo-e2e/specs/routed-dialog.spec.ts +1 -1
  44. package/scripts/runIsolatedTest.test.ts +426 -0
  45. package/scripts/runIsolatedTest.ts +454 -0
  46. package/scripts/testClaimCleanup.test.ts +346 -0
  47. package/scripts/testClaimCleanup.ts +375 -0
  48. package/scripts/testClaimProcesses.test.ts +106 -0
  49. package/scripts/testClaimProcesses.ts +129 -0
  50. package/scripts/testProcessGuardian.ts +163 -0
  51. package/scripts/upgrade.ts +1 -16
  52. package/vona/oxfmt.config.ts +2 -0
  53. package/vona/oxlint.config.ts +2 -0
  54. package/vona/package.original.json +2 -2
  55. package/vona/packages-cli/cli/package.json +1 -1
  56. package/vona/packages-cli/cli/src/play.test.ts +37 -0
  57. package/vona/packages-cli/cli/src/play.ts +23 -1
  58. package/vona/packages-cli/cli-set-api/package.json +1 -1
  59. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dbReset.ts +16 -7
  60. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +13 -7
  61. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.play.ts +5 -1
  62. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.test.ts +12 -7
  63. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/configUtils.ts +21 -2
  64. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/generateVonaMeta.ts +10 -0
  65. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.test.ts +153 -0
  66. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.ts +141 -0
  67. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.test.ts +148 -0
  68. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.ts +127 -0
  69. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.test.ts +47 -0
  70. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.ts +139 -0
  71. package/vona/packages-cli/cli-set-api/src/lib/utils.ts +2 -3
  72. package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_project.json +1 -0
  73. package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_source.json +1 -0
  74. package/vona/packages-cli/cli-set-api/test/binDevMode.test.ts +18 -0
  75. package/vona/packages-cli/cli-set-api/toolsIsolate/dbReset.ts +15 -2
  76. package/vona/packages-cli/cli-set-api/tsconfig.build.json +2 -1
  77. package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
  78. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.test.ts +16 -0
  79. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.ts +3 -2
  80. package/vona/packages-vona/vona/package.json +1 -1
  81. package/vona/packages-vona/vona-core/package.json +1 -1
  82. package/vona/packages-vona/vona-core/src/lib/addon/sqlite3.ts +1 -1
  83. package/vona/packages-vona/vona-core/src/lib/core/config.ts +44 -6
  84. package/vona/packages-vona/vona-core/src/lib/core/configCleanup.test.ts +40 -0
  85. package/vona/packages-vona/vona-core/src/lib/core/index.ts +3 -0
  86. package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.test.ts +301 -0
  87. package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.ts +312 -0
  88. package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +577 -0
  89. package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +985 -0
  90. package/vona/packages-vona/vona-core/src/lib/core/testIsolation.ts +129 -0
  91. package/vona/packages-vona/vona-core/src/lib/framework/createApp.ts +2 -0
  92. package/vona/packages-vona/vona-core/src/lib/framework/start.ts +4 -0
  93. package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +5 -1
  94. package/vona/packages-vona/vona-core/src/types/application/app.ts +2 -0
  95. package/vona/packages-vona/vona-core/src/types/utils/env.ts +1 -0
  96. package/vona/packages-vona/vona-mock/package.json +1 -1
  97. package/vona/pnpm-lock.yaml +40 -40
  98. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentIndexes.test.ts +73 -10
  99. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  100. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  101. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  102. package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
  103. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +15 -1
  104. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +24 -0
  105. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +11 -0
  106. package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
  107. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +15 -0
  108. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  109. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/extend/schemaBuilder.ts +11 -4
  110. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/databaseClient_.ts +6 -1
  111. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/package.json +1 -1
  112. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.betterSqlite3.ts +41 -1
  113. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.mysql.ts +4 -1
  114. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.pg.ts +2 -1
  115. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  116. package/vona/src/suite-vendor/a-vona/modules/a-redis/package.json +1 -1
  117. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/lib/redis.ts +8 -0
  118. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redis.ts +23 -6
  119. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redisClient_.ts +18 -1
  120. package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
  121. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +43 -15
  122. package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
  123. package/vona/src/suite-vendor/a-vona/modules/a-version/src/lib/databaseTestName.ts +3 -37
  124. package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +93 -15
  125. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  126. package/vona/tsconfig.json +1 -0
  127. package/scripts/initTestData.ts +0 -25
@@ -0,0 +1,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]];
@@ -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 like '${databasePrefix}%'`,
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,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-permission",
3
- "version": "5.1.24",
3
+ "version": "5.1.26",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-redis",
3
- "version": "5.1.15",
3
+ "version": "5.1.17",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -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
- `${getRedisClientKeyPrefix('bull', app)}*`,
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
- `${getRedisClientKeyPrefix('redlock', app)}*`,
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), `${keyPrefix}*`);
34
+ await this._clearRedisKeys(app.bean.redis.get(clientName), keyPrefix!);
33
35
  }
34
36
  }
35
37
  }
36
38
 
37
- private async _clearRedisKeys(redis: Redis, pattern: string) {
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
- const keys = await redis.keys(pattern);
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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-startup",
3
- "version": "5.1.16",
3
+ "version": "5.1.18",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -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 { BeanBase, cast, getRuntimePathPhysicalRoot } from 'vona';
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
- await this.runStartup(startup.name, null);
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
- // version seed
56
- if (this.app.meta.isTest) {
57
- const instanceName = '';
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: false,
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,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-version",
3
- "version": "5.1.17",
3
+ "version": "5.1.19",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona-module-a-version",
6
6
  "keywords": [
@@ -1,21 +1,8 @@
1
- import { createHash } from 'node:crypto';
1
+ import { getDatabaseTestPrefix } from 'vona';
2
2
 
3
- const DATABASE_NAME_MAX_BYTES = 63;
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
- const appHead = truncateUtf8(appName, APP_NAME_MAX_BYTES);
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
- }