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.
- package/.cabloy-version +1 -1
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/repo-agent-governance/managed-assets.json +3 -3
- package/repo-agent-governance/policies/repository.md +1 -1
- package/repo-docs/backend/migration-and-changes.md +27 -8
- package/repo-docs/reference/repo-scripts.md +20 -1
- package/scripts/init.ts +15 -0
- package/scripts/runIsolatedTest.test.ts +21 -0
- package/scripts/runIsolatedTest.ts +14 -6
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +17 -2
- package/vona/packages-cli/cli-set-api/test/binDevLifecycle.test.ts +100 -0
- 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/framework/cluster.ts +1 -1
- package/vona/packages-vona/vona-core/src/lib/framework/processMaster.test.ts +185 -0
- package/vona/packages-vona/vona-core/src/lib/framework/processMaster.ts +18 -7
- package/vona/packages-vona/vona-core/src/lib/framework/start.test.ts +127 -0
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-core/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/service/redis.ts +77 -42
- package/vona/src/suite-vendor/a-vona/modules/a-redis/test/redisCleanup.test.ts +378 -0
- 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 +56 -9
- package/vona/src/suite-vendor/a-vona/modules/a-startup/src/types/startup.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-startup/test/devSeed.test.ts +104 -0
- 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/service/database.ts +183 -51
- package/vona/src/suite-vendor/a-vona/modules/a-version/test/devDatabasePreparation.test.ts +221 -0
- 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
|
-
|
|
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
|
+
}
|
package/vona/pnpm-lock.yaml
CHANGED
|
@@ -1,67 +1,102 @@
|
|
|
1
1
|
import type { Redis } from 'ioredis';
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
app.
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
}
|