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
|
@@ -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
|
+
});
|
|
@@ -28,17 +28,22 @@ export class ServiceStartup extends BeanBase {
|
|
|
28
28
|
}
|
|
29
29
|
// clear keys
|
|
30
30
|
await this._clearResources();
|
|
31
|
+
if (this.app.meta.isDev) {
|
|
32
|
+
await this.bean.executor.newCtx(
|
|
33
|
+
() => this.$scope.version.service.database.prepareDevDatabases(),
|
|
34
|
+
{ dbInfo: { level: 1 }, instanceName: null },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
31
37
|
|
|
32
38
|
// run startups: not after
|
|
33
39
|
for (const startup of this._startups) {
|
|
34
40
|
const startupOptions = startup.beanOptions.options;
|
|
35
41
|
if (!startupOptions?.instance && startupOptions?.after !== true) {
|
|
36
|
-
// A previous dev startup may have left databaseInit's debounce key in Redis.
|
|
37
|
-
// Only the explicit reset must bypass that key to guarantee recreation.
|
|
38
42
|
const force =
|
|
39
43
|
this.app.meta.isDev &&
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
startup.name === 'a-version:databaseInit' &&
|
|
45
|
+
(process.env.VONA_DB_RESET === 'true' ||
|
|
46
|
+
this.$scope.version.service.database.createdInstanceNames.length > 0);
|
|
42
47
|
const run = () => this.runStartup(startup.name, null, force ? { force: true } : undefined);
|
|
43
48
|
if (this.app.meta.env.VONA_TEST_RUN_ID && startup.name === 'a-version:databaseInit') {
|
|
44
49
|
const defaultClient = this.app.config.database.defaultClient;
|
|
@@ -55,6 +60,25 @@ export class ServiceStartup extends BeanBase {
|
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
async appReady() {
|
|
63
|
+
const createdInstances = this.$scope.version.service.database.createdInstanceNames;
|
|
64
|
+
const preparingDev = this.app.meta.isDev && createdInstances.length > 0;
|
|
65
|
+
if (preparingDev) {
|
|
66
|
+
// Initialize and seed fresh instances before listeners or queue workers start.
|
|
67
|
+
const instanceNames = [
|
|
68
|
+
'',
|
|
69
|
+
...createdInstances.filter(name => name !== ''),
|
|
70
|
+
] as (keyof IInstanceRecord)[];
|
|
71
|
+
for (const instanceName of instanceNames) {
|
|
72
|
+
await this._appReadyInstanceStartup(instanceName, true, true);
|
|
73
|
+
}
|
|
74
|
+
for (const instanceName of instanceNames) {
|
|
75
|
+
await this.bean.executor.newCtx(() => this.runStartupInstanceAfter(instanceName), {
|
|
76
|
+
dbInfo: { level: 1 },
|
|
77
|
+
instanceName,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
58
82
|
// run startups: after
|
|
59
83
|
for (const startup of this._startups) {
|
|
60
84
|
const startupOptions = startup.beanOptions.options;
|
|
@@ -65,7 +89,7 @@ export class ServiceStartup extends BeanBase {
|
|
|
65
89
|
|
|
66
90
|
// version init : force: should be false
|
|
67
91
|
if (this.app.meta.isTest || this.app.meta.isDev) {
|
|
68
|
-
await this._appReadyInstanceStartup('', true);
|
|
92
|
+
if (!preparingDev) await this._appReadyInstanceStartup('', true);
|
|
69
93
|
} else {
|
|
70
94
|
// only start static instances (defined in config)
|
|
71
95
|
for (const key in this.app.config.instance.instances) {
|
|
@@ -78,8 +102,8 @@ export class ServiceStartup extends BeanBase {
|
|
|
78
102
|
}
|
|
79
103
|
}
|
|
80
104
|
|
|
81
|
-
//
|
|
82
|
-
if (this.app.meta.isTest
|
|
105
|
+
// Test seeding retains its existing lifecycle; dev seeding belongs to fresh instances.
|
|
106
|
+
if (this.app.meta.isTest) {
|
|
83
107
|
await this.seedDefaultInstance();
|
|
84
108
|
}
|
|
85
109
|
}
|
|
@@ -94,9 +118,17 @@ export class ServiceStartup extends BeanBase {
|
|
|
94
118
|
);
|
|
95
119
|
}
|
|
96
120
|
|
|
97
|
-
private async _appReadyInstanceStartup(
|
|
121
|
+
private async _appReadyInstanceStartup(
|
|
122
|
+
instanceName: keyof IInstanceRecord,
|
|
123
|
+
wait: boolean,
|
|
124
|
+
deferAfter?: boolean,
|
|
125
|
+
) {
|
|
98
126
|
const res = this.$scope.instance.service.instance.instanceStartupIsolate(instanceName, {
|
|
99
|
-
force:
|
|
127
|
+
force:
|
|
128
|
+
this.app.meta.isDev &&
|
|
129
|
+
(process.env.VONA_DB_RESET === 'true' ||
|
|
130
|
+
this.$scope.version.service.database.createdInstanceNames.includes(instanceName)),
|
|
131
|
+
deferAfter,
|
|
100
132
|
});
|
|
101
133
|
if (wait) {
|
|
102
134
|
await res;
|
|
@@ -182,6 +214,21 @@ export class ServiceStartup extends BeanBase {
|
|
|
182
214
|
await this.runStartup(startup.name, instanceName, options);
|
|
183
215
|
}
|
|
184
216
|
}
|
|
217
|
+
if (
|
|
218
|
+
this.app.meta.isDev &&
|
|
219
|
+
this.$scope.version.service.database.createdInstanceNames.includes(instanceName)
|
|
220
|
+
) {
|
|
221
|
+
await this.$scope.version.service.version.__instanceSeed(instanceName);
|
|
222
|
+
this.$scope.version.service.database.consumeCreatedInstance(instanceName);
|
|
223
|
+
}
|
|
224
|
+
if (options?.deferAfter) return;
|
|
225
|
+
await this.runStartupInstanceAfter(instanceName, options);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async runStartupInstanceAfter(
|
|
229
|
+
instanceName: keyof IInstanceRecord,
|
|
230
|
+
options?: IInstanceStartupOptions,
|
|
231
|
+
) {
|
|
185
232
|
// set flag
|
|
186
233
|
this.app.meta.appReadyInstances[instanceName] = true;
|
|
187
234
|
// run startups: after
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { ServiceStartup } from '../src/service/startup.ts';
|
|
5
|
+
|
|
6
|
+
function fixture(created: string[] = []) {
|
|
7
|
+
const calls: string[] = [];
|
|
8
|
+
const pending = new Set(created);
|
|
9
|
+
const app = {
|
|
10
|
+
meta: { isDev: true, isTest: false, appReadyInstances: {} as Record<string, boolean> },
|
|
11
|
+
config: { server: { listen: { disable: false } } },
|
|
12
|
+
};
|
|
13
|
+
const service = Object.create(ServiceStartup.prototype) as ServiceStartup;
|
|
14
|
+
const startups = [
|
|
15
|
+
{ name: 'a-version:instanceInit', beanOptions: { options: { instance: true } } },
|
|
16
|
+
{ name: 'a-runtime:runtime', beanOptions: { options: { instance: true, after: true } } },
|
|
17
|
+
{ name: 'a-queue:loadQueueWorkers', beanOptions: { options: { instance: true, after: true } } },
|
|
18
|
+
{ name: 'a-web:listen', beanOptions: { options: { after: true } } },
|
|
19
|
+
];
|
|
20
|
+
Object.defineProperties(service, {
|
|
21
|
+
app: { value: app },
|
|
22
|
+
_startups: { value: startups },
|
|
23
|
+
bean: { value: { executor: { newCtx: async (fn: () => Promise<unknown>) => fn() } } },
|
|
24
|
+
$scope: {
|
|
25
|
+
value: {
|
|
26
|
+
version: {
|
|
27
|
+
service: {
|
|
28
|
+
database: {
|
|
29
|
+
get createdInstanceNames() {
|
|
30
|
+
return [...pending];
|
|
31
|
+
},
|
|
32
|
+
consumeCreatedInstance: (name: string) => pending.delete(name),
|
|
33
|
+
},
|
|
34
|
+
version: {
|
|
35
|
+
__instanceSeed: async (name: string) => {
|
|
36
|
+
calls.push(`seed:${name}`);
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
instance: {
|
|
42
|
+
service: {
|
|
43
|
+
instance: {
|
|
44
|
+
instanceStartupIsolate: async (name: string, options: any) =>
|
|
45
|
+
service.runStartupInstance(name as never, options),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
runStartup: {
|
|
52
|
+
value: async (name: string, instance: string) => {
|
|
53
|
+
calls.push(`${name}:${instance}`);
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
return { service, app, calls, pending };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('creation-driven dev seed', { concurrency: false }, () => {
|
|
61
|
+
it('seeds fresh instances before runtime, queues, and listening', async () => {
|
|
62
|
+
const { service, calls, pending, app } = fixture(['', 'isolateTest']);
|
|
63
|
+
await service.appReady();
|
|
64
|
+
assert.equal(pending.size, 0);
|
|
65
|
+
const lastSeed = calls.indexOf('seed:isolateTest');
|
|
66
|
+
assert.ok(lastSeed > calls.indexOf('seed:'));
|
|
67
|
+
for (const call of ['a-runtime:runtime:', 'a-queue:loadQueueWorkers:', 'a-web:listen:null']) {
|
|
68
|
+
assert.ok(calls.indexOf(call) > lastSeed, `${call} must follow all seeds`);
|
|
69
|
+
}
|
|
70
|
+
assert.equal(app.meta.appReadyInstances[''], true);
|
|
71
|
+
assert.equal(app.meta.appReadyInstances.isolateTest, true);
|
|
72
|
+
await service.runStartupInstance('' as never);
|
|
73
|
+
assert.equal(calls.filter(call => call === 'seed:').length, 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does not seed existing default when only an isolated database is new', async () => {
|
|
77
|
+
const { service, calls } = fixture(['isolateTest']);
|
|
78
|
+
await service.appReady();
|
|
79
|
+
assert.ok(calls.includes('seed:isolateTest'));
|
|
80
|
+
assert.ok(!calls.includes('seed:'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('does not seed when all databases are reused', async () => {
|
|
84
|
+
const { service, calls } = fixture();
|
|
85
|
+
await service.appReady();
|
|
86
|
+
assert.ok(!calls.some(call => call.startsWith('seed:')));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('fails before after-startups and readiness if seeding fails', async () => {
|
|
90
|
+
const { service, app, calls, pending } = fixture(['']);
|
|
91
|
+
(service as any).$scope.version.service.version.__instanceSeed = async () => {
|
|
92
|
+
throw new Error('seed failed');
|
|
93
|
+
};
|
|
94
|
+
await assert.rejects(service.appReady(), /seed failed/);
|
|
95
|
+
assert.ok(pending.has(''));
|
|
96
|
+
assert.deepEqual(app.meta.appReadyInstances, {});
|
|
97
|
+
assert.ok(
|
|
98
|
+
!calls.some(
|
|
99
|
+
call =>
|
|
100
|
+
call.includes('runtime') || call.includes('listen') || call.includes('loadQueueWorkers'),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|