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,153 @@
1
+ import type { TestClaimManifest } from 'vona-core';
2
+
3
+ import assert from 'node:assert/strict';
4
+ import { test } from 'node:test';
5
+ import {
6
+ getTestClaimCreatedAt,
7
+ getTestClaimPaths,
8
+ getTestClaimRedisPrefix,
9
+ getDatabaseTestPrefix,
10
+ getTestAppName,
11
+ } from 'vona-core';
12
+
13
+ import type { TestCleanupConfigDependencies } from './resolveTestCleanupConfig.ts';
14
+
15
+ import { resolveTestCleanupConfig } from './resolveTestCleanupConfig.ts';
16
+
17
+ const runId = '20261011010203-deadbeef';
18
+ const projectPath = '/canonical-checkout/vona';
19
+ function fixture() {
20
+ const prefix = getDatabaseTestPrefix(getTestAppName(runId), 'share');
21
+ const paths = getTestClaimPaths(projectPath, runId);
22
+ const claim: TestClaimManifest = {
23
+ version: 1,
24
+ runId,
25
+ projectPath,
26
+ createdAt: getTestClaimCreatedAt(runId),
27
+ kind: 'unit',
28
+ owner: { pid: 1, startTime: 'fake', host: 'fake' },
29
+ status: 'failed',
30
+ locals: [
31
+ { kind: 'generated', path: paths.generatedRoot, stage: 'owned' },
32
+ { kind: 'data', path: paths.dataRoot, stage: 'owned' },
33
+ ],
34
+ databases: [
35
+ {
36
+ clientName: 'pg',
37
+ scope: 'share',
38
+ prefix,
39
+ endpoint: {
40
+ dialect: 'pg',
41
+ transport: 'tcp',
42
+ host: 'override-db',
43
+ port: 6543,
44
+ tls: false,
45
+ adminDatabase: 'postgres',
46
+ },
47
+ creations: [{ name: `${prefix}20261011-010203`, stage: 'intent' }],
48
+ },
49
+ ],
50
+ redis: [
51
+ {
52
+ clientName: 'cache',
53
+ ...getTestClaimRedisPrefix(runId, 'cache')!,
54
+ endpoint: { transport: 'tcp', host: 'override-redis', port: 6380, tls: false, db: 4 },
55
+ released: false,
56
+ },
57
+ ],
58
+ processes: [],
59
+ cleanup: { processes: false, databases: false, redis: false, locals: false },
60
+ };
61
+ const base = {
62
+ meta: { mode: 'test', flavor: 'normal' },
63
+ database: {
64
+ testDatabase: true,
65
+ defaultClient: 'pg',
66
+ base: { connection: { host: 'base-db', port: 5432, password: 'memory-only-secret' } },
67
+ clients: { pg: { client: 'pg', connection: { database: 'postgres', user: 'user' } } },
68
+ },
69
+ redis: {
70
+ base: { host: 'base-redis', port: 6379, db: 0, password: 'memory-only-secret' },
71
+ clients: { cache: { keyPrefix: true } },
72
+ },
73
+ instance: { instances: {} },
74
+ };
75
+ const override = {
76
+ database: { clients: { pg: { connection: { host: 'override-db', port: 6543 } } } },
77
+ redis: { clients: { cache: { host: 'override-redis', port: 6380, db: 4 } } },
78
+ };
79
+ const env = {
80
+ NODE_ENV: 'test' as const,
81
+ META_MODE: 'test' as const,
82
+ META_FLAVOR: 'normal' as const,
83
+ VONA_TEST_RUN_ID: runId,
84
+ APP_NAME: getTestAppName(runId),
85
+ };
86
+ const evaluated: string[] = [];
87
+ const deps: TestCleanupConfigDependencies = {
88
+ loadEnvs: () => env,
89
+ files: ['default', 'test', 'local'],
90
+ async loadConfig(file) {
91
+ return {
92
+ default: async () => {
93
+ evaluated.push(file);
94
+ return file === 'default' ? base : file === 'local' ? override : {};
95
+ },
96
+ };
97
+ },
98
+ createApp: currentEnv =>
99
+ ({
100
+ options: { projectPath, configResolutionOnly: true },
101
+ projectPath,
102
+ name: currentEnv.APP_NAME,
103
+ meta: { env: currentEnv },
104
+ }) as any,
105
+ };
106
+ return { claim, deps, base, override, env, evaluated };
107
+ }
108
+
109
+ test('current source config evaluation and ordered deep precedence, credentials stay memory-only', async () => {
110
+ const f = fixture();
111
+ const resolved = await resolveTestCleanupConfig(f.claim, f.deps);
112
+ assert.deepEqual(f.evaluated, ['default', 'test', 'local']);
113
+ assert.equal(resolved.databaseOptions.get('pg')!.connection.host, 'override-db');
114
+ assert.equal(resolved.databaseOptions.get('pg')!.connection.password, 'memory-only-secret');
115
+ assert.equal(resolved.redisOptions.get('cache')!.host, 'override-redis');
116
+ assert.equal(resolved.redisOptions.get('cache')!.keyPrefix, undefined);
117
+ assert.ok(!JSON.stringify(f.claim).includes('memory-only-secret'));
118
+ });
119
+
120
+ test('current configuration drift and unsupported targets fail before any driver access', async () => {
121
+ for (const mutate of [
122
+ (f: ReturnType<typeof fixture>) => {
123
+ f.override.database.clients.pg.connection.host = 'different';
124
+ },
125
+ (f: ReturnType<typeof fixture>) => {
126
+ f.override.redis.clients.cache.db = 5;
127
+ },
128
+ (f: ReturnType<typeof fixture>) => {
129
+ f.base.database.clients.pg.connection.database = 'development';
130
+ },
131
+ (f: ReturnType<typeof fixture>) => {
132
+ f.base.redis.clients.cache.keyPrefix = false as any;
133
+ },
134
+ (f: ReturnType<typeof fixture>) => {
135
+ (f.base.database.clients.pg.connection as any).connectionString = 'secret://different';
136
+ },
137
+ (f: ReturnType<typeof fixture>) => {
138
+ (f.base.redis.clients.cache as any).sentinels = [];
139
+ },
140
+ ]) {
141
+ const f = fixture();
142
+ mutate(f);
143
+ await assert.rejects(resolveTestCleanupConfig(f.claim, f.deps));
144
+ }
145
+ });
146
+
147
+ test('cleanup source-only resolution rejects wrong identity and does not call lifecycle loaders', async () => {
148
+ const f = fixture();
149
+ const resolved = await resolveTestCleanupConfig(f.claim, f.deps);
150
+ assert.equal(resolved.databaseOptions.size, 1);
151
+ f.env.APP_NAME = 'development';
152
+ await assert.rejects(resolveTestCleanupConfig(f.claim, f.deps), /identity mismatch/);
153
+ });
@@ -0,0 +1,141 @@
1
+ import type { TestClaimManifest, VonaConfig, VonaConfigEnv } from 'vona-core';
2
+
3
+ import { getEnvFiles } from '@cabloy/dotenv';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { isDeepStrictEqual } from 'node:util';
7
+ import {
8
+ VonaApplication,
9
+ deepExtend,
10
+ getTestAppName,
11
+ getTestClaimRedisPrefix,
12
+ mergeTestClaimDatabaseOptions,
13
+ mergeTestClaimRedisOptions,
14
+ normalizeTestClaimDatabaseEndpoint,
15
+ normalizeTestClaimRedisEndpoint,
16
+ getDatabaseTestPrefix,
17
+ assertDisposableDatabaseName,
18
+ } from 'vona-core';
19
+
20
+ import { createConfigUtils } from './configUtils.ts';
21
+
22
+ export interface TestCleanupResolvedConfig {
23
+ databaseOptions: Map<string, Record<string, any>>;
24
+ redisOptions: Map<string, Record<string, any>>;
25
+ }
26
+ export interface TestCleanupConfigDependencies {
27
+ loadEnvs(): NodeJS.ProcessEnv;
28
+ files: string[];
29
+ loadConfig(file: string): Promise<{ default: (app: VonaApplication) => unknown }>;
30
+ createApp(env: NodeJS.ProcessEnv): VonaApplication;
31
+ }
32
+
33
+ export async function resolveTestCleanupConfig(
34
+ claim: TestClaimManifest,
35
+ injected?: TestCleanupConfigDependencies,
36
+ ): Promise<TestCleanupResolvedConfig> {
37
+ const meta = { flavor: 'normal' as const, mode: 'test' as const };
38
+ const deps = injected ?? {
39
+ loadEnvs: createConfigUtils(meta as any, {
40
+ appDir: claim.projectPath,
41
+ runtimeDir: '.unused-cleanup',
42
+ }).loadEnvs,
43
+ files: getEnvFiles(
44
+ { ...meta, local: 'local' },
45
+ path.join(claim.projectPath, 'src/backend/config/config'),
46
+ 'config',
47
+ ['.ts', '.tsx'],
48
+ )!,
49
+ loadConfig: (file: string) => import(pathToFileURL(file).href),
50
+ createApp: (env: NodeJS.ProcessEnv) =>
51
+ new VonaApplication({
52
+ name: getTestAppName(claim.runId),
53
+ projectPath: claim.projectPath,
54
+ env: env as unknown as VonaConfigEnv,
55
+ configResolutionOnly: true,
56
+ config: async () => {
57
+ throw new Error('Cleanup config is source-only');
58
+ },
59
+ modulesMeta: async () => {
60
+ throw new Error('Cleanup never loads modules');
61
+ },
62
+ locales: async () => {
63
+ throw new Error('Cleanup never loads locales');
64
+ },
65
+ }),
66
+ };
67
+ const env = deps.loadEnvs();
68
+ if (
69
+ env.META_MODE !== 'test' ||
70
+ env.VONA_TEST_RUN_ID !== claim.runId ||
71
+ env.APP_NAME !== getTestAppName(claim.runId)
72
+ ) {
73
+ throw new Error('Cleanup config identity mismatch');
74
+ }
75
+ const app = deps.createApp(env);
76
+ const loaders = await Promise.all(deps.files.map(file => deps.loadConfig(file)));
77
+ // Exactly Start's concurrent evaluation and ordered deep merge, without Start,
78
+ // createGeneralApp/createApp, module loading, listeners, migrations or seeds.
79
+ const items = await Promise.all(loaders.map(loader => loader.default(app)));
80
+ const config: Record<string, any> = {};
81
+ for (const item of items) {
82
+ if (item) deepExtend(config, item);
83
+ }
84
+ app.config = config as VonaConfig;
85
+ if (
86
+ config.meta?.mode !== 'test' ||
87
+ config.meta?.flavor !== 'normal' ||
88
+ config.database?.testDatabase !== true
89
+ ) {
90
+ throw new Error('Unsupported cleanup config mode');
91
+ }
92
+ const databaseOptions = new Map<string, Record<string, any>>();
93
+ const redisOptions = new Map<string, Record<string, any>>();
94
+ for (const database of claim.databases) {
95
+ const client = config.database.clients?.[database.clientName];
96
+ if (!client) throw new Error('Cleanup database client missing');
97
+ // Built-in dialect defaults only shape pools/afterCreate, never endpoints.
98
+ // Admin cleanup uses a dedicated bounded pool instead of application hooks.
99
+ const options = mergeTestClaimDatabaseOptions(config.database.base, undefined, client);
100
+ const endpoint = normalizeTestClaimDatabaseEndpoint(options, claim.projectPath, claim.runId);
101
+ if (!isDeepStrictEqual(endpoint, database.endpoint))
102
+ throw new Error('Cleanup database endpoint drift');
103
+ if (database.prefix !== getDatabaseTestPrefix(getTestAppName(claim.runId), database.scope))
104
+ throw new Error('Cleanup database namespace drift');
105
+ if (database.scope !== 'share') {
106
+ const instance = config.instance?.instances?.[database.scope.slice('isolate-'.length)];
107
+ if (!instance?.isolate || instance.isolateClient !== database.clientName)
108
+ throw new Error('Cleanup database scope drift');
109
+ } else if (config.database.defaultClient !== database.clientName) {
110
+ throw new Error('Cleanup default database client drift');
111
+ }
112
+ if (endpoint.dialect !== 'better-sqlite3') {
113
+ if (options.connection.database !== endpoint.adminDatabase)
114
+ throw new Error('Cleanup administrative target drift');
115
+ for (const creation of database.creations)
116
+ assertDisposableDatabaseName(creation.name, database.prefix);
117
+ }
118
+ databaseOptions.set(database.clientName, options);
119
+ }
120
+ for (const resource of claim.redis) {
121
+ const client = config.redis?.clients?.[resource.clientName];
122
+ if (!client || config.redis.base?.keyPrefix !== undefined)
123
+ throw new Error('Unsupported cleanup Redis prefix');
124
+ const options = mergeTestClaimRedisOptions(config.redis.base, client);
125
+ const endpoint = normalizeTestClaimRedisEndpoint(options);
126
+ const owned = getTestClaimRedisPrefix(claim.runId, resource.clientName);
127
+ if (
128
+ !isDeepStrictEqual(endpoint, resource.endpoint) ||
129
+ !owned ||
130
+ owned.prefix !== resource.prefix ||
131
+ owned.kind !== resource.kind
132
+ ) {
133
+ throw new Error('Cleanup Redis endpoint or namespace drift');
134
+ }
135
+ if (resource.kind === 'client' ? options.keyPrefix !== true : options.keyPrefix !== undefined)
136
+ throw new Error('Cleanup Redis prefix drift');
137
+ delete options.keyPrefix;
138
+ redisOptions.set(resource.clientName, options);
139
+ }
140
+ return { databaseOptions, redisOptions };
141
+ }
@@ -0,0 +1,148 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import {
5
+ clearExactRedisPrefix,
6
+ dropExactCleanupDatabase,
7
+ escapeRedisCleanupPattern,
8
+ quoteCleanupDatabase,
9
+ } from './testCleanupAdapters.ts';
10
+
11
+ const validate = (value: string) => {
12
+ if (!value.startsWith('owned:')) throw new Error('foreign');
13
+ };
14
+ function redis(keys: string[], error?: Error) {
15
+ const deleted: string[][] = [];
16
+ const fallback: string[][] = [];
17
+ const patterns: string[] = [];
18
+ let closed = false;
19
+ return {
20
+ deleted,
21
+ fallback,
22
+ patterns,
23
+ get closed() {
24
+ return closed;
25
+ },
26
+ connection: {
27
+ async scan(
28
+ _cursor: string,
29
+ _match: 'MATCH',
30
+ pattern: string,
31
+ _count: 'COUNT',
32
+ _size: number,
33
+ ): Promise<[string, string[]]> {
34
+ patterns.push(pattern);
35
+ return ['0', keys];
36
+ },
37
+ async unlink(...batch: string[]) {
38
+ if (error) throw error;
39
+ deleted.push(batch);
40
+ },
41
+ async del(...batch: string[]) {
42
+ fallback.push(batch);
43
+ },
44
+ disconnect() {
45
+ closed = true;
46
+ },
47
+ },
48
+ };
49
+ }
50
+
51
+ test('database cleanup quotes exact identifiers, has bounded PG locks, and always closes', async () => {
52
+ assert.equal(quoteCleanupDatabase('a"b', 'pg'), '"a""b"');
53
+ assert.equal(quoteCleanupDatabase('a`b', 'mysql2'), '`a``b`');
54
+ assert.throws(() => quoteCleanupDatabase('x', 'unsupported'));
55
+ const statements: string[] = [];
56
+ let closed = false;
57
+ await dropExactCleanupDatabase(
58
+ {
59
+ async raw(sql) {
60
+ statements.push(sql);
61
+ },
62
+ async destroy() {
63
+ closed = true;
64
+ },
65
+ },
66
+ 'pg',
67
+ 'owned:exact',
68
+ validate,
69
+ );
70
+ assert.equal(statements.at(-1), 'DROP DATABASE IF EXISTS "owned:exact"');
71
+ assert.equal(statements.length, 3);
72
+ assert.ok(closed);
73
+ assert.ok(statements.every(sql => !/FORCE|terminate|LIKE|pg_database/i.test(sql)));
74
+ await assert.rejects(
75
+ dropExactCleanupDatabase(
76
+ {
77
+ async raw() {
78
+ throw new Error('in use');
79
+ },
80
+ async destroy() {
81
+ closed = true;
82
+ },
83
+ },
84
+ 'mysql2',
85
+ 'owned:exact',
86
+ validate,
87
+ ),
88
+ /in use/,
89
+ );
90
+ });
91
+
92
+ test('Redis scans escaped literal prefixes, deletes bounded batches and leaves development sentinel', async () => {
93
+ const sentinel = 'dev:important';
94
+ const fake = redis(['owned:[x]:1', 'owned:[x]:2', 'owned:[x]:3']);
95
+ await clearExactRedisPrefix(fake.connection, 'owned:[x]:', validate, { batchSize: 2 });
96
+ assert.equal(escapeRedisCleanupPattern('a*?[x]\\'), 'a\\*\\?\\[x\\]\\\\');
97
+ assert.deepEqual(
98
+ fake.deleted.map(batch => batch.length),
99
+ [2, 1],
100
+ );
101
+ assert.deepEqual(fake.patterns, ['owned:\\[x\\]:*']);
102
+ assert.ok(!fake.deleted.flat().includes(sentinel));
103
+ assert.ok(fake.closed);
104
+ });
105
+
106
+ test('Redis rejects a foreign key before deleting any batch', async () => {
107
+ const fake = redis(['owned:1', 'development:sentinel']);
108
+ await assert.rejects(
109
+ clearExactRedisPrefix(fake.connection, 'owned:', validate, { batchSize: 1 }),
110
+ /foreign key/,
111
+ );
112
+ assert.deepEqual(fake.deleted, []);
113
+ assert.ok(fake.closed);
114
+ });
115
+
116
+ test('Redis only falls back for missing UNLINK, never ACL or network errors', async () => {
117
+ const old = redis(
118
+ ['owned:1'],
119
+ new Error("ERR unknown command 'UNLINK', with args beginning with:"),
120
+ );
121
+ await clearExactRedisPrefix(old.connection, 'owned:', validate);
122
+ assert.deepEqual(old.fallback, [['owned:1']]);
123
+ for (const message of ['NOPERM UNLINK denied', 'connection reset', 'ERR unrelated error']) {
124
+ const fake = redis(['owned:1'], new Error(message));
125
+ await assert.rejects(clearExactRedisPrefix(fake.connection, 'owned:', validate));
126
+ assert.deepEqual(fake.fallback, []);
127
+ assert.ok(fake.closed);
128
+ }
129
+ });
130
+
131
+ test('Redis scan loops have a finite bound', async () => {
132
+ let closed = false;
133
+ const connection = {
134
+ async scan(): Promise<[string, string[]]> {
135
+ return ['1', []];
136
+ },
137
+ async unlink() {},
138
+ async del() {},
139
+ disconnect() {
140
+ closed = true;
141
+ },
142
+ };
143
+ await assert.rejects(
144
+ clearExactRedisPrefix(connection, 'owned:', validate, { maxScans: 2 }),
145
+ /limit exceeded/,
146
+ );
147
+ assert.ok(closed);
148
+ });
@@ -0,0 +1,127 @@
1
+ // Cleanup primitives deliberately have no framework startup or connection dependencies.
2
+ export interface CleanupSqlConnection {
3
+ raw(sql: string): Promise<unknown>;
4
+ destroy(): Promise<unknown>;
5
+ }
6
+
7
+ export interface CleanupRedisConnection {
8
+ scan(
9
+ cursor: string,
10
+ match: 'MATCH',
11
+ pattern: string,
12
+ count: 'COUNT',
13
+ size: number,
14
+ ): Promise<[string, string[]]>;
15
+ unlink(...keys: string[]): Promise<unknown>;
16
+ del(...keys: string[]): Promise<unknown>;
17
+ disconnect(): unknown;
18
+ }
19
+
20
+ export function quoteCleanupDatabase(name: string, dialect: string): string {
21
+ if (typeof name !== 'string' || !name || name.includes('\0')) {
22
+ throw new Error('Invalid cleanup database identifier');
23
+ }
24
+ if (dialect === 'pg') return `"${name.replaceAll('"', '""')}"`;
25
+ if (dialect === 'mysql' || dialect === 'mysql2') return `\`${name.replaceAll('`', '``')}\``;
26
+ throw new Error('Unsupported cleanup database dialect');
27
+ }
28
+
29
+ export async function dropExactCleanupDatabase(
30
+ connection: CleanupSqlConnection,
31
+ dialect: string,
32
+ name: string,
33
+ validate: (name: string) => void,
34
+ ) {
35
+ // Never discover databases, terminate sessions, or use FORCE. IF EXISTS is the only
36
+ // absent-resource accommodation; authentication and in-use failures propagate.
37
+ try {
38
+ validate(name);
39
+ const identifier = quoteCleanupDatabase(name, dialect);
40
+ if (dialect === 'pg') {
41
+ await connection.raw("SET statement_timeout = '10000ms'");
42
+ await connection.raw("SET lock_timeout = '3000ms'");
43
+ }
44
+ await connection.raw(`DROP DATABASE IF EXISTS ${identifier}`);
45
+ } finally {
46
+ await connection.destroy();
47
+ }
48
+ }
49
+
50
+ export function escapeRedisCleanupPattern(prefix: string): string {
51
+ return prefix.replace(/[\\*?[\]]/g, '\\$&');
52
+ }
53
+
54
+ function isUnsupportedUnlink(error: unknown): boolean {
55
+ // Redis <4 can lack UNLINK. ACL, network, timeout and unrelated command errors
56
+ // are never reasons to retry a destructive command using a different method.
57
+ return (
58
+ error instanceof Error && /^ERR unknown command ['"]?UNLINK['"]?(?:,|\s|$)/i.test(error.message)
59
+ );
60
+ }
61
+
62
+ export async function clearExactRedisPrefix(
63
+ connection: CleanupRedisConnection,
64
+ prefix: string,
65
+ validate: (prefix: string) => void,
66
+ options: { batchSize?: number; maxScans?: number } = {},
67
+ ) {
68
+ const batchSize = options.batchSize ?? 128;
69
+ const maxScans = options.maxScans ?? 100000;
70
+ let useDel = false;
71
+ let scans = 0;
72
+ let cursor = '0';
73
+ try {
74
+ validate(prefix);
75
+ if (
76
+ !Number.isSafeInteger(batchSize) ||
77
+ batchSize <= 0 ||
78
+ batchSize > 1024 ||
79
+ !Number.isSafeInteger(maxScans) ||
80
+ maxScans <= 0
81
+ ) {
82
+ throw new Error('Invalid Redis cleanup bounds');
83
+ }
84
+ do {
85
+ if (++scans > maxScans) throw new Error('Redis cleanup scan limit exceeded');
86
+ const result = await connection.scan(
87
+ cursor,
88
+ 'MATCH',
89
+ `${escapeRedisCleanupPattern(prefix)}*`,
90
+ 'COUNT',
91
+ batchSize,
92
+ );
93
+ if (
94
+ !Array.isArray(result) ||
95
+ result.length !== 2 ||
96
+ typeof result[0] !== 'string' ||
97
+ !/^\d+$/.test(result[0]) ||
98
+ !Array.isArray(result[1])
99
+ ) {
100
+ throw new Error('Invalid Redis cleanup scan response');
101
+ }
102
+ cursor = result[0];
103
+ const keys = result[1];
104
+ // Validate the entire response before the first deletion, not just each
105
+ // batch. A server response outside the literal namespace is a hard stop.
106
+ if (keys.some(key => typeof key !== 'string' || !key.startsWith(prefix))) {
107
+ throw new Error('Redis cleanup scan returned a foreign key');
108
+ }
109
+ for (let offset = 0; offset < keys.length; offset += batchSize) {
110
+ const batch = keys.slice(offset, offset + batchSize);
111
+ if (useDel) {
112
+ await connection.del(...batch);
113
+ } else {
114
+ try {
115
+ await connection.unlink(...batch);
116
+ } catch (error) {
117
+ if (!isUnsupportedUnlink(error)) throw error;
118
+ useDel = true;
119
+ await connection.del(...batch);
120
+ }
121
+ }
122
+ }
123
+ } while (cursor !== '0');
124
+ } finally {
125
+ connection.disconnect();
126
+ }
127
+ }
@@ -0,0 +1,47 @@
1
+ import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { test } from 'node:test';
5
+ import { createTestClaim, getTestClaimRedisPrefix, writeTestClaim } from 'vona-core';
6
+
7
+ import { prepareCleanupProcess } from '../../../../../../../scripts/testClaimCleanup.ts';
8
+
9
+ // A fake source config exercises child registration/current resolution/IPC but
10
+ // never opens a database, Redis connection, listener, migration or seed.
11
+ test('cleanup helper launches via process.execPath and resolves only fake source config', async () => {
12
+ const project = await mkdtemp(path.join(await realpath(os.tmpdir()), 'cleanup-helper-'));
13
+ const runId = '20261011010203-deadbeef';
14
+ try {
15
+ const configDir = path.join(project, 'src/backend/config/config');
16
+ await mkdir(configDir, { recursive: true });
17
+ await writeFile(path.join(project, 'package.json'), '{"type":"module"}');
18
+ await writeFile(
19
+ path.join(project, 'tsconfig.json'),
20
+ '{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext","target":"ESNext","allowImportingTsExtensions":true},"ts-node":{"transpileOnly":true}}',
21
+ );
22
+ await writeFile(
23
+ path.join(configDir, 'config.ts'),
24
+ `export default async function(app) {
25
+ if (app.server || !app.options.configResolutionOnly) throw new Error('Started application');
26
+ await app.options.modulesMeta().then(() => { throw new Error('Module loading permitted'); }, () => {});
27
+ return { meta: { mode: 'test', flavor: 'normal' }, database: { testDatabase: true }, redis: { base: { host: '127.0.0.1', port: 6379, db: 0 }, clients: { cache: { keyPrefix: true } } } };
28
+ }`,
29
+ );
30
+ const { claimPath, manifest } = await createTestClaim(project, runId, 'unit', {
31
+ pid: process.pid,
32
+ startTime: 'fake',
33
+ host: os.hostname(),
34
+ });
35
+ manifest.redis.push({
36
+ clientName: 'cache',
37
+ ...getTestClaimRedisPrefix(runId, 'cache')!,
38
+ endpoint: { transport: 'tcp', host: '127.0.0.1', port: 6379, db: 0, tls: false },
39
+ released: false,
40
+ });
41
+ await writeTestClaim(claimPath, manifest);
42
+ const session = await prepareCleanupProcess(manifest, claimPath);
43
+ await session.close();
44
+ } finally {
45
+ await rm(project, { recursive: true, force: true });
46
+ }
47
+ });