cabloy 5.1.205 → 5.1.207

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 (34) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +24 -0
  3. package/package.json +1 -1
  4. package/repo-agent-governance/managed-assets.json +3 -3
  5. package/repo-agent-governance/policies/repository.md +1 -1
  6. package/repo-docs/backend/migration-and-changes.md +27 -8
  7. package/repo-docs/reference/repo-scripts.md +20 -1
  8. package/scripts/init.ts +15 -0
  9. package/scripts/runIsolatedTest.test.ts +21 -0
  10. package/scripts/runIsolatedTest.ts +14 -6
  11. package/vona/packages-cli/cli/package.json +1 -1
  12. package/vona/packages-cli/cli-set-api/package.json +1 -1
  13. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +17 -2
  14. package/vona/packages-cli/cli-set-api/test/binDevLifecycle.test.ts +100 -0
  15. package/vona/packages-vona/vona/package.json +1 -1
  16. package/vona/packages-vona/vona-core/package.json +1 -1
  17. package/vona/packages-vona/vona-core/src/lib/framework/cluster.ts +1 -1
  18. package/vona/packages-vona/vona-core/src/lib/framework/processMaster.test.ts +185 -0
  19. package/vona/packages-vona/vona-core/src/lib/framework/processMaster.ts +18 -7
  20. package/vona/packages-vona/vona-core/src/lib/framework/start.test.ts +127 -0
  21. package/vona/packages-vona/vona-mock/package.json +1 -1
  22. package/vona/pnpm-lock.yaml +1 -1
  23. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  24. package/vona/src/suite-vendor/a-vona/modules/a-redis/package.json +1 -1
  25. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redis.ts +77 -42
  26. package/vona/src/suite-vendor/a-vona/modules/a-redis/test/redisCleanup.test.ts +378 -0
  27. package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
  28. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +56 -9
  29. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/types/startup.ts +1 -0
  30. package/vona/src/suite-vendor/a-vona/modules/a-startup/test/devSeed.test.ts +104 -0
  31. package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
  32. package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +183 -51
  33. package/vona/src/suite-vendor/a-vona/modules/a-version/test/devDatabasePreparation.test.ts +221 -0
  34. package/vona/src/suite-vendor/a-vona/package.json +1 -1
@@ -1,10 +1,14 @@
1
+ import type { Worker } from 'node:cluster';
2
+
1
3
  import chalk from 'chalk';
2
4
  import cluster from 'node:cluster';
3
5
 
4
6
  import { useApp } from './useApp.ts';
5
7
 
6
8
  let __closing = false;
9
+ let __failed = false;
7
10
  let __timeout;
11
+ const __reloading = new WeakSet<Worker>();
8
12
 
9
13
  async function _closeInner() {
10
14
  __timeout = setTimeout(() => {
@@ -23,32 +27,39 @@ export function handleProcessMaster(workers: number) {
23
27
  });
24
28
  });
25
29
 
26
- for (let i = 0; i < workers; i++) {
27
- cluster.fork();
28
- }
29
-
30
30
  cluster.on('message', (worker, message) => {
31
31
  if (message === 'reload-worker') {
32
32
  if (!__closing) {
33
33
  cluster.fork();
34
34
  }
35
+ __reloading.add(worker);
35
36
  worker.process.kill('SIGTERM');
36
37
  }
37
38
  });
38
39
 
39
40
  cluster.on('exit', (_worker, _code, _signal) => {
40
- // console.log(`----------------- worker ${_worker.process.pid} died`, _code, _signal);
41
+ const expectedSignal =
42
+ (__closing && ['SIGINT', 'SIGUSR2', 'SIGTERM'].includes(_signal)) ||
43
+ (__reloading.has(_worker) && _signal === 'SIGTERM');
44
+ __reloading.delete(_worker);
45
+ if ((_code !== 0 || _signal) && !expectedSignal) {
46
+ __failed = true;
47
+ }
41
48
  if (cluster.workers && Object.keys(cluster.workers).length === 0) {
42
49
  clearTimeout(__timeout);
43
50
  // log
44
51
  const app = useApp();
45
52
  if (app.meta.env.LOGGER_DUMMY !== 'true') {
46
- const message = `Master shutdown gracefully: ${process.pid}`;
53
+ const message = `Master shutdown ${__failed ? 'with worker failure' : 'gracefully'}: ${process.pid}`;
47
54
  // eslint-disable-next-line
48
55
  console.log(chalk.cyan(message));
49
56
  }
50
57
  // exit
51
- process.exit(0);
58
+ process.exit(__failed ? 1 : 0);
52
59
  }
53
60
  });
61
+
62
+ for (let i = 0; i < workers; i++) {
63
+ cluster.fork();
64
+ }
54
65
  }
@@ -0,0 +1,127 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import { test } from 'node:test';
8
+
9
+ const startUrl = new URL('./start.ts', import.meta.url).href;
10
+
11
+ // Execute Start's real lifecycle with only its imported collaborators mocked. No application,
12
+ // environment files, database/Redis connections, or server listeners are created.
13
+ const lifecycleSource = `
14
+ import assert from 'node:assert/strict';
15
+ import { readFile } from 'node:fs/promises';
16
+ import { registerHooks } from 'node:module';
17
+ const startUrl = ${JSON.stringify(startUrl)};
18
+ const mocks = {
19
+ '../../types/index.ts': 'export const EnumAppEvent = { AppStartError: "error", AppStarted: "started" };',
20
+ '../core/isolationGuard.ts': 'export function assertIsolatedTestConfig() {}',
21
+ '../core/testClaim.ts': 'export function registerTestClaimResourceInventory() {}',
22
+ '../module/loader.ts': 'export class ModuleLoader { async execute() { await globalThis.phase("Load"); } }',
23
+ '../utils/util.ts': 'export function deepExtend(target, source) { Object.assign(target, source); }',
24
+ };
25
+ registerHooks({
26
+ resolve(specifier, context, nextResolve) {
27
+ if (context.parentURL === startUrl && mocks[specifier]) {
28
+ return { url: 'data:text/javascript,' + encodeURIComponent(mocks[specifier]), shortCircuit: true };
29
+ }
30
+ return nextResolve(specifier, context);
31
+ },
32
+ });
33
+ const { Start } = await import(startUrl);
34
+ const [projectPath, lockPath, lockContents, failingPhase] = process.argv.slice(1);
35
+ const phases = [];
36
+ const events = [];
37
+ const failure = new Error(failingPhase);
38
+ globalThis.phase = async phase => {
39
+ assert.equal(await readFile(lockPath, 'utf8'), lockContents);
40
+ phases.push(phase);
41
+ if (phase === failingPhase) throw failure;
42
+ };
43
+ const config = {
44
+ database: { testDatabase: true },
45
+ server: { keys: ['test-key'], subdomainOffset: 2 },
46
+ proxy: { enable: false, ipHeaders: 'X-Forwarded-For', maxIpsCount: 0 },
47
+ };
48
+ const app = {
49
+ projectPath,
50
+ name: 'app',
51
+ meta: { env: { META_MODE: 'dev' } },
52
+ options: { async config() {
53
+ await globalThis.phase('Config');
54
+ return { default: [() => config] };
55
+ } },
56
+ bean: { _newBean: Bean => new Bean() },
57
+ util: { async monkeyModule(_appMonkey, _modulesMonkey, _forward, hook) {
58
+ await globalThis.phase(hook.slice(3));
59
+ } },
60
+ emit(event, error) {
61
+ events.push({ event, error, phases: [...phases] });
62
+ },
63
+ };
64
+ const start = new Start(app);
65
+ const order = ['Config', 'Load', 'Start', 'Ready', 'Started'];
66
+ if (failingPhase) {
67
+ await assert.rejects(start.start(), error => error === failure);
68
+ assert.equal(app.meta.appStartError, failure);
69
+ assert.deepEqual(phases, order.slice(0, order.indexOf(failingPhase) + 1));
70
+ assert.equal(events.at(-1).event, 'error');
71
+ assert.equal(events.at(-1).error, failure);
72
+ assert.deepEqual(events.at(-1).phases, phases);
73
+ } else {
74
+ await start.start();
75
+ assert.deepEqual(phases, order);
76
+ assert.equal(app.meta.appStartError, undefined);
77
+ }
78
+ const reachesStarted = !failingPhase || failingPhase === 'Started';
79
+ assert.equal(app.meta.appStarted, reachesStarted ? true : undefined);
80
+ const reachesReady = reachesStarted || failingPhase === 'Ready';
81
+ assert.equal(app.meta.appReady, reachesReady ? true : undefined);
82
+ if (reachesReady) assert.deepEqual(app.meta.appReadyInstances, {});
83
+ assert.deepEqual(events.map(item => item.event), [
84
+ ...(reachesStarted ? ['started'] : []),
85
+ ...(failingPhase ? ['error'] : []),
86
+ ]);
87
+ if (reachesStarted) {
88
+ assert.deepEqual(events[0].phases, ['Config', 'Load', 'Start', 'Ready']);
89
+ }
90
+ assert.equal(await readFile(lockPath, 'utf8'), lockContents);
91
+ console.log('verified');
92
+ `;
93
+
94
+ for (const failingPhase of ['', 'Config', 'Load', 'Start', 'Ready', 'Started']) {
95
+ test(`Start ignores the legacy preparation lock on ${failingPhase ? `${failingPhase} failure` : 'success'}`, async () => {
96
+ const root = await mkdtemp(path.join(await realpath(tmpdir()), 'vona-start-'));
97
+ try {
98
+ const identity = createHash('sha256')
99
+ .update(JSON.stringify([root, 'app']))
100
+ .digest('hex');
101
+ const lockPath = path.join(root, '.app', 'dev-database-preparation', `${identity}.lock`);
102
+ const lockContents = `${JSON.stringify({ token: 'legacy-owner', pid: process.pid })}\n`;
103
+ await mkdir(path.dirname(lockPath), { recursive: true });
104
+ await writeFile(lockPath, lockContents);
105
+ const result = spawnSync(
106
+ process.execPath,
107
+ [
108
+ '--input-type=module',
109
+ '--eval',
110
+ lifecycleSource,
111
+ root,
112
+ lockPath,
113
+ lockContents,
114
+ failingPhase,
115
+ ],
116
+ { encoding: 'utf8', timeout: 5_000 },
117
+ );
118
+ assert.ifError(result.error);
119
+ assert.equal(result.signal, null, result.stderr);
120
+ assert.equal(result.status, 0, result.stderr);
121
+ assert.equal(result.stdout.trim(), 'verified');
122
+ assert.equal(await readFile(lockPath, 'utf8'), lockContents);
123
+ } finally {
124
+ await rm(root, { recursive: true, force: true });
125
+ }
126
+ });
127
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-mock",
3
- "version": "6.1.44",
3
+ "version": "6.1.45",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona mock",
6
6
  "keywords": [
@@ -1652,7 +1652,7 @@ importers:
1652
1652
  specifier: ^5.0.3
1653
1653
  version: link:modules/a-markdown
1654
1654
  vona-module-a-rbac:
1655
- specifier: ^5.0.9
1655
+ specifier: ^5.0.10
1656
1656
  version: link:modules/a-rbac
1657
1657
  vona-module-a-socket:
1658
1658
  specifier: workspace:^
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-core",
3
- "version": "5.1.58",
3
+ "version": "5.1.59",
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.17",
3
+ "version": "5.1.18",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -1,67 +1,102 @@
1
1
  import type { Redis } from 'ioredis';
2
2
 
3
- import { BeanBase, resolveTestRunId } from 'vona';
3
+ import { assertTestAppIdentity, BeanBase } from 'vona';
4
4
  import { Service } from 'vona-module-a-bean';
5
5
 
6
6
  import type { IRedisClientRecord } from '../types/redis.ts';
7
7
 
8
8
  import { getRedisClientKeyPrefix, prepareRedisClientKeyPrefix } from '../lib/redis.ts';
9
9
 
10
+ interface RedisCleanupTarget {
11
+ redis: Redis;
12
+ expectedPrefix: string;
13
+ keyPrefix: string | undefined;
14
+ }
15
+
10
16
  @Service()
11
17
  export class ServiceRedis extends BeanBase {
12
18
  async clearAllData() {
13
19
  const app = this.app;
14
- // clear keys
15
- await this._clearRedisKeys(
16
- app.bean.redis.get('queue'),
17
- getRedisClientKeyPrefix('bull', app),
18
- true,
20
+ const isolated = assertTestAppIdentity(
21
+ app.meta.env.META_MODE,
22
+ app.meta.env.VONA_TEST_RUN_ID,
23
+ app.name,
19
24
  );
20
- // broadcast channel has subscribed
21
- // await _clearRedisKeys(app.redis.get('broadcast'), `${getRedisClientKeyPrefix('broadcast', app)}*`);
22
- for (const _clientName in app.config.redis.clients) {
25
+ if (isolated && app.config.redis.base.keyPrefix !== undefined) {
26
+ throw new Error('Isolated test cannot inherit a Redis base keyPrefix');
27
+ }
28
+
29
+ // Prepare every target before looking up keys or deleting anything.
30
+ const targets = [this._prepareCleanupTarget('queue', Boolean(isolated))];
31
+ for (const _clientName of Object.keys(app.config.redis.clients)) {
23
32
  const clientName = _clientName as keyof IRedisClientRecord;
33
+ // Broadcast carries subscriptions, not cleanup keys.
24
34
  if (['queue', 'broadcast'].includes(clientName)) continue;
25
- if (clientName.startsWith('redlock')) {
26
- await this._clearRedisKeys(
27
- app.bean.redis.get(clientName),
28
- getRedisClientKeyPrefix('redlock', app),
29
- true,
30
- );
31
- } else {
32
- const client = app.config.redis.clients[clientName];
33
- const keyPrefix = prepareRedisClientKeyPrefix(client.keyPrefix, clientName, this.app);
34
- await this._clearRedisKeys(app.bean.redis.get(clientName), keyPrefix!);
35
+ targets.push(this._prepareCleanupTarget(clientName, Boolean(isolated)));
36
+ }
37
+
38
+ // KEYS returns full keys even for prefixed clients. Validate the complete
39
+ // lookup result for every target before issuing the first DEL.
40
+ const deletions: { redis: Redis; keys: string[] }[] = [];
41
+ for (const { redis, expectedPrefix, keyPrefix } of targets) {
42
+ const fullKeys = await redis.keys(`${expectedPrefix}*`);
43
+ if (
44
+ !Array.isArray(fullKeys) ||
45
+ fullKeys.some(key => typeof key !== 'string' || !key.startsWith(expectedPrefix))
46
+ ) {
47
+ throw new Error('Redis cleanup lookup returned a key outside the owned prefix');
35
48
  }
49
+ deletions.push({
50
+ redis,
51
+ keys: fullKeys.map(key => (keyPrefix ? key.substring(keyPrefix.length) : key)),
52
+ });
53
+ }
54
+ for (const { redis, keys } of deletions) {
55
+ if (keys.length > 0) await redis.del(keys);
36
56
  }
37
57
  }
38
58
 
39
- private async _clearRedisKeys(redis: Redis, expectedPrefix: string, unprefixedClient = false) {
40
- if (!redis) return;
41
- const isolated = resolveTestRunId(
42
- this.app.meta.env.META_MODE,
43
- this.app.meta.env.VONA_TEST_RUN_ID,
59
+ private _prepareCleanupTarget(
60
+ clientName: keyof IRedisClientRecord,
61
+ isolated: boolean,
62
+ ): RedisCleanupTarget {
63
+ const app = this.app;
64
+ const configClient = app.config.redis.clients[clientName];
65
+ if (!configClient) throw new Error(`Redis cleanup client is not configured: ${clientName}`);
66
+ if (isolated && ('sentinels' in configClient || 'nodes' in configClient)) {
67
+ throw new Error(`Isolated test cannot use a Redis sentinel or cluster client: ${clientName}`);
68
+ }
69
+ const unprefixedClient = clientName === 'queue' || clientName.startsWith('redlock');
70
+ const frameworkPrefix = getRedisClientKeyPrefix(
71
+ clientName === 'queue' ? 'bull' : unprefixedClient ? 'redlock' : clientName,
72
+ app,
44
73
  );
45
- const keyPrefix = redis.options.keyPrefix;
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
- }
74
+ const configuredPrefix = prepareRedisClientKeyPrefix(configClient.keyPrefix, clientName, app);
75
+ const expectedPrefix = unprefixedClient ? frameworkPrefix : configuredPrefix;
76
+ if (
77
+ !app.name ||
78
+ typeof expectedPrefix !== 'string' ||
79
+ !expectedPrefix.startsWith(frameworkPrefix) ||
80
+ !expectedPrefix.endsWith(':') ||
81
+ /[*?[\]\\]/.test(expectedPrefix) ||
82
+ Array.from(expectedPrefix).some(
83
+ char => char.charCodeAt(0) <= 32 || char.charCodeAt(0) === 127,
84
+ )
85
+ ) {
86
+ throw new Error(`Redis cleanup prefix does not belong to this app: ${clientName}`);
53
87
  }
54
- const keys = await redis.keys(`${expectedPrefix}*`);
55
- const keysDel: string[] = [];
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
- }
60
- const key = keyPrefix ? fullKey.substring(keyPrefix.length) : fullKey;
61
- keysDel.push(key);
88
+ // Custom prefixes may narrow the framework namespace, never widen it.
89
+ if (unprefixedClient && configuredPrefix !== undefined && configuredPrefix !== '') {
90
+ throw new Error(`Redis cleanup requires an unprefixed client: ${clientName}`);
62
91
  }
63
- if (keysDel.length > 0) {
64
- await redis.del(keysDel);
92
+ const redis = app.bean.redis.get(clientName);
93
+ if (!redis?.options) throw new Error(`Redis cleanup client is unavailable: ${clientName}`);
94
+ const keyPrefix = redis.options.keyPrefix;
95
+ if (
96
+ unprefixedClient ? keyPrefix !== undefined && keyPrefix !== '' : keyPrefix !== expectedPrefix
97
+ ) {
98
+ throw new Error(`Redis cleanup uses an unexpected client prefix: ${clientName}`);
65
99
  }
100
+ return { redis, expectedPrefix, keyPrefix };
66
101
  }
67
102
  }