cabloy 5.1.219 → 5.1.221
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/.github/workflows/agent-governance.yml +2 -2
- package/.github/workflows/create-cabloy.yml +3 -3
- package/.github/workflows/docs-pages.yml +2 -2
- package/.github/workflows/playwright-e2e.yml +4 -4
- package/.github/workflows/vona-cov-pg.yml +2 -2
- package/.github/workflows/vona-test-crud.yml +2 -2
- package/.github/workflows/vona-test-mysql.yml +2 -2
- package/.github/workflows/vona-test-pg.yml +2 -2
- package/.github/workflows/vona-test-sqlite3.yml +2 -2
- package/.github/workflows/vona-tsc.yml +2 -2
- package/.github/workflows/zova-ui.yml +2 -2
- package/CHANGELOG.md +23 -0
- package/README.md +1 -1
- package/package.json +4 -3
- package/repo-docs/frontend/openapi-sdk-guide.md +2 -0
- package/repo-docs/fullstack/quickstart.md +3 -3
- package/repo-docs/reference/repo-scripts.md +16 -0
- package/scripts/generateOpenapiIsolated.test.ts +25 -0
- package/scripts/generateOpenapiIsolated.ts +46 -12
- package/scripts/runIsolatedTest.test.ts +165 -45
- package/scripts/runIsolatedTest.ts +111 -24
- package/scripts/strictGenerationDiagnostics.test.ts +1009 -0
- package/scripts/strictGenerationDiagnostics.ts +61 -0
- package/scripts/strictGenerationStartup.test.ts +212 -0
- package/vona/package.original.json +3 -2
- 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 +14 -4
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/generateEntryFiles.ts +6 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/generateVonaMeta.ts +2 -10
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/validateGenerationPaths.ts +27 -0
- package/vona/packages-cli/cli-set-api/test/binDevLifecycle.test.ts +1 -1
- package/vona/packages-cli/cli-set-api/test/strictGenerationPrivacy.test.ts +439 -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/bean/beanBase.ts +4 -0
- package/vona/packages-vona/vona-core/src/lib/core/asyncLocalStorage.ts +84 -12
- package/vona/packages-vona/vona-core/src/lib/core/index.ts +14 -0
- package/vona/packages-vona/vona-core/src/lib/core/logger/appLogger.ts +19 -3
- package/vona/packages-vona/vona-core/src/lib/core/logger/diagnosticLogger.test.ts +346 -0
- package/vona/packages-vona/vona-core/src/lib/core/logger/diagnosticLogger.ts +138 -0
- package/vona/packages-vona/vona-core/src/lib/core/logger/index.ts +1 -0
- package/vona/packages-vona/vona-core/src/lib/core/logger/utils.ts +25 -5
- package/vona/packages-vona/vona-core/src/lib/core/requestBoundary.ts +89 -0
- package/vona/packages-vona/vona-core/src/lib/core/testFreshness.test.ts +379 -0
- package/vona/packages-vona/vona-core/src/lib/core/testFreshness.ts +232 -0
- package/vona/packages-vona/vona-core/src/lib/core/testStrictMode.ts +9 -0
- package/vona/packages-vona/vona-core/src/lib/framework/start.test.ts +4 -3
- package/vona/packages-vona/vona-core/src/lib/framework/start.ts +2 -0
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +691 -356
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/bean/bean.executor.ts +111 -60
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/service/executor.ts +6 -6
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/types/executor.ts +13 -1
- package/vona/src/suite-vendor/a-vona/modules/a-jwt/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/lib/jwtClientLogging.test.ts +182 -0
- package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/service/jwtClient.ts +2 -8
- package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/aopMethod.log.ts +4 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/bean.logger.ts +9 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +27 -14
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactHttpLog.test.ts +207 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactHttpLog.ts +30 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/test/middlewareSystem.httpLog.test.ts +33 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/index.ts +11 -0
- package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/lib/protectedSqliteRegistry.ts +356 -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 +6 -0
- package/vona/src/suite-vendor/a-vona/modules/a-startup/test/devSeed.test.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-telemetry/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/bean/bean.telemetry.ts +8 -1
- package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/bean/middlewareSystem.trace.ts +9 -14
- package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/lib/spanDiagnostics.ts +70 -0
- package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/service/telemetry.ts +37 -23
- 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 +31 -4
- package/vona/src/suite-vendor/a-vona/package.json +2 -2
- package/zova/package.original.json +3 -2
- package/zova/packages-cli/cli/package.json +2 -2
- package/zova/packages-cli/cli-set-front/package.json +1 -1
- package/zova/packages-cli/cli-set-front/src/lib/bean/cli.bin.buildRest.ts +11 -101
- package/zova/packages-cli/cli-set-front/src/lib/common/restDependencies.ts +78 -0
- package/zova/packages-cli/cli-set-front/src/lib/common/restDependencyVersions.ts +252 -0
- package/zova/packages-cli/cli-set-front/test/restDependencies.test.ts +568 -0
- package/zova/pnpm-lock.yaml +442 -564
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
|
|
7
|
+
import type { VonaApplication } from './application.ts';
|
|
8
|
+
import type { TestFreshnessDependencies } from './testFreshness.ts';
|
|
9
|
+
|
|
10
|
+
import { buildTestClaimResourceInventory } from './testClaim.ts';
|
|
11
|
+
import {
|
|
12
|
+
assertFreshTestResources,
|
|
13
|
+
assertFreshTestResourcesReady,
|
|
14
|
+
createFreshnessDependencies,
|
|
15
|
+
hasFreshnessDatabase,
|
|
16
|
+
hasFreshnessRedis,
|
|
17
|
+
requiresFreshTestResources,
|
|
18
|
+
TEST_REQUIRE_FRESH_RESOURCES_ENV,
|
|
19
|
+
} from './testFreshness.ts';
|
|
20
|
+
import { resolveVonaLocalPaths } from './testIsolation.ts';
|
|
21
|
+
|
|
22
|
+
function fixture() {
|
|
23
|
+
const root = mkdtempSync(path.join(realpathSync(tmpdir()), 'vona-freshness-'));
|
|
24
|
+
const runId = '20261019010203-a1b2c3d4';
|
|
25
|
+
const projectPath = path.join(root, 'vona');
|
|
26
|
+
const paths = resolveVonaLocalPaths(projectPath, 'test', runId);
|
|
27
|
+
for (const directory of [paths.dataRoot, paths.generatedRoot]) {
|
|
28
|
+
mkdirSync(directory, { recursive: true });
|
|
29
|
+
writeFileSync(path.join(directory, '.vona-test-owner'), `${runId}\n`);
|
|
30
|
+
}
|
|
31
|
+
const fixtureApp = {
|
|
32
|
+
projectPath,
|
|
33
|
+
name: `cabtest${runId}`,
|
|
34
|
+
meta: { env: { META_MODE: 'test', VONA_TEST_RUN_ID: runId, SERVER_LISTEN_PORT: '41234' } },
|
|
35
|
+
config: {
|
|
36
|
+
server: {
|
|
37
|
+
publicDir: paths.publicRoot,
|
|
38
|
+
workers: 1,
|
|
39
|
+
listen: { hostname: '127.0.0.1', port: 41234 },
|
|
40
|
+
serve: { protocol: 'http', host: '127.0.0.1:41234' },
|
|
41
|
+
},
|
|
42
|
+
logger: { baseDir: paths.logsRoot },
|
|
43
|
+
database: {
|
|
44
|
+
testDatabase: true,
|
|
45
|
+
base: {},
|
|
46
|
+
defaultClient: 'main',
|
|
47
|
+
clients: {
|
|
48
|
+
main: {
|
|
49
|
+
client: 'pg',
|
|
50
|
+
connection: { host: '127.0.0.1', port: 5432, database: 'postgres', user: 'synthetic' },
|
|
51
|
+
},
|
|
52
|
+
isolated: {
|
|
53
|
+
client: 'pg',
|
|
54
|
+
connection: { host: '127.0.0.1', port: 5433, database: 'postgres', user: 'synthetic' },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
instance: { instances: { isolated: { id: 2, isolate: true, isolateClient: 'isolated' } } },
|
|
59
|
+
redis: {
|
|
60
|
+
base: {},
|
|
61
|
+
clients: {
|
|
62
|
+
worker: { keyPrefix: true },
|
|
63
|
+
queue: {},
|
|
64
|
+
broadcast: {},
|
|
65
|
+
redlock: {},
|
|
66
|
+
redlock2: { db: 2 },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
const app = fixtureApp as unknown as VonaApplication & { config: typeof fixtureApp.config };
|
|
72
|
+
const events: string[] = [];
|
|
73
|
+
const readers: TestFreshnessDependencies = {
|
|
74
|
+
databaseExists: async plan => {
|
|
75
|
+
events.push(`db:${plan.scope}`);
|
|
76
|
+
return false;
|
|
77
|
+
},
|
|
78
|
+
redisExists: async resource => {
|
|
79
|
+
events.push(`redis:${resource.clientName}`);
|
|
80
|
+
return false;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
return { root, paths, app, readers, events };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function strict(fn: () => Promise<void>) {
|
|
87
|
+
const previous = process.env[TEST_REQUIRE_FRESH_RESOURCES_ENV];
|
|
88
|
+
process.env[TEST_REQUIRE_FRESH_RESOURCES_ENV] = 'true';
|
|
89
|
+
try {
|
|
90
|
+
await fn();
|
|
91
|
+
} finally {
|
|
92
|
+
if (previous === undefined) delete process.env[TEST_REQUIRE_FRESH_RESOURCES_ENV];
|
|
93
|
+
else process.env[TEST_REQUIRE_FRESH_RESOURCES_ENV] = previous;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
test(
|
|
98
|
+
'strict freshness covers all targets before marking proof, without writes or resource identifiers',
|
|
99
|
+
{ concurrency: false },
|
|
100
|
+
async () => {
|
|
101
|
+
await strict(async () => {
|
|
102
|
+
const f = fixture();
|
|
103
|
+
try {
|
|
104
|
+
assert.throws(() => assertFreshTestResourcesReady(f.app), /not established/);
|
|
105
|
+
mkdirSync(f.paths.publicRoot); // Config may already have created this empty directory.
|
|
106
|
+
await assertFreshTestResources(f.app, f.readers);
|
|
107
|
+
assert.deepEqual(f.events, [
|
|
108
|
+
'db:share',
|
|
109
|
+
'db:isolate-isolated',
|
|
110
|
+
'redis:worker',
|
|
111
|
+
'redis:queue',
|
|
112
|
+
'redis:redlock',
|
|
113
|
+
'redis:redlock2',
|
|
114
|
+
]);
|
|
115
|
+
assert.doesNotThrow(() => assertFreshTestResourcesReady(f.app));
|
|
116
|
+
await assert.rejects(assertFreshTestResources(f.app, f.readers), /new isolated startup/);
|
|
117
|
+
} finally {
|
|
118
|
+
rmSync(f.root, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
test(
|
|
125
|
+
'strict freshness rejects a later isolate conflict or Redis residue without admitting startup',
|
|
126
|
+
{ concurrency: false },
|
|
127
|
+
async () => {
|
|
128
|
+
await strict(async () => {
|
|
129
|
+
for (const conflict of ['db:isolate-isolated', 'redis:redlock2', 'read-error']) {
|
|
130
|
+
const f = fixture();
|
|
131
|
+
try {
|
|
132
|
+
f.readers.databaseExists = async plan => {
|
|
133
|
+
f.events.push(`db:${plan.scope}`);
|
|
134
|
+
if (conflict === 'read-error') throw new Error('synthetic-protected-driver-secret');
|
|
135
|
+
return conflict === `db:${plan.scope}`;
|
|
136
|
+
};
|
|
137
|
+
f.readers.redisExists = async resource => {
|
|
138
|
+
f.events.push(`redis:${resource.clientName}`);
|
|
139
|
+
return conflict === `redis:${resource.clientName}`;
|
|
140
|
+
};
|
|
141
|
+
await assert.rejects(assertFreshTestResources(f.app, f.readers), error => {
|
|
142
|
+
assert.equal((error as Error).message, 'Strict test resource freshness check failed');
|
|
143
|
+
assert.equal((error as Error).cause, undefined);
|
|
144
|
+
return true;
|
|
145
|
+
});
|
|
146
|
+
assert.throws(() => assertFreshTestResourcesReady(f.app), /not established/);
|
|
147
|
+
if (conflict.startsWith('db:')) {
|
|
148
|
+
assert.equal(
|
|
149
|
+
f.events.some(value => value.startsWith('redis:')),
|
|
150
|
+
false,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
rmSync(f.root, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
test(
|
|
162
|
+
'strict freshness rejects local residue, symlinks and preselected database targets before driver reads',
|
|
163
|
+
{ concurrency: false },
|
|
164
|
+
async () => {
|
|
165
|
+
await strict(async () => {
|
|
166
|
+
for (const kind of ['public', 'runtime', 'sqlite', 'symlink', 'selected', 'unisolated']) {
|
|
167
|
+
const f = fixture();
|
|
168
|
+
try {
|
|
169
|
+
if (kind === 'selected') {
|
|
170
|
+
f.app.config.database.clients.main.connection.database = 'vona-test-preselected';
|
|
171
|
+
} else if (kind === 'unisolated') {
|
|
172
|
+
f.app.meta.env.VONA_TEST_RUN_ID = undefined;
|
|
173
|
+
} else if (kind === 'symlink') {
|
|
174
|
+
symlinkSync(f.paths.generatedRoot, f.paths.publicRoot);
|
|
175
|
+
} else {
|
|
176
|
+
const directory =
|
|
177
|
+
kind === 'public'
|
|
178
|
+
? f.paths.publicRoot
|
|
179
|
+
: kind === 'runtime'
|
|
180
|
+
? f.paths.runtimeRoot
|
|
181
|
+
: f.paths.sqliteRoot;
|
|
182
|
+
mkdirSync(directory);
|
|
183
|
+
writeFileSync(path.join(directory, 'synthetic-residue'), 'keep');
|
|
184
|
+
}
|
|
185
|
+
await assert.rejects(assertFreshTestResources(f.app, f.readers));
|
|
186
|
+
assert.deepEqual(f.events, []);
|
|
187
|
+
} finally {
|
|
188
|
+
rmSync(f.root, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
test(
|
|
196
|
+
'freshness is explicit and default lanes never invoke readers',
|
|
197
|
+
{ concurrency: false },
|
|
198
|
+
async () => {
|
|
199
|
+
const previous = process.env[TEST_REQUIRE_FRESH_RESOURCES_ENV];
|
|
200
|
+
try {
|
|
201
|
+
delete process.env[TEST_REQUIRE_FRESH_RESOURCES_ENV];
|
|
202
|
+
const app = {} as VonaApplication;
|
|
203
|
+
assert.equal(requiresFreshTestResources(), false);
|
|
204
|
+
await assertFreshTestResources(app, {
|
|
205
|
+
databaseExists: async () => {
|
|
206
|
+
throw new Error('must not read');
|
|
207
|
+
},
|
|
208
|
+
redisExists: async () => {
|
|
209
|
+
throw new Error('must not read');
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
assert.doesNotThrow(() => assertFreshTestResourcesReady(app));
|
|
213
|
+
process.env[TEST_REQUIRE_FRESH_RESOURCES_ENV] = 'false';
|
|
214
|
+
assert.throws(() => requiresFreshTestResources(), /Invalid strict/);
|
|
215
|
+
} finally {
|
|
216
|
+
if (previous === undefined) delete process.env[TEST_REQUIRE_FRESH_RESOURCES_ENV];
|
|
217
|
+
else process.env[TEST_REQUIRE_FRESH_RESOURCES_ENV] = previous;
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
test('read-only database inspection uses exact prefix bindings and always disposes', async () => {
|
|
223
|
+
const f = fixture();
|
|
224
|
+
try {
|
|
225
|
+
const plan = buildTestClaimResourceInventory(f.app).databases[0];
|
|
226
|
+
for (const dialect of ['pg', 'mysql', 'mysql2'] as const) {
|
|
227
|
+
for (const found of [false, true]) {
|
|
228
|
+
let closed = 0;
|
|
229
|
+
const result = await hasFreshnessDatabase(
|
|
230
|
+
{
|
|
231
|
+
raw: async (sql, bindings) => {
|
|
232
|
+
assert.match(sql, /^select .* limit 1$/);
|
|
233
|
+
assert.deepEqual(bindings, [plan.prefix.length, plan.prefix]);
|
|
234
|
+
const rows = found ? [{}] : [];
|
|
235
|
+
return dialect === 'pg' ? { rows } : [rows];
|
|
236
|
+
},
|
|
237
|
+
destroy: async () => {
|
|
238
|
+
closed++;
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{ ...plan, endpoint: { ...plan.endpoint, dialect } },
|
|
242
|
+
);
|
|
243
|
+
assert.equal(result, found);
|
|
244
|
+
assert.equal(closed, 1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
let closed = 0;
|
|
248
|
+
await assert.rejects(
|
|
249
|
+
hasFreshnessDatabase(
|
|
250
|
+
{
|
|
251
|
+
raw: async () => ({}),
|
|
252
|
+
destroy: async () => {
|
|
253
|
+
closed++;
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
plan,
|
|
257
|
+
),
|
|
258
|
+
/Invalid freshness database response/,
|
|
259
|
+
);
|
|
260
|
+
assert.equal(closed, 1);
|
|
261
|
+
} finally {
|
|
262
|
+
rmSync(f.root, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('Redis freshness requires a completed cursor scan and rejects foreign/malformed/limited responses', async () => {
|
|
267
|
+
const prefix = 'synthetic_[*]?:';
|
|
268
|
+
const cursors: string[] = [];
|
|
269
|
+
assert.equal(
|
|
270
|
+
await hasFreshnessRedis(
|
|
271
|
+
{
|
|
272
|
+
scan: async (cursor, _match, pattern) => {
|
|
273
|
+
cursors.push(cursor);
|
|
274
|
+
assert.equal(pattern, 'synthetic_\\[\\*\\]\\?:*');
|
|
275
|
+
return [cursor === '0' ? '7' : '0', []];
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
prefix,
|
|
279
|
+
),
|
|
280
|
+
false,
|
|
281
|
+
);
|
|
282
|
+
assert.deepEqual(cursors, ['0', '7']);
|
|
283
|
+
assert.equal(
|
|
284
|
+
await hasFreshnessRedis({ scan: async () => ['1', [`${prefix}key`]] }, prefix),
|
|
285
|
+
true,
|
|
286
|
+
);
|
|
287
|
+
for (const response of [undefined, ['bad', []], ['0', ['foreign']], ['0', [1]], ['0']]) {
|
|
288
|
+
await assert.rejects(
|
|
289
|
+
hasFreshnessRedis({ scan: async () => response }, prefix),
|
|
290
|
+
/Invalid Redis/,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
await assert.rejects(
|
|
294
|
+
hasFreshnessRedis({ scan: async () => ['1', []] }, prefix, 2),
|
|
295
|
+
/limit exceeded/,
|
|
296
|
+
);
|
|
297
|
+
const failure = new Error('synthetic-ACL-error');
|
|
298
|
+
await assert.rejects(
|
|
299
|
+
hasFreshnessRedis(
|
|
300
|
+
{
|
|
301
|
+
scan: async () => {
|
|
302
|
+
throw failure;
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
prefix,
|
|
306
|
+
),
|
|
307
|
+
value => value === failure,
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('direct readers project connection options, widen queue scan and dispose on failure', async () => {
|
|
312
|
+
const f = fixture();
|
|
313
|
+
try {
|
|
314
|
+
const inventory = buildTestClaimResourceInventory(f.app);
|
|
315
|
+
const plan = inventory.databases[0];
|
|
316
|
+
const resource = inventory.redis.find(item => item.clientName === 'queue')!;
|
|
317
|
+
let sqlClosed = 0;
|
|
318
|
+
let redisClosed = 0;
|
|
319
|
+
let fail = false;
|
|
320
|
+
const readers = createFreshnessDependencies(f.app.projectPath, name => {
|
|
321
|
+
if (name === 'knex') {
|
|
322
|
+
return (options: any) => {
|
|
323
|
+
assert.equal(options.connection.database, 'postgres');
|
|
324
|
+
assert.equal(options.connection.user, 'synthetic');
|
|
325
|
+
assert.equal(options.connection.password, 'synthetic-password');
|
|
326
|
+
assert.equal(options.connection.beforeConnect, undefined);
|
|
327
|
+
assert.equal(options.pool.afterCreate, undefined);
|
|
328
|
+
return {
|
|
329
|
+
raw: async () => ({ rows: [] }),
|
|
330
|
+
destroy: async () => {
|
|
331
|
+
sqlClosed++;
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
assert.equal(name, 'ioredis');
|
|
337
|
+
return {
|
|
338
|
+
Redis: class {
|
|
339
|
+
constructor(options: any) {
|
|
340
|
+
assert.equal(options.db, 0);
|
|
341
|
+
assert.equal(options.keyPrefix, undefined);
|
|
342
|
+
assert.equal(options.enableOfflineQueue, false);
|
|
343
|
+
assert.equal(options.retryStrategy(), null);
|
|
344
|
+
assert.equal(options.maxRetriesPerRequest, 0);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
on() {}
|
|
348
|
+
|
|
349
|
+
async connect() {
|
|
350
|
+
if (fail) throw new Error('synthetic-driver-error');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async scan(_cursor: string, _match: string, pattern: string) {
|
|
354
|
+
assert.equal(pattern, `${resource.prefix.slice(0, -'queue:'.length)}*`);
|
|
355
|
+
return ['0', []];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
disconnect() {
|
|
359
|
+
redisClosed++;
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
});
|
|
364
|
+
assert.equal(
|
|
365
|
+
await readers.databaseExists(plan, {
|
|
366
|
+
connection: { user: 'synthetic', password: 'synthetic-password', beforeConnect() {} },
|
|
367
|
+
pool: { afterCreate() {} },
|
|
368
|
+
}),
|
|
369
|
+
false,
|
|
370
|
+
);
|
|
371
|
+
assert.equal(sqlClosed, 1);
|
|
372
|
+
assert.equal(await readers.redisExists(resource, {}), false);
|
|
373
|
+
fail = true;
|
|
374
|
+
await assert.rejects(readers.redisExists(resource, {}), /synthetic-driver-error/);
|
|
375
|
+
assert.equal(redisClosed, 2);
|
|
376
|
+
} finally {
|
|
377
|
+
rmSync(f.root, { recursive: true, force: true });
|
|
378
|
+
}
|
|
379
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import type { VonaApplication } from './application.ts';
|
|
6
|
+
import type { TestClaimDatabasePlan, TestClaimRedis } from './testClaim.ts';
|
|
7
|
+
|
|
8
|
+
import { assertIsolatedTestConfig, assertIsolatedTestPath } from './isolationGuard.ts';
|
|
9
|
+
import {
|
|
10
|
+
buildTestClaimResourceInventory,
|
|
11
|
+
mergeTestClaimDatabaseOptions,
|
|
12
|
+
mergeTestClaimRedisOptions,
|
|
13
|
+
} from './testClaim.ts';
|
|
14
|
+
import { resolveVonaLocalPaths } from './testIsolation.ts';
|
|
15
|
+
import { requiresFreshTestResources } from './testStrictMode.ts';
|
|
16
|
+
|
|
17
|
+
export { requiresFreshTestResources, TEST_REQUIRE_FRESH_RESOURCES_ENV } from './testStrictMode.ts';
|
|
18
|
+
|
|
19
|
+
const freshApps = new WeakSet<VonaApplication>();
|
|
20
|
+
|
|
21
|
+
export function assertFreshTestResourcesReady(app: VonaApplication) {
|
|
22
|
+
if (!requiresFreshTestResources()) return;
|
|
23
|
+
if (!freshApps.has(app)) throw new Error('Strict test resource freshness was not established');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TestFreshnessDependencies {
|
|
27
|
+
databaseExists(plan: TestClaimDatabasePlan, options: Record<string, any>): Promise<boolean>;
|
|
28
|
+
redisExists(resource: TestClaimRedis, options: Record<string, any>): Promise<boolean>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function assertEmptyDirectory(directory: string) {
|
|
32
|
+
assertIsolatedTestPath(directory, directory);
|
|
33
|
+
try {
|
|
34
|
+
if (!(await lstat(directory)).isDirectory() || (await readdir(directory)).length !== 0) {
|
|
35
|
+
throw new Error('Strict test local resource is not fresh');
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Runs after configuration, before module loading (including worker-alive Redis writes).
|
|
43
|
+
// This is a freshness snapshot, not a lease against independent external writers.
|
|
44
|
+
export async function assertFreshTestResources(
|
|
45
|
+
app: VonaApplication,
|
|
46
|
+
dependencies?: TestFreshnessDependencies,
|
|
47
|
+
) {
|
|
48
|
+
if (!requiresFreshTestResources()) return;
|
|
49
|
+
if (!app.meta.env.VONA_TEST_RUN_ID || app.meta.appReady || freshApps.has(app)) {
|
|
50
|
+
throw new Error('Strict test freshness requires a new isolated startup');
|
|
51
|
+
}
|
|
52
|
+
assertIsolatedTestConfig(app);
|
|
53
|
+
const config = app.config as typeof app.config & {
|
|
54
|
+
database: {
|
|
55
|
+
base: Record<string, unknown>;
|
|
56
|
+
clients: Record<string, Record<string, unknown>>;
|
|
57
|
+
};
|
|
58
|
+
redis: {
|
|
59
|
+
base: Record<string, unknown>;
|
|
60
|
+
clients: Record<string, Record<string, unknown>>;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
const paths = resolveVonaLocalPaths(app.projectPath, 'test', app.meta.env.VONA_TEST_RUN_ID);
|
|
64
|
+
for (const directory of [paths.publicRoot, paths.runtimeRoot, paths.sqliteRoot]) {
|
|
65
|
+
await assertEmptyDirectory(directory);
|
|
66
|
+
}
|
|
67
|
+
const inventory = buildTestClaimResourceInventory(app);
|
|
68
|
+
const readers = dependencies ?? createFreshnessDependencies(app.projectPath);
|
|
69
|
+
try {
|
|
70
|
+
for (const plan of inventory.databases) {
|
|
71
|
+
if (plan.endpoint.dialect === 'better-sqlite3') continue;
|
|
72
|
+
const options = mergeTestClaimDatabaseOptions(
|
|
73
|
+
config.database.base,
|
|
74
|
+
undefined,
|
|
75
|
+
config.database.clients[plan.clientName],
|
|
76
|
+
);
|
|
77
|
+
if (await readers.databaseExists(plan, options)) {
|
|
78
|
+
throw new Error('Preexisting strict test database namespace');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const resource of inventory.redis) {
|
|
82
|
+
const options = mergeTestClaimRedisOptions(
|
|
83
|
+
config.redis.base,
|
|
84
|
+
config.redis.clients[resource.clientName],
|
|
85
|
+
);
|
|
86
|
+
if (await readers.redisExists(resource, options)) {
|
|
87
|
+
throw new Error('Preexisting strict test Redis namespace');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// Driver messages may embed credentials, database names or Redis keys.
|
|
92
|
+
throw new Error('Strict test resource freshness check failed');
|
|
93
|
+
}
|
|
94
|
+
freshApps.add(app);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface FreshnessSqlConnection {
|
|
98
|
+
raw(sql: string, bindings: unknown[]): Promise<any>;
|
|
99
|
+
destroy(): Promise<unknown>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function hasFreshnessDatabase(
|
|
103
|
+
connection: FreshnessSqlConnection,
|
|
104
|
+
plan: TestClaimDatabasePlan,
|
|
105
|
+
) {
|
|
106
|
+
try {
|
|
107
|
+
const pg = plan.endpoint.dialect === 'pg';
|
|
108
|
+
if (!pg && !['mysql', 'mysql2'].includes(plan.endpoint.dialect)) {
|
|
109
|
+
throw new Error('Unsupported freshness database dialect');
|
|
110
|
+
}
|
|
111
|
+
const result = await connection.raw(
|
|
112
|
+
pg
|
|
113
|
+
? 'select datname from pg_database where left(datname, ?) = ? limit 1'
|
|
114
|
+
: 'select schema_name from information_schema.schemata where left(schema_name, ?) = ? limit 1',
|
|
115
|
+
[plan.prefix.length, plan.prefix],
|
|
116
|
+
);
|
|
117
|
+
const rows = pg ? result?.rows : result?.[0];
|
|
118
|
+
if (!Array.isArray(rows)) throw new Error('Invalid freshness database response');
|
|
119
|
+
return rows.length !== 0;
|
|
120
|
+
} finally {
|
|
121
|
+
await connection.destroy();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface FreshnessRedisConnection {
|
|
126
|
+
scan(
|
|
127
|
+
cursor: string,
|
|
128
|
+
match: 'MATCH',
|
|
129
|
+
pattern: string,
|
|
130
|
+
count: 'COUNT',
|
|
131
|
+
size: number,
|
|
132
|
+
): Promise<unknown>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function hasFreshnessRedis(
|
|
136
|
+
connection: FreshnessRedisConnection,
|
|
137
|
+
prefix: string,
|
|
138
|
+
maxScans = 10000,
|
|
139
|
+
) {
|
|
140
|
+
if (!prefix || !Number.isSafeInteger(maxScans) || maxScans < 1) {
|
|
141
|
+
throw new Error('Invalid Redis freshness bounds');
|
|
142
|
+
}
|
|
143
|
+
const pattern = `${prefix.replace(/[\\*?[\]]/g, '\\$&')}*`;
|
|
144
|
+
let cursor = '0';
|
|
145
|
+
for (let scans = 0; scans < maxScans; scans++) {
|
|
146
|
+
const result = await connection.scan(cursor, 'MATCH', pattern, 'COUNT', 128);
|
|
147
|
+
if (
|
|
148
|
+
!Array.isArray(result) ||
|
|
149
|
+
result.length !== 2 ||
|
|
150
|
+
typeof result[0] !== 'string' ||
|
|
151
|
+
!/^\d+$/.test(result[0]) ||
|
|
152
|
+
!Array.isArray(result[1]) ||
|
|
153
|
+
result[1].some(key => typeof key !== 'string' || !key.startsWith(prefix))
|
|
154
|
+
) {
|
|
155
|
+
throw new Error('Invalid Redis freshness response');
|
|
156
|
+
}
|
|
157
|
+
if (result[1].length !== 0) return true;
|
|
158
|
+
cursor = result[0];
|
|
159
|
+
if (cursor === '0') return false;
|
|
160
|
+
}
|
|
161
|
+
throw new Error('Redis freshness scan limit exceeded');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function createFreshnessDependencies(
|
|
165
|
+
projectPath: string,
|
|
166
|
+
load: (name: string) => any = createRequire(path.join(projectPath, 'package.json')),
|
|
167
|
+
): TestFreshnessDependencies {
|
|
168
|
+
return {
|
|
169
|
+
async databaseExists(plan, options) {
|
|
170
|
+
const endpoint = plan.endpoint;
|
|
171
|
+
const source = options.connection;
|
|
172
|
+
const target: Record<string, any> = {
|
|
173
|
+
user: source.user,
|
|
174
|
+
password: source.password,
|
|
175
|
+
ssl: source.ssl,
|
|
176
|
+
database: endpoint.adminDatabase,
|
|
177
|
+
connectionTimeoutMillis: 10000,
|
|
178
|
+
connectTimeout: 10000,
|
|
179
|
+
};
|
|
180
|
+
if (endpoint.transport === 'tcp') {
|
|
181
|
+
target.host = endpoint.host;
|
|
182
|
+
target.port = endpoint.port;
|
|
183
|
+
} else if (endpoint.dialect === 'pg') {
|
|
184
|
+
target.host = endpoint.socketPath;
|
|
185
|
+
target.port = endpoint.port;
|
|
186
|
+
} else {
|
|
187
|
+
target.socketPath = endpoint.socketPath;
|
|
188
|
+
}
|
|
189
|
+
const knex = load('knex');
|
|
190
|
+
return await hasFreshnessDatabase(
|
|
191
|
+
knex({
|
|
192
|
+
client: endpoint.dialect,
|
|
193
|
+
connection: target,
|
|
194
|
+
pool: { min: 0, max: 1 },
|
|
195
|
+
acquireConnectionTimeout: 10000,
|
|
196
|
+
}),
|
|
197
|
+
plan,
|
|
198
|
+
);
|
|
199
|
+
},
|
|
200
|
+
async redisExists(resource, options) {
|
|
201
|
+
const endpoint = resource.endpoint;
|
|
202
|
+
const target =
|
|
203
|
+
endpoint.transport === 'socket'
|
|
204
|
+
? { path: endpoint.socketPath }
|
|
205
|
+
: { host: endpoint.host, port: endpoint.port };
|
|
206
|
+
const Redis = load('ioredis').Redis;
|
|
207
|
+
const connection = new Redis({
|
|
208
|
+
...target,
|
|
209
|
+
username: options.username,
|
|
210
|
+
password: options.password,
|
|
211
|
+
tls: options.tls,
|
|
212
|
+
db: endpoint.db,
|
|
213
|
+
keyPrefix: undefined,
|
|
214
|
+
lazyConnect: true,
|
|
215
|
+
connectTimeout: 10000,
|
|
216
|
+
commandTimeout: 10000,
|
|
217
|
+
retryStrategy: () => null,
|
|
218
|
+
maxRetriesPerRequest: 0,
|
|
219
|
+
enableOfflineQueue: false,
|
|
220
|
+
});
|
|
221
|
+
connection.on('error', () => {});
|
|
222
|
+
try {
|
|
223
|
+
await connection.connect();
|
|
224
|
+
const prefix =
|
|
225
|
+
resource.kind === 'queue' ? resource.prefix.slice(0, -'queue:'.length) : resource.prefix;
|
|
226
|
+
return await hasFreshnessRedis(connection, prefix);
|
|
227
|
+
} finally {
|
|
228
|
+
connection.disconnect();
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const TEST_REQUIRE_FRESH_RESOURCES_ENV = 'VONA_TEST_REQUIRE_FRESH_RESOURCES';
|
|
2
|
+
|
|
3
|
+
export function requiresFreshTestResources(env: NodeJS.ProcessEnv = process.env) {
|
|
4
|
+
const value = env[TEST_REQUIRE_FRESH_RESOURCES_ENV];
|
|
5
|
+
if (value !== undefined && value !== 'true') {
|
|
6
|
+
throw new Error('Invalid strict test resource freshness flag');
|
|
7
|
+
}
|
|
8
|
+
return value === 'true';
|
|
9
|
+
}
|
|
@@ -19,6 +19,7 @@ const lifecycleSource = `
|
|
|
19
19
|
'../../types/index.ts': 'export const EnumAppEvent = { AppStartError: "error", AppStarted: "started" };',
|
|
20
20
|
'../core/isolationGuard.ts': 'export function assertIsolatedTestConfig() {}',
|
|
21
21
|
'../core/testClaim.ts': 'export function registerTestClaimResourceInventory() {}',
|
|
22
|
+
'../core/testFreshness.ts': 'export async function assertFreshTestResources() { await globalThis.phase("Freshness"); }',
|
|
22
23
|
'../module/loader.ts': 'export class ModuleLoader { async execute() { await globalThis.phase("Load"); } }',
|
|
23
24
|
'../utils/util.ts': 'export function deepExtend(target, source) { Object.assign(target, source); }',
|
|
24
25
|
};
|
|
@@ -62,7 +63,7 @@ const lifecycleSource = `
|
|
|
62
63
|
},
|
|
63
64
|
};
|
|
64
65
|
const start = new Start(app);
|
|
65
|
-
const order = ['Config', 'Load', 'Start', 'Ready', 'Started'];
|
|
66
|
+
const order = ['Config', 'Freshness', 'Load', 'Start', 'Ready', 'Started'];
|
|
66
67
|
if (failingPhase) {
|
|
67
68
|
await assert.rejects(start.start(), error => error === failure);
|
|
68
69
|
assert.equal(app.meta.appStartError, failure);
|
|
@@ -85,13 +86,13 @@ const lifecycleSource = `
|
|
|
85
86
|
...(failingPhase ? ['error'] : []),
|
|
86
87
|
]);
|
|
87
88
|
if (reachesStarted) {
|
|
88
|
-
assert.deepEqual(events[0].phases, ['Config', 'Load', 'Start', 'Ready']);
|
|
89
|
+
assert.deepEqual(events[0].phases, ['Config', 'Freshness', 'Load', 'Start', 'Ready']);
|
|
89
90
|
}
|
|
90
91
|
assert.equal(await readFile(lockPath, 'utf8'), lockContents);
|
|
91
92
|
console.log('verified');
|
|
92
93
|
`;
|
|
93
94
|
|
|
94
|
-
for (const failingPhase of ['', 'Config', 'Load', 'Start', 'Ready', 'Started']) {
|
|
95
|
+
for (const failingPhase of ['', 'Config', 'Freshness', 'Load', 'Start', 'Ready', 'Started']) {
|
|
95
96
|
test(`Start ignores the legacy preparation lock on ${failingPhase ? `${failingPhase} failure` : 'success'}`, async () => {
|
|
96
97
|
const root = await mkdtemp(path.join(await realpath(tmpdir()), 'vona-start-'));
|
|
97
98
|
try {
|
|
@@ -4,6 +4,7 @@ import type { VonaApplication } from '../core/application.ts';
|
|
|
4
4
|
import { EnumAppEvent } from '../../types/index.ts';
|
|
5
5
|
import { assertIsolatedTestConfig } from '../core/isolationGuard.ts';
|
|
6
6
|
import { registerTestClaimResourceInventory } from '../core/testClaim.ts';
|
|
7
|
+
import { assertFreshTestResources } from '../core/testFreshness.ts';
|
|
7
8
|
import { ModuleLoader } from '../module/loader.ts';
|
|
8
9
|
import { deepExtend } from '../utils/util.ts';
|
|
9
10
|
|
|
@@ -18,6 +19,7 @@ export class Start {
|
|
|
18
19
|
const app = this.app;
|
|
19
20
|
try {
|
|
20
21
|
await this._start_appConfig();
|
|
22
|
+
await assertFreshTestResources(app);
|
|
21
23
|
await this._start_appLoad();
|
|
22
24
|
await this._start_appStart();
|
|
23
25
|
await this._start_appReady();
|