cabloy 5.1.203 → 5.1.205

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 (127) hide show
  1. package/.cabloy-version +1 -1
  2. package/.gitignore +2 -0
  3. package/CHANGELOG.md +21 -0
  4. package/lint-staged.config.mjs +2 -0
  5. package/oxfmt.config.ts +2 -0
  6. package/oxlint.config.ts +2 -0
  7. package/package.json +6 -5
  8. package/repo-agent-governance/managed-assets.json +9 -9
  9. package/repo-agent-governance/policies/repository.md +5 -2
  10. package/repo-agent-governance/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +2 -2
  11. package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +2 -0
  12. package/repo-docs/backend/migration-and-changes.md +13 -12
  13. package/repo-docs/backend/orm-select-guide.md +6 -0
  14. package/repo-docs/backend/runtime-and-flavors.md +2 -1
  15. package/repo-docs/backend/transaction-guide.md +12 -0
  16. package/repo-docs/backend/unit-testing.md +12 -8
  17. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/cover-en-v1.png +0 -0
  18. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/completion-summary.jpg +0 -0
  19. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/multitasking-draft.jpg +0 -0
  20. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/ssr-refresh.jpg +0 -0
  21. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/index.md +216 -0
  22. package/repo-docs/blogs/index.md +15 -0
  23. package/repo-docs/frontend/scripts.md +1 -1
  24. package/repo-docs/fullstack/parallel-worktree-environment.md +6 -6
  25. package/repo-docs/fullstack/ssr-entry-modes.md +19 -0
  26. package/repo-docs/index.md +2 -0
  27. package/repo-docs/reference/repo-scripts.md +22 -1
  28. package/repo-e2e/config/playwright.config.ts +32 -14
  29. package/repo-e2e/docs/specs/blogs-index.spec.ts +23 -6
  30. package/repo-e2e/scripts/e2e.test.ts +39 -0
  31. package/repo-e2e/scripts/e2e.ts +19 -1
  32. package/repo-e2e/scripts/managedTest.test.ts +389 -0
  33. package/repo-e2e/scripts/managedTest.ts +210 -0
  34. package/repo-e2e/scripts/managedTestImports.test.ts +74 -0
  35. package/repo-e2e/scripts/managedTestImports.ts +110 -0
  36. package/repo-e2e/scripts/runE2e.ts +12 -1
  37. package/repo-e2e/scripts/startE2eVona.ts +37 -5
  38. package/repo-e2e/specs/a-commerce.spec.ts +1 -1
  39. package/repo-e2e/specs/cabloy-basic.spec.ts +1 -1
  40. package/repo-e2e/specs/controller-boundary.spec.ts +1 -1
  41. package/repo-e2e/specs/home-user-account.spec.ts +2 -1
  42. package/repo-e2e/specs/markdown.spec.ts +1 -1
  43. package/repo-e2e/specs/routed-dialog.spec.ts +1 -1
  44. package/scripts/runIsolatedTest.test.ts +426 -0
  45. package/scripts/runIsolatedTest.ts +454 -0
  46. package/scripts/testClaimCleanup.test.ts +346 -0
  47. package/scripts/testClaimCleanup.ts +375 -0
  48. package/scripts/testClaimProcesses.test.ts +106 -0
  49. package/scripts/testClaimProcesses.ts +129 -0
  50. package/scripts/testProcessGuardian.ts +163 -0
  51. package/scripts/upgrade.ts +1 -16
  52. package/vona/oxfmt.config.ts +2 -0
  53. package/vona/oxlint.config.ts +2 -0
  54. package/vona/package.original.json +2 -2
  55. package/vona/packages-cli/cli/package.json +1 -1
  56. package/vona/packages-cli/cli/src/play.test.ts +37 -0
  57. package/vona/packages-cli/cli/src/play.ts +23 -1
  58. package/vona/packages-cli/cli-set-api/package.json +1 -1
  59. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dbReset.ts +16 -7
  60. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +13 -7
  61. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.play.ts +5 -1
  62. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.test.ts +12 -7
  63. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/configUtils.ts +21 -2
  64. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/generateVonaMeta.ts +10 -0
  65. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.test.ts +153 -0
  66. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.ts +141 -0
  67. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.test.ts +148 -0
  68. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.ts +127 -0
  69. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.test.ts +47 -0
  70. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.ts +139 -0
  71. package/vona/packages-cli/cli-set-api/src/lib/utils.ts +2 -3
  72. package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_project.json +1 -0
  73. package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_source.json +1 -0
  74. package/vona/packages-cli/cli-set-api/test/binDevMode.test.ts +18 -0
  75. package/vona/packages-cli/cli-set-api/toolsIsolate/dbReset.ts +15 -2
  76. package/vona/packages-cli/cli-set-api/tsconfig.build.json +2 -1
  77. package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
  78. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.test.ts +16 -0
  79. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.ts +3 -2
  80. package/vona/packages-vona/vona/package.json +1 -1
  81. package/vona/packages-vona/vona-core/package.json +1 -1
  82. package/vona/packages-vona/vona-core/src/lib/addon/sqlite3.ts +1 -1
  83. package/vona/packages-vona/vona-core/src/lib/core/config.ts +44 -6
  84. package/vona/packages-vona/vona-core/src/lib/core/configCleanup.test.ts +40 -0
  85. package/vona/packages-vona/vona-core/src/lib/core/index.ts +3 -0
  86. package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.test.ts +301 -0
  87. package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.ts +312 -0
  88. package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +577 -0
  89. package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +985 -0
  90. package/vona/packages-vona/vona-core/src/lib/core/testIsolation.ts +129 -0
  91. package/vona/packages-vona/vona-core/src/lib/framework/createApp.ts +2 -0
  92. package/vona/packages-vona/vona-core/src/lib/framework/start.ts +4 -0
  93. package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +5 -1
  94. package/vona/packages-vona/vona-core/src/types/application/app.ts +2 -0
  95. package/vona/packages-vona/vona-core/src/types/utils/env.ts +1 -0
  96. package/vona/packages-vona/vona-mock/package.json +1 -1
  97. package/vona/pnpm-lock.yaml +40 -40
  98. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentIndexes.test.ts +73 -10
  99. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  100. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  101. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  102. package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
  103. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +15 -1
  104. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +24 -0
  105. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +11 -0
  106. package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
  107. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +15 -0
  108. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  109. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/extend/schemaBuilder.ts +11 -4
  110. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/databaseClient_.ts +6 -1
  111. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/package.json +1 -1
  112. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.betterSqlite3.ts +41 -1
  113. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.mysql.ts +4 -1
  114. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.pg.ts +2 -1
  115. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  116. package/vona/src/suite-vendor/a-vona/modules/a-redis/package.json +1 -1
  117. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/lib/redis.ts +8 -0
  118. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redis.ts +23 -6
  119. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redisClient_.ts +18 -1
  120. package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
  121. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +43 -15
  122. package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
  123. package/vona/src/suite-vendor/a-vona/modules/a-version/src/lib/databaseTestName.ts +3 -37
  124. package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +93 -15
  125. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  126. package/vona/tsconfig.json +1 -0
  127. package/scripts/initTestData.ts +0 -25
@@ -0,0 +1,985 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { constants, lstatSync } from 'node:fs';
3
+ import { mkdir, open, rename, unlink } from 'node:fs/promises';
4
+ import { createServer } from 'node:http';
5
+ import path from 'node:path';
6
+ import { isDeepStrictEqual } from 'node:util';
7
+
8
+ import {
9
+ assertDisposableDatabaseName,
10
+ getDatabaseTestPrefix,
11
+ getTestAppName,
12
+ resolveTestRunId,
13
+ resolveVonaLocalPaths,
14
+ } from './testIsolation.ts';
15
+
16
+ export const TEST_CLAIM_URL_ENV = 'VONA_TEST_CLAIM_URL';
17
+ export const TEST_CLAIM_TOKEN_ENV = 'VONA_TEST_CLAIM_TOKEN';
18
+ export const TEST_CLAIM_DIRECTORY = '.vona-test-claim';
19
+ export type TestClaimKind = 'unit' | 'cov' | 'e2e';
20
+ export type TestClaimEnv = Record<string, string | undefined>;
21
+ export interface TestClaimOwner {
22
+ pid: number;
23
+ startTime: string;
24
+ host: string;
25
+ }
26
+ export interface TestClaimDatabaseEndpoint {
27
+ dialect: 'pg' | 'mysql' | 'mysql2' | 'better-sqlite3';
28
+ transport: 'tcp' | 'socket' | 'file';
29
+ host?: string;
30
+ port?: number;
31
+ socketPath?: string;
32
+ tls: boolean;
33
+ adminDatabase?: string;
34
+ }
35
+ export interface TestClaimRedisEndpoint {
36
+ transport: 'tcp' | 'socket';
37
+ host?: string;
38
+ port?: number;
39
+ socketPath?: string;
40
+ tls: boolean;
41
+ db: number;
42
+ }
43
+ export interface TestClaimDatabasePlan {
44
+ clientName: string;
45
+ scope: string;
46
+ prefix: string;
47
+ endpoint: TestClaimDatabaseEndpoint;
48
+ }
49
+ export interface TestClaimDatabase extends TestClaimDatabasePlan {
50
+ creations: { name: string; stage: 'intent' | 'created' | 'released' }[];
51
+ }
52
+ export interface TestClaimRedis {
53
+ clientName: string;
54
+ kind: 'client' | 'queue' | 'redlock';
55
+ prefix: string;
56
+ endpoint: TestClaimRedisEndpoint;
57
+ released: boolean;
58
+ }
59
+ export interface TestClaimProcess {
60
+ id: string;
61
+ role: string;
62
+ stage: 'pending' | 'registered' | 'drained';
63
+ pid?: number;
64
+ pgid?: number;
65
+ startTime?: string;
66
+ host?: string;
67
+ profilePath?: string;
68
+ }
69
+ export type TestClaimLocalKind = 'generated' | 'data';
70
+ export type TestClaimLocalStage = 'intent' | 'created' | 'owned' | 'released';
71
+ export interface TestClaimManifest {
72
+ version: 1;
73
+ runId: string;
74
+ createdAt: string;
75
+ projectPath: string;
76
+ kind: TestClaimKind;
77
+ owner: TestClaimOwner;
78
+ status: 'allocated' | 'running' | 'failed' | 'cleaning';
79
+ locals: { kind: TestClaimLocalKind; path: string; stage: TestClaimLocalStage }[];
80
+ databases: TestClaimDatabase[];
81
+ redis: TestClaimRedis[];
82
+ processes: TestClaimProcess[];
83
+ cleanup: { processes: boolean; databases: boolean; redis: boolean; locals: boolean };
84
+ }
85
+ export type TestClaimEvent =
86
+ | { type: 'local'; kind: TestClaimLocalKind; stage: TestClaimLocalStage }
87
+ | { type: 'status'; status: TestClaimManifest['status'] }
88
+ | { type: 'process'; process: TestClaimProcess }
89
+ | { type: 'inventory'; databases: TestClaimDatabasePlan[]; redis: TestClaimRedis[] }
90
+ | {
91
+ type: 'database';
92
+ database: TestClaimDatabasePlan;
93
+ name: string;
94
+ stage: 'intent' | 'created' | 'released';
95
+ }
96
+ | { type: 'cleanup'; resource: keyof TestClaimManifest['cleanup']; complete: boolean };
97
+
98
+ export function getTestClaimPaths(projectPath: string, runId: string) {
99
+ resolveTestRunId('test', runId);
100
+ const project = path.resolve(projectPath);
101
+ const local = resolveVonaLocalPaths(project, 'test', runId);
102
+ const claimDirectory = path.join(project, TEST_CLAIM_DIRECTORY);
103
+ return {
104
+ ...local,
105
+ claimDirectory,
106
+ claimPath: path.join(claimDirectory, `${runId}.json`),
107
+ };
108
+ }
109
+
110
+ export function getTestClaimCreatedAt(runId: string) {
111
+ resolveTestRunId('test', runId);
112
+ const s = runId.slice(0, 14);
113
+ return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(8, 10)}:${s.slice(10, 12)}:${s.slice(12, 14)}.000Z`;
114
+ }
115
+
116
+ export function assertTestClaimSafePath(file: string, allowMissing = true) {
117
+ let current = path.resolve(file);
118
+ while (true) {
119
+ try {
120
+ const stat = lstatSync(current);
121
+ if (stat.isSymbolicLink() || (current !== path.resolve(file) && !stat.isDirectory())) {
122
+ throw new Error('Unsafe test claim path');
123
+ }
124
+ } catch (error) {
125
+ if (!allowMissing || (error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
126
+ }
127
+ const parent = path.dirname(current);
128
+ if (parent === current) break;
129
+ current = parent;
130
+ }
131
+ }
132
+
133
+ function record(value: unknown): asserts value is Record<string, any> {
134
+ if (!value || typeof value !== 'object' || Array.isArray(value))
135
+ throw new Error('Invalid test claim object');
136
+ }
137
+ function keys(value: unknown, allowed: string[]) {
138
+ record(value);
139
+ if (Object.keys(value).some(key => !allowed.includes(key)))
140
+ throw new Error('Unknown test claim field');
141
+ }
142
+ function text(value: unknown): asserts value is string {
143
+ if (
144
+ typeof value !== 'string' ||
145
+ !value ||
146
+ value.length > 4096 ||
147
+ [...value].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)
148
+ ) {
149
+ throw new Error('Invalid test claim text');
150
+ }
151
+ }
152
+ function label(value: unknown): asserts value is string {
153
+ text(value);
154
+ if (!/^[\w.-]+$/.test(value)) throw new Error('Invalid test claim label');
155
+ }
156
+ function integer(value: unknown, min: number, max = Number.MAX_SAFE_INTEGER) {
157
+ if (!Number.isSafeInteger(value) || (value as number) < min || (value as number) > max)
158
+ throw new Error('Invalid test claim integer');
159
+ }
160
+ function oneOf(value: unknown, values: string[]) {
161
+ if (!values.includes(value as string)) throw new Error('Invalid test claim state');
162
+ }
163
+ function bool(value: unknown) {
164
+ if (typeof value !== 'boolean') throw new Error('Invalid test claim boolean');
165
+ }
166
+ function array(value: unknown): asserts value is any[] {
167
+ if (!Array.isArray(value) || value.length > 10000) throw new Error('Invalid test claim array');
168
+ }
169
+ function unique(values: string[]) {
170
+ if (new Set(values).size !== values.length) throw new Error('Duplicate test claim resource');
171
+ }
172
+ function validateEndpoint(value: unknown, redis: boolean) {
173
+ keys(
174
+ value,
175
+ redis
176
+ ? ['transport', 'host', 'port', 'socketPath', 'tls', 'db']
177
+ : ['dialect', 'transport', 'host', 'port', 'socketPath', 'tls', 'adminDatabase'],
178
+ );
179
+ const endpoint = value as TestClaimDatabaseEndpoint & { db?: number };
180
+ bool(endpoint.tls);
181
+ oneOf(endpoint.transport, redis ? ['tcp', 'socket'] : ['tcp', 'socket', 'file']);
182
+ if (redis) integer(endpoint.db, 0);
183
+ else oneOf(endpoint.dialect, ['pg', 'mysql', 'mysql2', 'better-sqlite3']);
184
+ if (endpoint.transport === 'tcp') {
185
+ text(endpoint.host);
186
+ if (/[\s/@?#\\]/.test(endpoint.host!)) throw new Error('Invalid test claim host');
187
+ integer(endpoint.port, 1, 65535);
188
+ if (endpoint.socketPath !== undefined) throw new Error('Conflicting test claim endpoint');
189
+ } else {
190
+ text(endpoint.socketPath);
191
+ if (
192
+ !path.isAbsolute(endpoint.socketPath!) ||
193
+ path.resolve(endpoint.socketPath!) !== endpoint.socketPath ||
194
+ endpoint.host !== undefined
195
+ ) {
196
+ throw new Error('Invalid test claim socket');
197
+ }
198
+ if (!redis && endpoint.dialect === 'pg' && endpoint.transport === 'socket')
199
+ integer(endpoint.port, 1, 65535);
200
+ else if (endpoint.port !== undefined) throw new Error('Invalid test claim socket port');
201
+ }
202
+ if (!redis) {
203
+ if (endpoint.dialect === 'better-sqlite3') {
204
+ if (endpoint.transport !== 'file' || endpoint.tls || endpoint.adminDatabase !== undefined)
205
+ throw new Error('Invalid SQLite claim endpoint');
206
+ } else if (
207
+ endpoint.transport === 'file' ||
208
+ endpoint.adminDatabase !== (endpoint.dialect === 'pg' ? 'postgres' : 'mysql')
209
+ ) {
210
+ throw new Error('Invalid database administration endpoint');
211
+ }
212
+ }
213
+ }
214
+ export function validateTestClaim(value: unknown, claimPath?: string): TestClaimManifest {
215
+ keys(value, [
216
+ 'version',
217
+ 'runId',
218
+ 'createdAt',
219
+ 'projectPath',
220
+ 'kind',
221
+ 'owner',
222
+ 'status',
223
+ 'locals',
224
+ 'databases',
225
+ 'redis',
226
+ 'processes',
227
+ 'cleanup',
228
+ ]);
229
+ const m = value as TestClaimManifest;
230
+ if (m.version !== 1) throw new Error('Unsupported test claim version');
231
+ resolveTestRunId('test', m.runId);
232
+ if (m.createdAt !== getTestClaimCreatedAt(m.runId))
233
+ throw new Error('Invalid test claim creation time');
234
+ text(m.projectPath);
235
+ if (!path.isAbsolute(m.projectPath) || path.resolve(m.projectPath) !== m.projectPath)
236
+ throw new Error('Invalid test claim checkout');
237
+ const p = getTestClaimPaths(m.projectPath, m.runId);
238
+ if (claimPath && path.resolve(claimPath) !== p.claimPath)
239
+ throw new Error('Test claim filename or checkout mismatch');
240
+ oneOf(m.kind, ['unit', 'cov', 'e2e']);
241
+ keys(m.owner, ['pid', 'startTime', 'host']);
242
+ integer(m.owner.pid, 1);
243
+ text(m.owner.startTime);
244
+ text(m.owner.host);
245
+ oneOf(m.status, ['allocated', 'running', 'failed', 'cleaning']);
246
+ array(m.locals);
247
+ unique(m.locals.map(item => item.kind));
248
+ for (const item of m.locals) {
249
+ keys(item, ['kind', 'path', 'stage']);
250
+ oneOf(item.kind, ['generated', 'data']);
251
+ oneOf(item.stage, ['intent', 'created', 'owned', 'released']);
252
+ const expected = item.kind === 'generated' ? p.generatedRoot : p.dataRoot;
253
+ if (item.path !== expected) throw new Error('Invalid test claim local root');
254
+ }
255
+ if (
256
+ m.locals.length !== 2 ||
257
+ !m.locals.some(item => item.kind === 'data') ||
258
+ !m.locals.some(item => item.kind === 'generated')
259
+ ) {
260
+ throw new Error('Missing test claim local roots');
261
+ }
262
+ array(m.databases);
263
+ unique(m.databases.map(item => `${item.clientName}\0${item.scope}`));
264
+ for (const db of m.databases) {
265
+ keys(db, ['clientName', 'scope', 'prefix', 'endpoint', 'creations']);
266
+ label(db.clientName);
267
+ text(db.scope);
268
+ if (db.scope !== 'share' && !/^isolate-[\w-]+$/.test(db.scope))
269
+ throw new Error('Invalid test claim database scope');
270
+ if (db.prefix !== getDatabaseTestPrefix(getTestAppName(m.runId), db.scope))
271
+ throw new Error('Invalid test claim database prefix');
272
+ validateEndpoint(db.endpoint, false);
273
+ if (db.endpoint.dialect === 'better-sqlite3' && db.endpoint.socketPath !== p.sqliteRoot)
274
+ throw new Error('Invalid test claim SQLite root');
275
+ array(db.creations);
276
+ unique(db.creations.map(item => item.name));
277
+ for (const creation of db.creations) {
278
+ keys(creation, ['name', 'stage']);
279
+ oneOf(creation.stage, ['intent', 'created', 'released']);
280
+ text(creation.name);
281
+ let name = creation.name;
282
+ if (db.endpoint.dialect === 'better-sqlite3') {
283
+ if (name !== path.join(p.sqliteRoot, path.basename(name)) || path.extname(name) !== '.db')
284
+ throw new Error('Invalid test claim SQLite target');
285
+ name = path.basename(name, '.db');
286
+ }
287
+ assertDisposableDatabaseName(name, db.prefix);
288
+ }
289
+ }
290
+ array(m.redis);
291
+ unique(m.redis.map(item => item.clientName));
292
+ for (const item of m.redis) {
293
+ keys(item, ['clientName', 'kind', 'prefix', 'endpoint', 'released']);
294
+ label(item.clientName);
295
+ oneOf(item.kind, ['client', 'queue', 'redlock']);
296
+ const expected = getTestClaimRedisPrefix(m.runId, item.clientName);
297
+ if (!expected || item.kind !== expected.kind || item.prefix !== expected.prefix)
298
+ throw new Error('Invalid test claim Redis prefix');
299
+ validateEndpoint(item.endpoint, true);
300
+ bool(item.released);
301
+ }
302
+ array(m.processes);
303
+ unique(m.processes.map(item => item.id));
304
+ for (const proc of m.processes) {
305
+ keys(proc, ['id', 'role', 'stage', 'pid', 'pgid', 'startTime', 'host', 'profilePath']);
306
+ label(proc.id);
307
+ oneOf(proc.role, ['guardian', 'browser', 'cleanup']);
308
+ if (proc.host !== undefined) text(proc.host);
309
+ oneOf(proc.stage, ['pending', 'registered', 'drained']);
310
+ const hasIdentity =
311
+ proc.pid !== undefined || proc.pgid !== undefined || proc.startTime !== undefined;
312
+ if (hasIdentity || proc.stage !== 'pending') {
313
+ integer(proc.pid, 1);
314
+ integer(proc.pgid, 1);
315
+ text(proc.startTime);
316
+ }
317
+ if (proc.profilePath !== undefined) {
318
+ text(proc.profilePath);
319
+ if (
320
+ path.resolve(proc.profilePath) !== proc.profilePath ||
321
+ !proc.profilePath.startsWith(`${p.dataRoot}${path.sep}`)
322
+ ) {
323
+ throw new Error('Invalid test claim process profile');
324
+ }
325
+ }
326
+ }
327
+ keys(m.cleanup, ['processes', 'databases', 'redis', 'locals']);
328
+ for (const key of ['processes', 'databases', 'redis', 'locals']) bool(m.cleanup[key]);
329
+ return m;
330
+ }
331
+
332
+ function assertPrivateFile(file: string) {
333
+ assertTestClaimSafePath(file, false);
334
+ const stat = lstatSync(file);
335
+ if (
336
+ !stat.isFile() ||
337
+ stat.nlink !== 1 ||
338
+ (stat.mode & 0o077) !== 0 ||
339
+ (process.getuid && stat.uid !== process.getuid())
340
+ ) {
341
+ throw new Error('Unsafe test claim file');
342
+ }
343
+ }
344
+ export async function loadTestClaim(claimPath: string) {
345
+ assertPrivateFile(claimPath);
346
+ const handle = await open(claimPath, constants.O_RDONLY | constants.O_NOFOLLOW);
347
+ try {
348
+ const stat = await handle.stat();
349
+ if (!stat.isFile() || stat.size > 4 * 1024 * 1024) throw new Error('Invalid test claim file');
350
+ return validateTestClaim(parseTestClaimJson(await handle.readFile('utf8')), claimPath);
351
+ } finally {
352
+ await handle.close();
353
+ }
354
+ }
355
+ function parseTestClaimJson(source: string) {
356
+ // Claim identities stay literal even when @cabloy/json5 revives ISO strings.
357
+ return JSON.parse(source, function (this: Record<string, unknown>, key: string) {
358
+ return this[key];
359
+ });
360
+ }
361
+ async function syncDirectory(directory: string) {
362
+ const handle = await open(directory, constants.O_RDONLY);
363
+ try {
364
+ await handle.sync();
365
+ } finally {
366
+ await handle.close();
367
+ }
368
+ }
369
+ export async function createTestClaim(
370
+ projectPath: string,
371
+ runId: string,
372
+ kind: TestClaimKind,
373
+ owner: TestClaimOwner,
374
+ ) {
375
+ const p = getTestClaimPaths(projectPath, runId);
376
+ assertTestClaimSafePath(p.claimDirectory);
377
+ await mkdir(p.claimDirectory, { mode: 0o700 }).catch(error => {
378
+ if (error.code !== 'EEXIST') throw error;
379
+ });
380
+ // The directory is shared across runs; an existing directory is valid, never an existing claim.
381
+ return createClaimFile(p, runId, kind, owner);
382
+ }
383
+ async function createClaimFile(
384
+ p: ReturnType<typeof getTestClaimPaths>,
385
+ runId: string,
386
+ kind: TestClaimKind,
387
+ owner: TestClaimOwner,
388
+ ) {
389
+ assertTestClaimSafePath(p.claimDirectory, false);
390
+ const directoryStat = lstatSync(p.claimDirectory);
391
+ if (
392
+ !directoryStat.isDirectory() ||
393
+ (directoryStat.mode & 0o077) !== 0 ||
394
+ (process.getuid && directoryStat.uid !== process.getuid())
395
+ ) {
396
+ throw new Error('Unsafe shared test claim directory');
397
+ }
398
+ const manifest: TestClaimManifest = {
399
+ version: 1,
400
+ runId,
401
+ createdAt: getTestClaimCreatedAt(runId),
402
+ projectPath: path.dirname(p.claimDirectory),
403
+ kind,
404
+ owner,
405
+ status: 'allocated',
406
+ locals: [
407
+ { kind: 'generated', path: p.generatedRoot, stage: 'intent' },
408
+ { kind: 'data', path: p.dataRoot, stage: 'intent' },
409
+ ],
410
+ databases: [],
411
+ redis: [],
412
+ processes: [],
413
+ cleanup: { processes: false, databases: false, redis: false, locals: false },
414
+ };
415
+ validateTestClaim(manifest, p.claimPath);
416
+ const handle = await open(
417
+ p.claimPath,
418
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
419
+ 0o600,
420
+ );
421
+ try {
422
+ await handle.writeFile(`${JSON.stringify(manifest, null, 2)}\n`);
423
+ await handle.sync();
424
+ } finally {
425
+ await handle.close();
426
+ }
427
+ await syncDirectory(p.claimDirectory);
428
+ return { claimPath: p.claimPath, manifest };
429
+ }
430
+
431
+ // Only the live broker or a cleanup editor holding the exclusive cleanup lock may call this.
432
+ export async function writeTestClaim(claimPath: string, manifest: TestClaimManifest) {
433
+ validateTestClaim(manifest, claimPath);
434
+ const previous = await loadTestClaim(claimPath);
435
+ for (const key of ['version', 'runId', 'createdAt', 'projectPath', 'kind', 'owner'] as const) {
436
+ if (!isDeepStrictEqual(previous[key], manifest[key]))
437
+ throw new Error('Test claim identity is immutable');
438
+ }
439
+ const temp = `${claimPath}.${randomBytes(12).toString('hex')}.tmp`;
440
+ const handle = await open(
441
+ temp,
442
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
443
+ 0o600,
444
+ );
445
+ try {
446
+ try {
447
+ await handle.writeFile(`${JSON.stringify(manifest, null, 2)}\n`);
448
+ await handle.sync();
449
+ } finally {
450
+ await handle.close();
451
+ }
452
+ assertPrivateFile(claimPath);
453
+ await rename(temp, claimPath);
454
+ await syncDirectory(path.dirname(claimPath));
455
+ } finally {
456
+ await unlink(temp).catch(error => {
457
+ if (error.code !== 'ENOENT') throw error;
458
+ });
459
+ }
460
+ }
461
+
462
+ function applyEvent(m: TestClaimManifest, event: TestClaimEvent) {
463
+ record(event);
464
+ switch (event.type) {
465
+ case 'local': {
466
+ keys(event, ['type', 'kind', 'stage']);
467
+ const local = m.locals.find(item => item.kind === event.kind);
468
+ if (!local) throw new Error('Unplanned test claim local root');
469
+ const stages = ['intent', 'created', 'owned', 'released'];
470
+ if (stages.indexOf(event.stage) < stages.indexOf(local.stage))
471
+ throw new Error('Test claim local stage regression');
472
+ local.stage = event.stage;
473
+ break;
474
+ }
475
+ case 'status':
476
+ keys(event, ['type', 'status']);
477
+ m.status = event.status;
478
+ break;
479
+ case 'process': {
480
+ keys(event, ['type', 'process']);
481
+ const old = m.processes.find(item => item.id === event.process.id);
482
+ if (!old) {
483
+ if (event.process.stage !== 'pending')
484
+ throw new Error('Process needs a write-ahead intent');
485
+ m.processes.push(event.process);
486
+ } else {
487
+ if (old.role !== event.process.role) throw new Error('Test claim process identity changed');
488
+ if (
489
+ event.process.profilePath !== undefined &&
490
+ old.profilePath !== event.process.profilePath
491
+ ) {
492
+ if (
493
+ old.stage !== 'pending' ||
494
+ event.process.stage !== 'registered' ||
495
+ !old.profilePath ||
496
+ !event.process.profilePath.startsWith(`${old.profilePath}${path.sep}`)
497
+ ) {
498
+ throw new Error('Test claim process profile changed');
499
+ }
500
+ }
501
+ if (
502
+ (old.stage === 'drained' && event.process.stage !== 'drained') ||
503
+ (old.stage === 'registered' && event.process.stage === 'pending')
504
+ ) {
505
+ throw new Error('Test claim process stage regression');
506
+ }
507
+ if (old.stage === 'pending' && event.process.stage === 'drained')
508
+ throw new Error('Pending process cannot establish drainage');
509
+ for (const key of ['pid', 'pgid', 'startTime', 'host'] as const) {
510
+ if (
511
+ old[key] !== undefined &&
512
+ event.process[key] !== undefined &&
513
+ old[key] !== event.process[key]
514
+ ) {
515
+ throw new Error('Test claim process identity changed');
516
+ }
517
+ }
518
+ Object.assign(old, event.process);
519
+ }
520
+ break;
521
+ }
522
+ case 'inventory': {
523
+ keys(event, ['type', 'databases', 'redis']);
524
+ for (const plan of event.databases) {
525
+ keys(plan, ['clientName', 'scope', 'prefix', 'endpoint']);
526
+ const old = m.databases.find(
527
+ item => item.clientName === plan.clientName && item.scope === plan.scope,
528
+ );
529
+ if (old) {
530
+ const { creations: _, ...oldPlan } = old;
531
+ if (!isDeepStrictEqual(oldPlan, plan)) throw new Error('Database inventory drift');
532
+ } else {
533
+ m.databases.push({ ...plan, creations: [] });
534
+ }
535
+ }
536
+ for (const resource of event.redis) {
537
+ const old = m.redis.find(item => item.clientName === resource.clientName);
538
+ if (old && !isDeepStrictEqual(old, resource)) throw new Error('Redis inventory drift');
539
+ if (!old) m.redis.push(resource);
540
+ }
541
+ break;
542
+ }
543
+ case 'database': {
544
+ keys(event, ['type', 'database', 'name', 'stage']);
545
+ keys(event.database, ['clientName', 'scope', 'prefix', 'endpoint']);
546
+ const db = m.databases.find(
547
+ item =>
548
+ item.clientName === event.database.clientName && item.scope === event.database.scope,
549
+ );
550
+ if (!db) throw new Error('Unplanned database creation');
551
+ const { creations: _, ...plan } = db;
552
+ if (!isDeepStrictEqual(plan, event.database))
553
+ throw new Error('Database creation endpoint drift');
554
+ const old = db.creations.find(item => item.name === event.name);
555
+ if (!old) {
556
+ if (event.stage !== 'intent') throw new Error('Database needs a write-ahead intent');
557
+ db.creations.push({ name: event.name, stage: event.stage });
558
+ } else {
559
+ if (
560
+ ['intent', 'created', 'released'].indexOf(event.stage) <
561
+ ['intent', 'created', 'released'].indexOf(old.stage)
562
+ ) {
563
+ throw new Error('Database creation stage regression');
564
+ }
565
+ old.stage = event.stage;
566
+ }
567
+ break;
568
+ }
569
+ case 'cleanup':
570
+ keys(event, ['type', 'resource', 'complete']);
571
+ if (!(event.resource in m.cleanup)) throw new Error('Unknown cleanup resource');
572
+ m.cleanup[event.resource] = event.complete;
573
+ break;
574
+ default:
575
+ throw new Error('Unknown test claim event');
576
+ }
577
+ validateTestClaim(m);
578
+ }
579
+
580
+ export async function startTestClaimBroker(claimPath: string) {
581
+ await loadTestClaim(claimPath);
582
+ const token = randomBytes(32).toString('hex');
583
+ let queue: Promise<void> = Promise.resolve();
584
+ let closing = false;
585
+ const update = (event: TestClaimEvent) => {
586
+ if (closing) return Promise.reject(new Error('Test claim broker is closed'));
587
+ const result = queue.then(async () => {
588
+ const manifest = await loadTestClaim(claimPath);
589
+ applyEvent(manifest, event);
590
+ await writeTestClaim(claimPath, manifest);
591
+ });
592
+ queue = result.catch(() => {});
593
+ return result;
594
+ };
595
+ const server = createServer(async (req, res) => {
596
+ const auth = req.headers.authorization;
597
+ const expected = `Bearer ${token}`;
598
+ if (
599
+ req.method !== 'POST' ||
600
+ req.url !== '/event' ||
601
+ typeof auth !== 'string' ||
602
+ Buffer.byteLength(auth) !== Buffer.byteLength(expected) ||
603
+ !timingSafeEqual(Buffer.from(auth), Buffer.from(expected))
604
+ ) {
605
+ res.writeHead(403).end();
606
+ req.resume();
607
+ return;
608
+ }
609
+ try {
610
+ let body = '';
611
+ for await (const chunk of req) {
612
+ body += chunk.toString('utf8');
613
+ if (Buffer.byteLength(body) > 1024 * 1024) throw new Error('Test claim event too large');
614
+ }
615
+ await update(parseTestClaimJson(body));
616
+ res.writeHead(204).end();
617
+ } catch {
618
+ res.writeHead(409).end('Test claim registration rejected');
619
+ }
620
+ });
621
+ server.requestTimeout = 10000;
622
+ server.headersTimeout = 10000;
623
+ await new Promise<void>((resolve, reject) => {
624
+ server.once('error', reject);
625
+ server.listen(0, '127.0.0.1', resolve);
626
+ });
627
+ const address = server.address();
628
+ if (!address || typeof address === 'string') throw new Error('Cannot bind test claim broker');
629
+ return {
630
+ env: {
631
+ [TEST_CLAIM_URL_ENV]: `http://127.0.0.1:${address.port}/event`,
632
+ [TEST_CLAIM_TOKEN_ENV]: token,
633
+ },
634
+ update,
635
+ async close() {
636
+ closing = true;
637
+ await new Promise<void>((resolve, reject) =>
638
+ server.close(error => (error ? reject(error) : resolve())),
639
+ );
640
+ await queue;
641
+ },
642
+ };
643
+ }
644
+ export function hasTestClaimTransport(env: TestClaimEnv = process.env) {
645
+ const url = env[TEST_CLAIM_URL_ENV];
646
+ const token = env[TEST_CLAIM_TOKEN_ENV];
647
+ if (url === undefined && token === undefined) return false;
648
+ if (!url || !token) throw new Error('Incomplete managed test claim transport');
649
+ if (!resolveTestRunId(env.META_MODE!, env.VONA_TEST_RUN_ID))
650
+ throw new Error('Managed test claim transport requires test identity');
651
+ let parsed: URL;
652
+ try {
653
+ parsed = new URL(url);
654
+ } catch {
655
+ throw new Error('Invalid managed test claim transport');
656
+ }
657
+ if (
658
+ parsed.protocol !== 'http:' ||
659
+ parsed.hostname !== '127.0.0.1' ||
660
+ !parsed.port ||
661
+ parsed.pathname !== '/event' ||
662
+ parsed.username ||
663
+ parsed.password ||
664
+ parsed.search ||
665
+ parsed.hash ||
666
+ !/^[a-f0-9]{64}$/.test(token)
667
+ ) {
668
+ throw new Error('Invalid managed test claim transport');
669
+ }
670
+ return true;
671
+ }
672
+ export async function sendTestClaimEvent(env: TestClaimEnv, event: TestClaimEvent) {
673
+ if (!hasTestClaimTransport(env)) return;
674
+ try {
675
+ const response = await fetch(env[TEST_CLAIM_URL_ENV]!, {
676
+ method: 'POST',
677
+ headers: {
678
+ 'authorization': `Bearer ${env[TEST_CLAIM_TOKEN_ENV]}`,
679
+ 'content-type': 'application/json',
680
+ },
681
+ body: JSON.stringify(event),
682
+ signal: AbortSignal.timeout(10000),
683
+ redirect: 'error',
684
+ });
685
+ if (response.status !== 204) throw new Error('Registration rejected');
686
+ } catch {
687
+ throw new Error('Managed test allocation registration failed');
688
+ }
689
+ }
690
+
691
+ export function getTestClaimRedisPrefix(
692
+ runId: string,
693
+ clientName: string,
694
+ ): { kind: TestClaimRedis['kind']; prefix: string } | undefined {
695
+ const appName = getTestAppName(runId);
696
+ if (clientName === 'broadcast') return undefined;
697
+ if (clientName === 'queue') return { kind: 'queue', prefix: `bull_${appName}_local:queue:` };
698
+ if (clientName.startsWith('redlock'))
699
+ return { kind: 'redlock', prefix: `redlock_${appName}_local:` };
700
+ return { kind: 'client', prefix: `${clientName}_${appName}_local:` };
701
+ }
702
+
703
+ function assertNoTlsTargetOverrides(tls: unknown) {
704
+ if (!tls) return;
705
+ if (typeof tls === 'string') throw new Error('Unsupported managed TLS profile');
706
+ if (typeof tls === 'object') {
707
+ for (const key of [
708
+ 'host',
709
+ 'port',
710
+ 'path',
711
+ 'socketPath',
712
+ 'socket',
713
+ 'stream',
714
+ 'lookup',
715
+ 'createConnection',
716
+ 'servername',
717
+ ]) {
718
+ if ((tls as any)[key] !== undefined)
719
+ throw new Error('Unsupported managed TLS target override');
720
+ }
721
+ }
722
+ }
723
+ // Only explicit connection fields are projected. Credentials, URLs and option snapshots never enter the journal.
724
+ export function normalizeTestClaimDatabaseEndpoint(
725
+ client: Record<string, any>,
726
+ projectPath: string,
727
+ runId: string,
728
+ ): TestClaimDatabaseEndpoint {
729
+ const dialect = client.client as TestClaimDatabaseEndpoint['dialect'];
730
+ record(client.connection);
731
+ const c = client.connection;
732
+ assertNoTlsTargetOverrides(c.ssl);
733
+ if (
734
+ c.connectionString !== undefined ||
735
+ c.uri !== undefined ||
736
+ client.connectionPool !== undefined ||
737
+ c.stream !== undefined ||
738
+ c.lookup !== undefined ||
739
+ client.driver !== undefined ||
740
+ client.dialect !== undefined ||
741
+ c.beforeConnect !== undefined
742
+ ) {
743
+ throw new Error('Unsupported managed database target');
744
+ }
745
+ let endpoint: TestClaimDatabaseEndpoint;
746
+ if (dialect === 'better-sqlite3') {
747
+ endpoint = {
748
+ dialect,
749
+ transport: 'file',
750
+ socketPath: getTestClaimPaths(projectPath, runId).sqliteRoot,
751
+ tls: false,
752
+ };
753
+ } else {
754
+ if (!['pg', 'mysql', 'mysql2'].includes(dialect))
755
+ throw new Error('Unsupported managed database dialect');
756
+ const socket =
757
+ c.socketPath ??
758
+ (dialect === 'pg' && typeof c.host === 'string' && c.host.startsWith('/')
759
+ ? c.host
760
+ : undefined);
761
+ const adminDatabase = dialect === 'pg' ? 'postgres' : 'mysql';
762
+ if (socket !== undefined) {
763
+ endpoint = {
764
+ dialect,
765
+ transport: 'socket',
766
+ socketPath: socket,
767
+ ...(dialect === 'pg' ? { port: Number(c.port) } : {}),
768
+ tls: Boolean(c.ssl),
769
+ adminDatabase,
770
+ };
771
+ } else {
772
+ // pg otherwise consults mutable PGHOST/PGPORT environment defaults: fail closed.
773
+ if (dialect === 'pg' && (c.host === undefined || c.port === undefined))
774
+ throw new Error('Managed PostgreSQL requires explicit host and port');
775
+ endpoint = {
776
+ dialect,
777
+ transport: 'tcp',
778
+ host: c.host ?? 'localhost',
779
+ port: Number(c.port ?? 3306),
780
+ tls: Boolean(c.ssl),
781
+ adminDatabase,
782
+ };
783
+ }
784
+ }
785
+ validateEndpoint(endpoint, false);
786
+ return endpoint;
787
+ }
788
+ function mergeClaimOptions(...sources: Record<string, any>[]): Record<string, any> {
789
+ const result: Record<string, any> = {};
790
+ for (const source of sources) {
791
+ for (const [key, value] of Object.entries(source ?? {})) {
792
+ if (['__proto__', 'constructor', 'prototype'].includes(key))
793
+ throw new Error('Unsupported managed option field');
794
+ if (value === undefined) continue;
795
+ if (value && Object.getPrototypeOf(value) === Object.prototype) {
796
+ const previous = result[key];
797
+ result[key] = mergeClaimOptions(
798
+ previous && Object.getPrototypeOf(previous) === Object.prototype ? previous : {},
799
+ value,
800
+ );
801
+ } else {
802
+ result[key] = value;
803
+ }
804
+ }
805
+ }
806
+ return result;
807
+ }
808
+ export function mergeTestClaimDatabaseOptions(
809
+ base: Record<string, any>,
810
+ dialect: Record<string, any> | undefined,
811
+ client: Record<string, any>,
812
+ ) {
813
+ return mergeClaimOptions(base, dialect ?? {}, client);
814
+ }
815
+ export function mergeTestClaimRedisOptions(
816
+ base: Record<string, any>,
817
+ client: Record<string, any>,
818
+ ): Record<string, any> {
819
+ // Match extend(true, ...) for connection/TLS objects and undefined values without importing runtime code.
820
+ return mergeClaimOptions(base, client);
821
+ }
822
+ export function normalizeTestClaimRedisEndpoint(
823
+ options: Record<string, any>,
824
+ ): TestClaimRedisEndpoint {
825
+ assertNoTlsTargetOverrides(options.tls);
826
+ if (
827
+ options.sentinels ||
828
+ options.nodes ||
829
+ options.url ||
830
+ options.connectionString ||
831
+ options.Connector !== undefined ||
832
+ options.connector !== undefined ||
833
+ options.stream !== undefined ||
834
+ options.dnsLookup !== undefined ||
835
+ options.natMap !== undefined
836
+ ) {
837
+ throw new Error('Unsupported managed Redis target');
838
+ }
839
+ const endpoint: TestClaimRedisEndpoint =
840
+ options.path !== undefined
841
+ ? {
842
+ transport: 'socket',
843
+ socketPath: options.path,
844
+ tls: Boolean(options.tls),
845
+ db: Number(options.db ?? 0),
846
+ }
847
+ : {
848
+ transport: 'tcp',
849
+ host: options.host ?? 'localhost',
850
+ port: Number(options.port ?? 6379),
851
+ tls: Boolean(options.tls),
852
+ db: Number(options.db ?? 0),
853
+ };
854
+ validateEndpoint(endpoint, true);
855
+ return endpoint;
856
+ }
857
+
858
+ export interface TestClaimApp {
859
+ projectPath: string;
860
+ name: string;
861
+ meta: { env: { META_MODE: string; VONA_TEST_RUN_ID?: string } };
862
+ config: any;
863
+ }
864
+ export function buildTestClaimResourceInventory(app: TestClaimApp): {
865
+ databases: TestClaimDatabasePlan[];
866
+ redis: TestClaimRedis[];
867
+ } {
868
+ const id = resolveTestRunId(app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID);
869
+ if (!id || app.name !== getTestAppName(id))
870
+ throw new Error('Managed resource inventory requires test identity');
871
+ const database = app.config.database;
872
+ const scopes: [string, string][] = [['share', database.defaultClient]];
873
+ for (const [name, instance] of Object.entries(app.config.instance.instances)) {
874
+ const item = instance as any;
875
+ if (item && item.isolate) scopes.push([`isolate-${name}`, item.isolateClient]);
876
+ }
877
+ const databases = scopes.map(([scope, clientName]) => {
878
+ const client = database.clients[clientName];
879
+ if (!client) throw new Error('Managed database client is missing');
880
+ const options = mergeTestClaimDatabaseOptions(database.base, undefined, client);
881
+ return {
882
+ clientName,
883
+ scope,
884
+ prefix: getDatabaseTestPrefix(app.name, scope),
885
+ endpoint: normalizeTestClaimDatabaseEndpoint(options, app.projectPath, id),
886
+ };
887
+ });
888
+ const redis: TestClaimRedis[] = [];
889
+ for (const [clientName, client] of Object.entries(app.config.redis.clients)) {
890
+ const prefix = getTestClaimRedisPrefix(id, clientName);
891
+ if (!prefix) continue;
892
+ const options = mergeTestClaimRedisOptions(app.config.redis.base, client as any);
893
+ redis.push({
894
+ clientName,
895
+ ...prefix,
896
+ endpoint: normalizeTestClaimRedisEndpoint(options),
897
+ released: false,
898
+ });
899
+ }
900
+ return { databases, redis };
901
+ }
902
+
903
+ const appInventories = new WeakMap<object, ReturnType<typeof buildTestClaimResourceInventory>>();
904
+ const appDatabaseIntents = new WeakMap<object, Set<string>>();
905
+ export async function registerTestClaimResourceInventory(app: TestClaimApp) {
906
+ if (!hasTestClaimTransport()) return;
907
+ const inventory = buildTestClaimResourceInventory(app);
908
+ await sendTestClaimEvent(process.env, { type: 'inventory', ...inventory });
909
+ appInventories.set(app, inventory);
910
+ appDatabaseIntents.set(app, new Set());
911
+ }
912
+ export function assertTestClaimDatabaseClient(
913
+ app: TestClaimApp,
914
+ clientName: string,
915
+ config: Record<string, any>,
916
+ ) {
917
+ if (!hasTestClaimTransport()) return;
918
+ const inventory = appInventories.get(app);
919
+ const id = resolveTestRunId(app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID)!;
920
+ const endpoint = normalizeTestClaimDatabaseEndpoint(config, app.projectPath, id);
921
+ const matches = inventory?.databases.filter(item => isDeepStrictEqual(item.endpoint, endpoint));
922
+ if (!matches?.length) throw new Error('Unregistered managed database endpoint');
923
+ const target = config.connection[endpoint.dialect === 'better-sqlite3' ? 'filename' : 'database'];
924
+ const admin =
925
+ endpoint.dialect === 'better-sqlite3'
926
+ ? ['', ':memory:'].includes(target)
927
+ : target === endpoint.adminDatabase;
928
+ if (admin) {
929
+ if (!matches.some(item => item.clientName === clientName))
930
+ throw new Error('Unregistered managed database endpoint');
931
+ } else if (
932
+ !matches.some(item => appDatabaseIntents.get(app)?.has(`${item.clientName}\0${target}`))
933
+ ) {
934
+ throw new Error('Unregistered managed database allocation');
935
+ }
936
+ }
937
+ export async function registerTestClaimDatabaseCreation(
938
+ app: TestClaimApp,
939
+ config: Record<string, any>,
940
+ databaseName: string,
941
+ stage: 'intent' | 'created',
942
+ ) {
943
+ if (!hasTestClaimTransport()) return;
944
+ const id = resolveTestRunId(app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID)!;
945
+ const endpoint = normalizeTestClaimDatabaseEndpoint(config, app.projectPath, id);
946
+ const plan = appInventories.get(app)?.databases.find(item => {
947
+ if (!isDeepStrictEqual(item.endpoint, endpoint)) return false;
948
+ try {
949
+ assertDisposableDatabaseName(databaseName, item.prefix);
950
+ return true;
951
+ } catch {
952
+ return false;
953
+ }
954
+ });
955
+ if (!plan) throw new Error('Unregistered managed database creation endpoint');
956
+ const name =
957
+ endpoint.dialect === 'better-sqlite3'
958
+ ? path.join(getTestClaimPaths(app.projectPath, id).sqliteRoot, `${databaseName}.db`)
959
+ : databaseName;
960
+ await sendTestClaimEvent(process.env, { type: 'database', database: plan, name, stage });
961
+ appDatabaseIntents.get(app)!.add(`${plan.clientName}\0${name}`);
962
+ }
963
+ export function assertTestClaimRedisClient(
964
+ app: TestClaimApp,
965
+ clientName: string,
966
+ options: Record<string, any>,
967
+ ) {
968
+ if (!hasTestClaimTransport()) return;
969
+ // Broadcast carries subscriptions only, but may not drift to an unregistered endpoint either.
970
+ const inventory = appInventories.get(app);
971
+ const endpoint = normalizeTestClaimRedisEndpoint(options);
972
+ const plan = inventory?.redis.find(item => item.clientName === clientName);
973
+ if (clientName === 'broadcast') {
974
+ const current = normalizeTestClaimRedisEndpoint(
975
+ mergeTestClaimRedisOptions(app.config.redis.base, app.config.redis.clients.broadcast),
976
+ );
977
+ if (!inventory || !isDeepStrictEqual(current, endpoint))
978
+ throw new Error('Unregistered managed Redis broadcast endpoint');
979
+ return;
980
+ }
981
+ if (!plan || !isDeepStrictEqual(plan.endpoint, endpoint))
982
+ throw new Error('Unregistered managed Redis endpoint');
983
+ if (plan.kind === 'client' ? options.keyPrefix !== plan.prefix : options.keyPrefix !== undefined)
984
+ throw new Error('Unregistered managed Redis key prefix');
985
+ }