cabloy 5.1.206 → 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 (31) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +14 -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/vona/packages-cli/cli/package.json +1 -1
  10. package/vona/packages-cli/cli-set-api/package.json +1 -1
  11. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +17 -2
  12. package/vona/packages-cli/cli-set-api/test/binDevLifecycle.test.ts +100 -0
  13. package/vona/packages-vona/vona/package.json +1 -1
  14. package/vona/packages-vona/vona-core/package.json +1 -1
  15. package/vona/packages-vona/vona-core/src/lib/framework/cluster.ts +1 -1
  16. package/vona/packages-vona/vona-core/src/lib/framework/processMaster.test.ts +185 -0
  17. package/vona/packages-vona/vona-core/src/lib/framework/processMaster.ts +18 -7
  18. package/vona/packages-vona/vona-core/src/lib/framework/start.test.ts +127 -0
  19. package/vona/packages-vona/vona-mock/package.json +1 -1
  20. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  21. package/vona/src/suite-vendor/a-vona/modules/a-redis/package.json +1 -1
  22. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redis.ts +77 -42
  23. package/vona/src/suite-vendor/a-vona/modules/a-redis/test/redisCleanup.test.ts +378 -0
  24. package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
  25. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +56 -9
  26. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/types/startup.ts +1 -0
  27. package/vona/src/suite-vendor/a-vona/modules/a-startup/test/devSeed.test.ts +104 -0
  28. package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
  29. package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +183 -51
  30. package/vona/src/suite-vendor/a-vona/modules/a-version/test/devDatabasePreparation.test.ts +221 -0
  31. package/vona/src/suite-vendor/a-vona/package.json +1 -1
@@ -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": [
@@ -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
  }
@@ -0,0 +1,378 @@
1
+ import type { VonaApplication } from 'vona';
2
+
3
+ import assert from 'node:assert/strict';
4
+ import { execFile } from 'node:child_process';
5
+ import { registerHooks } from 'node:module';
6
+ import { describe, it } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { promisify } from 'node:util';
9
+
10
+ import { getRedisClientKeyPrefix } from '../src/lib/redis.ts';
11
+
12
+ // Only bypass bean registration; exercise the real service and prefix helpers
13
+ // with object mocks, without loading an app or connecting to Redis.
14
+ const serviceUrl = new URL('../src/service/redis.ts', import.meta.url).href;
15
+ const hooks = registerHooks({
16
+ resolve(specifier, context, nextResolve) {
17
+ if (context.parentURL === serviceUrl && specifier === 'vona-module-a-bean') {
18
+ return {
19
+ url: 'data:text/javascript,export function Service() { return () => {}; }',
20
+ shortCircuit: true,
21
+ };
22
+ }
23
+ return nextResolve(specifier, context);
24
+ },
25
+ });
26
+ const { ServiceRedis } = await import('../src/service/redis.ts');
27
+ hooks.deregister();
28
+
29
+ const runId = '20261014010203-a1b2c3d4';
30
+ const prefixedClients = ['default', 'cache', 'io', 'summer', 'model', 'worker', 'limiter'];
31
+ const clientNames = ['queue', 'broadcast', 'redlock', 'redlockPeer', ...prefixedClients];
32
+
33
+ type ClientConfig = {
34
+ keyPrefix?: string | true;
35
+ sentinels?: unknown[];
36
+ nodes?: unknown[];
37
+ };
38
+
39
+ function createFixture(mode = 'dev', isolated = false) {
40
+ const name = isolated ? `cabtest${runId}` : 'cleanup-app';
41
+ const config = {
42
+ meta: { mode },
43
+ redis: {
44
+ base: {} as { keyPrefix?: string },
45
+ clients: Object.fromEntries(
46
+ clientNames.map(clientName => [
47
+ clientName,
48
+ prefixedClients.includes(clientName) ? { keyPrefix: true } : {},
49
+ ]),
50
+ ) as Record<string, ClientConfig>,
51
+ },
52
+ };
53
+ const calls: { clientName: string; command: string; keys: string[] }[] = [];
54
+ const store = new Set<string>();
55
+ const clients: Record<
56
+ string,
57
+ {
58
+ options: { keyPrefix?: string };
59
+ keys: (pattern: string) => Promise<string[]>;
60
+ del: (keys: string[]) => Promise<number>;
61
+ }
62
+ > = {};
63
+ const app = {
64
+ name,
65
+ meta: { env: { META_MODE: mode, VONA_TEST_RUN_ID: isolated ? runId : undefined } },
66
+ config,
67
+ bean: {
68
+ redis: {
69
+ get(clientName: string) {
70
+ if (clientName === 'broadcast') {
71
+ throw new Error('Cleanup must not access the subscribed broadcast client');
72
+ }
73
+ return clients[clientName];
74
+ },
75
+ },
76
+ },
77
+ } as unknown as VonaApplication;
78
+ const prefix = (clientName: string) => getRedisClientKeyPrefix(clientName, app);
79
+ for (const clientName of clientNames) {
80
+ const keyPrefix = prefixedClients.includes(clientName) ? prefix(clientName) : undefined;
81
+ const client = {
82
+ options: { keyPrefix },
83
+ async keys(pattern: string) {
84
+ calls.push({ clientName, command: 'keys', keys: [pattern] });
85
+ assert.equal(pattern.endsWith('*'), true);
86
+ assert.equal(/[?[\]\\*]/.test(pattern.slice(0, -1)), false);
87
+ return [...store].filter(key => key.startsWith(pattern.slice(0, -1)));
88
+ },
89
+ async del(keys: string[]) {
90
+ calls.push({ clientName, command: 'del', keys });
91
+ for (const key of keys) store.delete(`${client.options.keyPrefix ?? ''}${key}`);
92
+ return keys.length;
93
+ },
94
+ };
95
+ clients[clientName] = client;
96
+ }
97
+ const service = Object.create(ServiceRedis.prototype) as InstanceType<typeof ServiceRedis>;
98
+ Object.defineProperty(service, 'app', { value: app });
99
+ return { app, service, config, clients, store, calls, prefix };
100
+ }
101
+
102
+ function addOwnedKeys(fixture: ReturnType<typeof createFixture>) {
103
+ for (const category of ['bull', 'redlock', ...prefixedClients]) {
104
+ fixture.store.add(`${fixture.prefix(category)}record`);
105
+ }
106
+ }
107
+
108
+ async function assertNoDeletion(
109
+ fixture: ReturnType<typeof createFixture>,
110
+ error: RegExp,
111
+ noLookup = true,
112
+ ) {
113
+ const before = [...fixture.store];
114
+ await assert.rejects(fixture.service.clearAllData(), error);
115
+ assert.deepEqual([...fixture.store], before);
116
+ assert.equal(
117
+ fixture.calls.some(call => call.command === 'del'),
118
+ false,
119
+ );
120
+ if (noLookup) assert.deepEqual(fixture.calls, []);
121
+ }
122
+
123
+ describe('Redis clearAllData ownership', () => {
124
+ // resolveTestRunId defaults an undefined fixture ID to the process ID. Under
125
+ // the isolated app runner, execute ordinary-app fixtures in their own pure
126
+ // process rather than mutating the runner's process-global environment.
127
+ if (process.env.VONA_TEST_RUN_ID !== undefined) {
128
+ it('runs ordinary-app ownership cases without inheriting the isolated runner ID', async () => {
129
+ const loader = import.meta.resolve('ts-node-maintained/esm');
130
+ const projectRoot = new URL('../../../../../../', import.meta.url);
131
+ const registerSource = `import { register } from 'node:module'; register(${JSON.stringify(loader)}, ${JSON.stringify(projectRoot.href)});`;
132
+ const env = Object.fromEntries(Object.entries(process.env)) as NodeJS.ProcessEnv;
133
+ delete env.VONA_TEST_RUN_ID;
134
+ delete env.NODE_TEST_CONTEXT;
135
+ env.TS_NODE_PROJECT = fileURLToPath(new URL('tsconfig.json', projectRoot));
136
+ const { stdout } = await promisify(execFile)(
137
+ process.execPath,
138
+ [
139
+ '--import',
140
+ `data:text/javascript,${encodeURIComponent(registerSource)}`,
141
+ '--test',
142
+ '--test-reporter=tap',
143
+ '--test-name-pattern=Redis clearAllData ownership',
144
+ fileURLToPath(import.meta.url),
145
+ ],
146
+ { env, timeout: 30_000 },
147
+ );
148
+ assert.match(stdout, /# fail 0\b/);
149
+ assert.match(stdout, /# pass 35\b/);
150
+ });
151
+ return;
152
+ }
153
+
154
+ for (const mode of ['dev', 'test', 'prod']) {
155
+ it(`cleans every category without touching other apps or broadcast in ${mode}`, async () => {
156
+ const fixture = createFixture(mode);
157
+ addOwnedKeys(fixture);
158
+ const preserved = [
159
+ `${fixture.prefix('broadcast')}subscription`,
160
+ 'unprefixed-global-key',
161
+ ...['bull', 'redlock', ...prefixedClients].flatMap(category => [
162
+ `${category}_other-app${mode === 'prod' ? '' : '_local'}:record`,
163
+ `${category}_cleanup-app-sibling${mode === 'prod' ? '' : '_local'}:record`,
164
+ `${category}_cleanup-app${mode === 'prod' ? '_local' : ''}:record`,
165
+ ]),
166
+ ];
167
+ for (const key of preserved) fixture.store.add(key);
168
+
169
+ await fixture.service.clearAllData();
170
+
171
+ assert.deepEqual([...fixture.store].toSorted(), preserved.toSorted());
172
+ assert.deepEqual(
173
+ fixture.calls.filter(call => call.command === 'keys').map(call => call.clientName),
174
+ clientNames.filter(clientName => clientName !== 'broadcast'),
175
+ );
176
+ const firstDelete = fixture.calls.findIndex(call => call.command === 'del');
177
+ assert.equal(firstDelete, clientNames.length - 1);
178
+ for (const clientName of prefixedClients) {
179
+ assert.deepEqual(
180
+ fixture.calls.find(call => call.clientName === clientName && call.command === 'del')
181
+ ?.keys,
182
+ ['record'],
183
+ );
184
+ }
185
+ assert.deepEqual(
186
+ fixture.calls.find(call => call.clientName === 'queue' && call.command === 'del')?.keys,
187
+ [`${fixture.prefix('bull')}record`],
188
+ );
189
+ assert.deepEqual(
190
+ fixture.calls.find(call => call.clientName === 'redlock' && call.command === 'del')?.keys,
191
+ [`${fixture.prefix('redlock')}record`],
192
+ );
193
+ });
194
+ }
195
+
196
+ it('allows only bounded custom nesting and strips the complete client prefix for DEL', async () => {
197
+ const fixture = createFixture();
198
+ const prefix = `${fixture.prefix('cache')}nested:deeper:`;
199
+ fixture.config.redis.clients.cache.keyPrefix = prefix;
200
+ fixture.clients.cache.options.keyPrefix = prefix;
201
+ fixture.store.add(`${prefix}record`);
202
+ fixture.store.add(`${fixture.prefix('cache')}outside-custom-namespace`);
203
+ fixture.store.add(`${fixture.prefix('cache')}nested:deeper-sibling:record`);
204
+
205
+ await fixture.service.clearAllData();
206
+
207
+ assert.deepEqual(
208
+ fixture.calls.filter(call => call.command === 'del'),
209
+ [{ clientName: 'cache', command: 'del', keys: ['record'] }],
210
+ );
211
+ assert.deepEqual(
212
+ [...fixture.store],
213
+ [
214
+ `${fixture.prefix('cache')}outside-custom-namespace`,
215
+ `${fixture.prefix('cache')}nested:deeper-sibling:record`,
216
+ ],
217
+ );
218
+ });
219
+
220
+ it('does not DEL when all owned lookups are empty', async () => {
221
+ const fixture = createFixture();
222
+ fixture.store.add('cache_other-app_local:record');
223
+ await fixture.service.clearAllData();
224
+ assert.equal(
225
+ fixture.calls.some(call => call.command === 'del'),
226
+ false,
227
+ );
228
+ assert.deepEqual([...fixture.store], ['cache_other-app_local:record']);
229
+ });
230
+
231
+ for (const keyPrefix of [
232
+ undefined,
233
+ '',
234
+ '*',
235
+ 'undefined',
236
+ 'worker_other-app_local:',
237
+ 'worker_cleanup-app_local',
238
+ 'worker_cleanup-app_local-sibling:',
239
+ 'worker_cleanup-app_local:unbounded',
240
+ 'worker_cleanup-app_local:*:',
241
+ 'worker_cleanup-app_local:?:',
242
+ 'worker_cleanup-app_local:[ab]:',
243
+ 'worker_cleanup-app_local:back\\slash:',
244
+ 'worker_cleanup-app_local:\n:',
245
+ ]) {
246
+ it(`rejects unsafe later configured prefixes before any lookup (${JSON.stringify(keyPrefix)})`, async () => {
247
+ const fixture = createFixture();
248
+ addOwnedKeys(fixture);
249
+ fixture.config.redis.clients.worker.keyPrefix = keyPrefix;
250
+ fixture.clients.worker.options.keyPrefix = keyPrefix;
251
+ await assertNoDeletion(fixture, /prefix does not belong to this app/);
252
+ });
253
+ }
254
+
255
+ for (const keyPrefix of [
256
+ undefined,
257
+ '',
258
+ 'worker_other-app_local:',
259
+ 'worker_cleanup-app_local:nested:',
260
+ ]) {
261
+ it(`rejects config/actual-client prefix drift before any lookup (${JSON.stringify(keyPrefix)})`, async () => {
262
+ const fixture = createFixture();
263
+ addOwnedKeys(fixture);
264
+ fixture.clients.worker.options.keyPrefix = keyPrefix;
265
+ await assertNoDeletion(fixture, /unexpected client prefix/);
266
+ });
267
+ }
268
+
269
+ it('rejects a configured custom prefix when the actual client still uses the framework prefix', async () => {
270
+ const fixture = createFixture();
271
+ addOwnedKeys(fixture);
272
+ fixture.config.redis.clients.worker.keyPrefix = `${fixture.prefix('worker')}nested:`;
273
+ await assertNoDeletion(fixture, /unexpected client prefix/);
274
+ });
275
+
276
+ for (const clientName of ['queue', 'redlock', 'redlockPeer']) {
277
+ it(`rejects prefixed special client configuration for ${clientName}`, async () => {
278
+ const fixture = createFixture();
279
+ addOwnedKeys(fixture);
280
+ fixture.config.redis.clients[clientName].keyPrefix = true;
281
+ await assertNoDeletion(fixture, /requires an unprefixed client/);
282
+ });
283
+
284
+ it(`rejects an unexpected actual keyPrefix for ${clientName}`, async () => {
285
+ const fixture = createFixture();
286
+ addOwnedKeys(fixture);
287
+ fixture.clients[clientName].options.keyPrefix = 'foreign:';
288
+ await assertNoDeletion(fixture, /unexpected client prefix/);
289
+ });
290
+ }
291
+
292
+ it('rejects an unavailable later client without deleting already prepared categories', async () => {
293
+ const fixture = createFixture();
294
+ addOwnedKeys(fixture);
295
+ delete fixture.clients.worker;
296
+ await assertNoDeletion(fixture, /client is unavailable/);
297
+ });
298
+
299
+ for (const clientName of ['queue', 'redlockPeer', 'worker']) {
300
+ it(`validates all fetched full keys before DEL when ${clientName} returns a foreign key`, async () => {
301
+ const fixture = createFixture();
302
+ addOwnedKeys(fixture);
303
+ const originalKeys = fixture.clients[clientName].keys;
304
+ fixture.clients[clientName].keys = async pattern => [
305
+ ...(await originalKeys(pattern)),
306
+ 'worker_other-app_local:record',
307
+ ];
308
+ await assertNoDeletion(fixture, /key outside the owned prefix/, false);
309
+ });
310
+ }
311
+
312
+ it('does not delete earlier fetched keys when a later lookup fails', async () => {
313
+ const fixture = createFixture();
314
+ addOwnedKeys(fixture);
315
+ fixture.clients.worker.keys = async () => {
316
+ throw new Error('mock lookup failure');
317
+ };
318
+ await assertNoDeletion(fixture, /mock lookup failure/, false);
319
+ });
320
+
321
+ it('rejects unsafe app identities before looking up any keys', async () => {
322
+ const fixture = createFixture();
323
+ Object.defineProperty(fixture.app, 'name', { value: 'cleanup*app' });
324
+ await assertNoDeletion(fixture, /prefix does not belong to this app/);
325
+ });
326
+ });
327
+
328
+ describe('isolated Redis cleanup', () => {
329
+ it('cleans the full run namespace while preserving sibling runs', async () => {
330
+ const fixture = createFixture('test', true);
331
+ addOwnedKeys(fixture);
332
+ const sibling = 'bull_cabtest20261014010203-d4e5f6a7_local:record';
333
+ fixture.store.add(sibling);
334
+ await fixture.service.clearAllData();
335
+ assert.deepEqual([...fixture.store], [sibling]);
336
+ });
337
+
338
+ it('rejects a run/app identity mismatch before cleanup', async () => {
339
+ const fixture = createFixture('test', true);
340
+ addOwnedKeys(fixture);
341
+ Object.defineProperty(fixture.app, 'name', { value: 'cleanup-app' });
342
+ await assertNoDeletion(fixture, /APP_NAME does not match its run ID/);
343
+ });
344
+
345
+ it('keeps the strict framework-managed prefix requirement even for bounded custom nesting', async () => {
346
+ const fixture = createFixture('test', true);
347
+ addOwnedKeys(fixture);
348
+ const prefix = `${fixture.prefix('worker')}nested:`;
349
+ fixture.config.redis.clients.worker.keyPrefix = prefix;
350
+ fixture.clients.worker.options.keyPrefix = prefix;
351
+ await assertNoDeletion(fixture, /requires a framework-managed Redis prefix/);
352
+ });
353
+
354
+ it('rejects inherited base prefixes before cleanup', async () => {
355
+ const fixture = createFixture('test', true);
356
+ addOwnedKeys(fixture);
357
+ fixture.config.redis.base.keyPrefix = 'foreign:';
358
+ await assertNoDeletion(fixture, /cannot inherit a Redis base keyPrefix/);
359
+ });
360
+
361
+ for (const clientName of ['queue', 'redlockPeer']) {
362
+ it(`retains the isolated undefined configured prefix rule for ${clientName}`, async () => {
363
+ const fixture = createFixture('test', true);
364
+ addOwnedKeys(fixture);
365
+ fixture.config.redis.clients[clientName].keyPrefix = '';
366
+ await assertNoDeletion(fixture, /requires a framework-managed Redis prefix/);
367
+ });
368
+ }
369
+
370
+ for (const property of ['sentinels', 'nodes'] as const) {
371
+ it(`rejects isolated ${property} clients before cleanup`, async () => {
372
+ const fixture = createFixture('test', true);
373
+ addOwnedKeys(fixture);
374
+ fixture.config.redis.clients.worker[property] = [];
375
+ await assertNoDeletion(fixture, /cannot use a Redis sentinel or cluster client/);
376
+ });
377
+ }
378
+ });