@elench/testkit 0.1.40 → 0.1.42

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 (38) hide show
  1. package/README.md +27 -13
  2. package/bin/testkit.mjs +6 -1
  3. package/lib/cli/args.mjs +0 -4
  4. package/lib/cli/args.test.mjs +0 -5
  5. package/lib/cli/index.mjs +4 -11
  6. package/lib/config/index.mjs +78 -24
  7. package/lib/database/index.mjs +19 -7
  8. package/lib/database/naming.mjs +2 -2
  9. package/lib/database/naming.test.mjs +2 -2
  10. package/lib/runner/default-runtime-runner.mjs +52 -55
  11. package/lib/runner/execution-config.mjs +31 -70
  12. package/lib/runner/execution-config.test.mjs +30 -74
  13. package/lib/runner/formatting.mjs +0 -15
  14. package/lib/runner/formatting.test.mjs +0 -18
  15. package/lib/runner/lifecycle.mjs +106 -8
  16. package/lib/runner/orchestrator.mjs +16 -10
  17. package/lib/runner/planning.mjs +66 -138
  18. package/lib/runner/planning.test.mjs +101 -167
  19. package/lib/runner/playwright-config.mjs +13 -2
  20. package/lib/runner/playwright-config.test.mjs +26 -6
  21. package/lib/runner/playwright-runner.mjs +50 -56
  22. package/lib/runner/readiness.mjs +2 -2
  23. package/lib/runner/reporting.mjs +4 -3
  24. package/lib/runner/reporting.test.mjs +2 -5
  25. package/lib/runner/results.mjs +1 -1
  26. package/lib/runner/results.test.mjs +1 -1
  27. package/lib/runner/runtime-contexts.mjs +20 -24
  28. package/lib/runner/runtime-manager.mjs +228 -0
  29. package/lib/runner/runtime-manager.test.mjs +206 -0
  30. package/lib/runner/services.mjs +8 -6
  31. package/lib/runner/state.mjs +1 -2
  32. package/lib/runner/state.test.mjs +2 -4
  33. package/lib/runner/template.mjs +90 -60
  34. package/lib/runner/template.test.mjs +59 -27
  35. package/lib/runner/worker-loop.mjs +35 -32
  36. package/lib/setup/index.d.ts +15 -10
  37. package/package.json +1 -1
  38. package/lib/runner/stack-manager.mjs +0 -146
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @elench/testkit
2
2
 
3
3
  `@elench/testkit` discovers `*.testkit.ts` files, infers suite ownership from the
4
- filesystem, starts local services, provisions isolated local Postgres databases,
5
- and runs HTTP, DAL, and Playwright suites.
4
+ filesystem, starts local services, provisions Docker-managed local Postgres
5
+ databases, and runs HTTP, DAL, and Playwright suites.
6
6
 
7
7
  The package is now driven by `testkit.setup.ts`, not `testkit.config.json`.
8
8
 
@@ -21,15 +21,12 @@ npx @elench/testkit --type e2e
21
21
  npx @elench/testkit --type int,e2e,dal
22
22
  npx @elench/testkit --type pw
23
23
 
24
- # Shared local stack with parallel workers
25
- npx @elench/testkit --workers 8 --stack-mode shared
24
+ # Parallel file execution
25
+ npx @elench/testkit --workers 8
26
26
 
27
27
  # One file-level wall clock budget for every suite file
28
28
  npx @elench/testkit --file-timeout-seconds 60
29
29
 
30
- # Two reusable local stacks for browser-heavy suites
31
- npx @elench/testkit --workers 6 --stack-mode pooled --stack-count 2
32
-
33
30
  # Run a deterministic shard
34
31
  npx @elench/testkit --shard 1/3
35
32
 
@@ -70,8 +67,6 @@ export default defineTestkitSetup({
70
67
  execution: {
71
68
  workers: 8,
72
69
  fileTimeoutSeconds: 60,
73
- stackMode: "shared",
74
- stackCount: 1,
75
70
  },
76
71
  services: {
77
72
  api: service({
@@ -86,6 +81,18 @@ export default defineTestkitSetup({
86
81
  migrate: lifecycle("npm run db:migrate", {
87
82
  testkitCmd: "npm run db:migrate:testkit",
88
83
  }),
84
+ runtime: {
85
+ instances: 1,
86
+ maxConcurrentTasks: 4,
87
+ },
88
+ requirements: {
89
+ files: [
90
+ {
91
+ path: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
92
+ locks: ["global-worker-loop"],
93
+ },
94
+ ],
95
+ },
89
96
  }),
90
97
  frontend: service({
91
98
  ...nextService({
@@ -97,6 +104,9 @@ export default defineTestkitSetup({
97
104
  }),
98
105
  dependsOn: ["api"],
99
106
  envFiles: ["frontend/.env.testkit"],
107
+ runtime: {
108
+ instances: 1,
109
+ },
100
110
  }),
101
111
  billing: service({
102
112
  skip: {
@@ -121,10 +131,13 @@ export default defineTestkitSetup({
121
131
  `testkit.setup.ts` is optional for simple repos, but it is the primary escape hatch
122
132
  for:
123
133
 
124
- - worker and stack topology
134
+ - worker count and per-file runtime budget
125
135
  - per-file wall clock timeout budget
126
136
  - multi-service graphs
127
- - local DB configuration
137
+ - local runtime instance counts
138
+ - per-runtime concurrent task caps
139
+ - local DB binding configuration
140
+ - explicit per-file or per-suite locks
128
141
  - migrate / seed commands
129
142
  - test-local migrate / seed overrides
130
143
  - named HTTP suite profiles
@@ -198,7 +211,7 @@ Example layouts:
198
211
  - `src/api/routes/__testkit__/auth/me.int.testkit.ts`
199
212
  - `src/db/__testkit__/sessions/count-type.dal.testkit.ts`
200
213
  - `frontend/__testkit__/navigation/navigation.pw.testkit.ts`
201
- - `avocado_api/internal/handler/__testkit__/repos/crud.int.testkit.ts`
214
+ - `src/internal/handler/__testkit__/repos/crud.int.testkit.ts`
202
215
 
203
216
  `testkit` uses these suffixes automatically:
204
217
 
@@ -224,7 +237,8 @@ Suite names are inferred from the colocated path:
224
237
  services that define `database: localDatabase(...)`.
225
238
 
226
239
  - template databases are cached
227
- - stack databases are cloned from templates
240
+ - runtime databases are cloned from templates when binding is `per-runtime`
241
+ - shared databases are reused when binding is `shared`
228
242
  - template fingerprints are derived automatically from env files, migrate/seed
229
243
  config, and repo contents
230
244
 
package/bin/testkit.mjs CHANGED
@@ -1,3 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from "../lib/cli/index.mjs";
3
- run();
3
+
4
+ run().catch((error) => {
5
+ setImmediate(() => {
6
+ throw error;
7
+ });
8
+ });
package/lib/cli/args.mjs CHANGED
@@ -2,8 +2,6 @@ import path from "path";
2
2
  import { normalizeTypeValues, parseSuiteSelectors } from "../runner/suite-selection.mjs";
3
3
  import {
4
4
  parseFileTimeoutOption,
5
- parseStackCountOption,
6
- parseStackModeOption,
7
5
  parseWorkersOption,
8
6
  } from "../runner/execution-config.mjs";
9
7
 
@@ -49,8 +47,6 @@ export function parseSuiteOption(values) {
49
47
  export {
50
48
  parseFileTimeoutOption,
51
49
  parseWorkersOption,
52
- parseStackModeOption,
53
- parseStackCountOption,
54
50
  };
55
51
 
56
52
  export function parseShardOption(value) {
@@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  parseFileTimeoutOption,
4
4
  parseShardOption,
5
- parseStackCountOption,
6
- parseStackModeOption,
7
5
  parseSuiteOption,
8
6
  parseTypeOption,
9
7
  parseWorkersOption,
@@ -61,13 +59,10 @@ describe("cli-args", () => {
61
59
  it("parses and validates execution options", () => {
62
60
  expect(parseWorkersOption("3")).toBe(3);
63
61
  expect(parseFileTimeoutOption("45")).toBe(45);
64
- expect(parseStackCountOption("2")).toBe(2);
65
- expect(parseStackModeOption("pooled")).toBe("pooled");
66
62
  expect(() => parseWorkersOption("0")).toThrow("Invalid --workers value");
67
63
  expect(() => parseFileTimeoutOption("0")).toThrow(
68
64
  "Invalid --file-timeout-seconds value"
69
65
  );
70
- expect(() => parseStackModeOption("legacy")).toThrow("Invalid --stack-mode value");
71
66
  });
72
67
 
73
68
  it("parses and validates shards", () => {
package/lib/cli/index.mjs CHANGED
@@ -3,8 +3,6 @@ import { loadConfigs } from "../config/index.mjs";
3
3
  import {
4
4
  parseFileTimeoutOption,
5
5
  parseShardOption,
6
- parseStackCountOption,
7
- parseStackModeOption,
8
6
  parseSuiteOption,
9
7
  parseTypeOption,
10
8
  parseWorkersOption,
@@ -13,7 +11,7 @@ import {
13
11
  } from "./args.mjs";
14
12
  import * as runner from "../runner/index.mjs";
15
13
 
16
- export function run() {
14
+ export async function run(argv = process.argv) {
17
15
  const cli = cac("testkit");
18
16
 
19
17
  cli
@@ -27,8 +25,6 @@ export function run() {
27
25
  .option("--dir <path>", "Explicit product directory")
28
26
  .option("--workers <n>", "Number of test executors for the whole run")
29
27
  .option("--file-timeout-seconds <n>", "Per-file wall-clock timeout in seconds")
30
- .option("--stack-mode <mode>", "Stack topology: shared, pooled, or isolated")
31
- .option("--stack-count <n>", "Number of prepared stacks when stack-mode=pooled")
32
28
  .option("--shard <i/n>", "Run only shard i of n at suite granularity")
33
29
  .option("--write-status", "Write a deterministic testkit.status.json snapshot")
34
30
  .option("--allow-partial-status", "Allow --write-status for filtered runs")
@@ -71,9 +67,6 @@ export function run() {
71
67
  options.fileTimeoutSeconds == null
72
68
  ? null
73
69
  : parseFileTimeoutOption(options.fileTimeoutSeconds);
74
- const stackMode = options.stackMode == null ? null : parseStackModeOption(options.stackMode);
75
- const stackCount =
76
- options.stackCount == null ? null : parseStackCountOption(options.stackCount);
77
70
  const shard = parseShardOption(options.shard);
78
71
  const typeValues = parseTypeOption(options.type, positionalType);
79
72
  const suiteSelectors = parseSuiteOption(options.suite);
@@ -90,8 +83,6 @@ export function run() {
90
83
  fileNames,
91
84
  workers,
92
85
  fileTimeoutSeconds,
93
- stackMode,
94
- stackCount,
95
86
  shard,
96
87
  serviceFilter: options.service || null,
97
88
  },
@@ -100,5 +91,7 @@ export function run() {
100
91
  });
101
92
 
102
93
  cli.help();
103
- cli.parse();
94
+ const parsed = cli.parse(argv, { run: false });
95
+ await cli.runMatchedCommand();
96
+ return parsed;
104
97
  }
@@ -10,8 +10,10 @@ import {
10
10
  } from "../runner/suite-selection.mjs";
11
11
  import {
12
12
  DEFAULT_FILE_TIMEOUT_SECONDS,
13
+ normalizeDatabaseBinding,
13
14
  normalizeExecutionConfig,
14
- normalizeStackModeValue,
15
+ normalizeRuntimeMaxConcurrentTasks,
16
+ normalizeRuntimeInstances,
15
17
  } from "../runner/execution-config.mjs";
16
18
 
17
19
  const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
@@ -116,12 +118,13 @@ function normalizeServiceConfig({
116
118
  const database = normalizeDatabaseConfig(explicitService, name);
117
119
  const migrate = normalizeLifecycle(explicitService.migrate);
118
120
  const seed = normalizeLifecycle(explicitService.seed);
121
+ const runtime = normalizeRuntimeConfig(explicitService.runtime, name);
119
122
  const skip = normalizeSkipConfig(explicitService.skip, {
120
123
  name,
121
124
  productDir,
122
125
  suites,
123
126
  });
124
- const serviceExecution = normalizeServiceExecution(explicitService.execution, {
127
+ const requirements = normalizeServiceRequirements(explicitService.requirements, {
125
128
  name,
126
129
  suites,
127
130
  });
@@ -137,6 +140,7 @@ function normalizeServiceConfig({
137
140
  local,
138
141
  database,
139
142
  databaseFrom: explicitService.databaseFrom,
143
+ runtime,
140
144
  migrate,
141
145
  seed,
142
146
  dependsOn: explicitService.dependsOn || [],
@@ -157,11 +161,12 @@ function normalizeServiceConfig({
157
161
  database,
158
162
  databaseFrom: explicitService.databaseFrom,
159
163
  envFiles,
160
- serviceExecution,
164
+ requirements,
161
165
  serviceEnv,
162
166
  migrate,
163
167
  seed,
164
168
  skip,
169
+ runtime,
165
170
  local,
166
171
  },
167
172
  };
@@ -240,6 +245,10 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
240
245
 
241
246
  return {
242
247
  ...database,
248
+ binding: normalizeDatabaseBinding(
249
+ database.binding || "per-runtime",
250
+ `Service "${serviceName}" database.binding`
251
+ ),
243
252
  provider: "local",
244
253
  selectedBackend: "local",
245
254
  reset: database.reset !== false,
@@ -253,6 +262,26 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
253
262
  };
254
263
  }
255
264
 
265
+ function normalizeRuntimeConfig(value, serviceName) {
266
+ if (!value) {
267
+ return {
268
+ instances: 1,
269
+ maxConcurrentTasks: Number.POSITIVE_INFINITY,
270
+ };
271
+ }
272
+
273
+ return {
274
+ instances: normalizeRuntimeInstances(
275
+ value.instances ?? 1,
276
+ `Service "${serviceName}" runtime.instances`
277
+ ),
278
+ maxConcurrentTasks: normalizeRuntimeMaxConcurrentTasks(
279
+ value.maxConcurrentTasks,
280
+ `Service "${serviceName}" runtime.maxConcurrentTasks`
281
+ ),
282
+ };
283
+ }
284
+
256
285
  function normalizeLifecycle(value) {
257
286
  if (!value) return undefined;
258
287
  if (!value.cmd && !value.testkitCmd) {
@@ -355,8 +384,8 @@ function normalizeSkipConfig(value, { name, productDir, suites }) {
355
384
  };
356
385
  }
357
386
 
358
- function normalizeServiceExecution(value, { name, suites }) {
359
- if (!value) return { suites: [], files: [], fileStackModeByPath: new Map() };
387
+ function normalizeServiceRequirements(value, { name, suites }) {
388
+ if (!value) return { suites: [], files: [], fileLocksByPath: new Map() };
360
389
 
361
390
  const discoveredSuites = [];
362
391
  const discoveredFiles = new Set();
@@ -376,25 +405,25 @@ function normalizeServiceExecution(value, { name, suites }) {
376
405
  const seenSelectors = new Set();
377
406
  for (const rule of value.suites || []) {
378
407
  if (!rule || typeof rule !== "object") {
379
- throw new Error(`Service "${name}" execution.suites entries must be objects`);
408
+ throw new Error(`Service "${name}" requirements.suites entries must be objects`);
380
409
  }
381
410
  const selector = String(rule.selector || "").trim();
382
411
  if (!selector) {
383
- throw new Error(`Service "${name}" execution.suites entries require a selector`);
412
+ throw new Error(`Service "${name}" requirements.suites entries require a selector`);
384
413
  }
385
414
  const parsed = parseSuiteSelectors([selector]);
386
415
  if (parsed.length !== 1) {
387
- throw new Error(`Service "${name}" execution.suites["${selector}"] is invalid`);
416
+ throw new Error(`Service "${name}" requirements.suites["${selector}"] is invalid`);
388
417
  }
389
418
  const parsedSelector = parsed[0];
390
419
  if (parsedSelector.kind !== "typed") {
391
420
  throw new Error(
392
- `Service "${name}" execution.suites["${selector}"] must use a typed selector like int:health`
421
+ `Service "${name}" requirements.suites["${selector}"] must use a typed selector like int:health`
393
422
  );
394
423
  }
395
424
  if (seenSelectors.has(parsedSelector.raw)) {
396
425
  throw new Error(
397
- `Service "${name}" defines duplicate execution.suites selector "${parsedSelector.raw}"`
426
+ `Service "${name}" defines duplicate requirements.suites selector "${parsedSelector.raw}"`
398
427
  );
399
428
  }
400
429
  const matched = discoveredSuites.some((suite) =>
@@ -402,15 +431,15 @@ function normalizeServiceExecution(value, { name, suites }) {
402
431
  );
403
432
  if (!matched) {
404
433
  throw new Error(
405
- `Service "${name}" execution.suites selector "${parsedSelector.raw}" did not match any discovered suite`
434
+ `Service "${name}" requirements.suites selector "${parsedSelector.raw}" did not match any discovered suite`
406
435
  );
407
436
  }
408
437
  seenSelectors.add(parsedSelector.raw);
409
438
  suiteRules.push({
410
439
  selector: parsedSelector,
411
- stackMode: normalizeStackModeValue(
412
- rule.stackMode,
413
- `Service "${name}" execution.suites["${parsedSelector.raw}"].stackMode`
440
+ locks: normalizeRequirementLocks(
441
+ rule.locks,
442
+ `Service "${name}" requirements.suites["${parsedSelector.raw}"].locks`
414
443
  ),
415
444
  });
416
445
  }
@@ -419,28 +448,28 @@ function normalizeServiceExecution(value, { name, suites }) {
419
448
  const seenFiles = new Set();
420
449
  for (const [index, rule] of (value.files || []).entries()) {
421
450
  if (!rule || typeof rule !== "object") {
422
- throw new Error(`Service "${name}" execution.files entries must be objects`);
451
+ throw new Error(`Service "${name}" requirements.files entries must be objects`);
423
452
  }
424
453
 
425
454
  const filePath = String(rule.path || "").trim();
426
455
  if (!filePath) {
427
- throw new Error(`Service "${name}" execution.files[${index}] requires a path`);
456
+ throw new Error(`Service "${name}" requirements.files[${index}] requires a path`);
428
457
  }
429
458
  if (!discoveredFiles.has(filePath)) {
430
459
  throw new Error(
431
- `Service "${name}" execution.files["${filePath}"] did not match any discovered test file`
460
+ `Service "${name}" requirements.files["${filePath}"] did not match any discovered test file`
432
461
  );
433
462
  }
434
463
  if (seenFiles.has(filePath)) {
435
- throw new Error(`Service "${name}" defines duplicate execution.files path "${filePath}"`);
464
+ throw new Error(`Service "${name}" defines duplicate requirements.files path "${filePath}"`);
436
465
  }
437
466
 
438
467
  seenFiles.add(filePath);
439
468
  fileRules.push({
440
469
  path: filePath,
441
- stackMode: normalizeStackModeValue(
442
- rule.stackMode,
443
- `Service "${name}" execution.files["${filePath}"].stackMode`
470
+ locks: normalizeRequirementLocks(
471
+ rule.locks,
472
+ `Service "${name}" requirements.files["${filePath}"].locks`
444
473
  ),
445
474
  });
446
475
  }
@@ -448,10 +477,28 @@ function normalizeServiceExecution(value, { name, suites }) {
448
477
  return {
449
478
  suites: suiteRules,
450
479
  files: fileRules,
451
- fileStackModeByPath: new Map(fileRules.map((rule) => [rule.path, rule.stackMode])),
480
+ fileLocksByPath: new Map(fileRules.map((rule) => [rule.path, rule.locks])),
452
481
  };
453
482
  }
454
483
 
484
+ function normalizeRequirementLocks(value, label) {
485
+ const input = Array.isArray(value) ? value : value == null ? [] : [value];
486
+ const seen = new Set();
487
+ const locks = [];
488
+
489
+ for (const rawLock of input) {
490
+ const lockName = String(rawLock || "").trim();
491
+ if (!lockName) {
492
+ throw new Error(`${label} entries must be non-empty strings`);
493
+ }
494
+ if (seen.has(lockName)) continue;
495
+ seen.add(lockName);
496
+ locks.push(lockName);
497
+ }
498
+
499
+ return locks.sort();
500
+ }
501
+
455
502
  function inferEnvFiles(productDir, explicitService, local) {
456
503
  if (explicitService.envFile || explicitService.envFiles) {
457
504
  const files = [];
@@ -512,6 +559,7 @@ function validateServiceConfig({
512
559
  local,
513
560
  database,
514
561
  databaseFrom,
562
+ runtime,
515
563
  migrate,
516
564
  seed,
517
565
  dependsOn,
@@ -533,6 +581,14 @@ function validateServiceConfig({
533
581
  if (database && databaseFrom) {
534
582
  throw new Error(`Service "${name}" cannot define both database and databaseFrom`);
535
583
  }
584
+ if (runtime.instances < 1) {
585
+ throw new Error(`Service "${name}" runtime.instances must be a positive integer`);
586
+ }
587
+ if (runtime.maxConcurrentTasks <= 0) {
588
+ throw new Error(
589
+ `Service "${name}" runtime.maxConcurrentTasks must be a positive integer when provided`
590
+ );
591
+ }
536
592
 
537
593
  for (const depName of dependsOn || []) {
538
594
  if (depName === name) {
@@ -592,8 +648,6 @@ function normalizeRepoExecution(execution) {
592
648
  return normalizeExecutionConfig({
593
649
  workers: 1,
594
650
  fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
595
- stackMode: "isolated",
596
- stackCount: 1,
597
651
  });
598
652
  }
599
653
  return normalizeExecutionConfig(execution);
@@ -9,8 +9,8 @@ import {
9
9
  import {
10
10
  buildContainerName as buildContainerNameModel,
11
11
  buildDatabaseUrl as buildDatabaseUrlModel,
12
+ buildRuntimeDatabaseName as buildRuntimeDatabaseNameModel,
12
13
  buildTemplateDatabaseName as buildTemplateDatabaseNameModel,
13
- buildWorkerDatabaseName as buildWorkerDatabaseNameModel,
14
14
  escapeIdentifier as escapeIdentifierModel,
15
15
  escapeSqlLiteral as escapeSqlLiteralModel,
16
16
  hashString as hashStringModel,
@@ -119,6 +119,7 @@ async function prepareLocalDatabase(config, hooks) {
119
119
  const db = config.testkit.database;
120
120
  const productDir = config.productDir;
121
121
  const serviceName = config.name;
122
+ const bindingKey = resolveDatabaseBindingKey(config);
122
123
  const lockDir = getLocalLocksDir(productDir);
123
124
  const cacheDir = getLocalServiceCacheDir(productDir, serviceName);
124
125
  fs.mkdirSync(lockDir, { recursive: true });
@@ -133,8 +134,8 @@ async function prepareLocalDatabase(config, hooks) {
133
134
  await ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, hooks);
134
135
  });
135
136
 
136
- await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(config.stateDir, 10)}.lock`), async () => {
137
- await ensureWorkerClone(config, infra, cacheDir, templateFingerprint);
137
+ await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(bindingKey, 10)}.lock`), async () => {
138
+ await ensureRuntimeClone(config, infra, cacheDir, templateFingerprint, bindingKey);
138
139
  });
139
140
  }
140
141
 
@@ -172,14 +173,14 @@ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerpri
172
173
  writeLocalCacheState(cacheDir, infra, desiredDbName, templateFingerprint);
173
174
  }
174
175
 
175
- async function ensureWorkerClone(config, infra, cacheDir, templateFingerprint) {
176
+ async function ensureRuntimeClone(config, infra, cacheDir, templateFingerprint, bindingKey) {
176
177
  const serviceName = config.name;
177
178
  const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
178
179
  if (!templateDbName) {
179
180
  throw new Error(`Missing template database for service "${serviceName}"`);
180
181
  }
181
182
 
182
- const desiredDbName = buildWorkerDatabaseName(serviceName, config.stateDir, templateFingerprint);
183
+ const desiredDbName = buildRuntimeDatabaseName(serviceName, bindingKey, templateFingerprint);
183
184
  const existingDbName = readStateValue(path.join(config.stateDir, "local_database_name"));
184
185
  const existingFingerprint = readStateValue(path.join(config.stateDir, "local_template_fingerprint"));
185
186
  const needsReset =
@@ -206,6 +207,17 @@ async function ensureWorkerClone(config, infra, cacheDir, templateFingerprint) {
206
207
  fs.writeFileSync(path.join(config.stateDir, "local_container_name"), infra.containerName);
207
208
  }
208
209
 
210
+ function resolveDatabaseBindingKey(config) {
211
+ const binding = config.testkit.database?.binding || "per-runtime";
212
+ if (binding === "shared") {
213
+ return `${config.name}:shared`;
214
+ }
215
+ if (config.runtimeId) {
216
+ return `${config.name}:${config.runtimeId}`;
217
+ }
218
+ return `${config.name}:${config.stateDir}`;
219
+ }
220
+
209
221
  async function destroyLocalRuntimeDatabase(productDir, stateDir) {
210
222
  const dbName = readStateValue(path.join(stateDir, "local_database_name"));
211
223
  if (!dbName) return;
@@ -420,8 +432,8 @@ function buildTemplateDatabaseName(serviceName, fingerprint) {
420
432
  return buildTemplateDatabaseNameModel(serviceName, fingerprint);
421
433
  }
422
434
 
423
- function buildWorkerDatabaseName(serviceName, stateDir, fingerprint) {
424
- return buildWorkerDatabaseNameModel(serviceName, stateDir, fingerprint);
435
+ function buildRuntimeDatabaseName(serviceName, bindingKey, fingerprint) {
436
+ return buildRuntimeDatabaseNameModel(serviceName, bindingKey, fingerprint);
425
437
  }
426
438
 
427
439
  function writeLocalInfraState(infraDir, infra) {
@@ -19,9 +19,9 @@ export function buildTemplateDatabaseName(serviceName, fingerprint) {
19
19
  );
20
20
  }
21
21
 
22
- export function buildWorkerDatabaseName(serviceName, stateDir, fingerprint) {
22
+ export function buildRuntimeDatabaseName(serviceName, bindingKey, fingerprint) {
23
23
  return limitIdentifier(
24
- `tk_${slugSegment(serviceName)}_${hashString(stateDir, 10)}_${fingerprint.slice(0, 12)}`,
24
+ `tk_${slugSegment(serviceName)}_${hashString(bindingKey, 10)}_${fingerprint.slice(0, 12)}`,
25
25
  63
26
26
  );
27
27
  }
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  buildContainerName,
4
4
  buildDatabaseUrl,
5
+ buildRuntimeDatabaseName,
5
6
  buildTemplateDatabaseName,
6
- buildWorkerDatabaseName,
7
7
  escapeIdentifier,
8
8
  escapeSqlLiteral,
9
9
  slugSegment,
@@ -15,7 +15,7 @@ describe("database-naming", () => {
15
15
  expect(buildTemplateDatabaseName("api", "1234567890abcdef1234")).toBe(
16
16
  "tk_tpl_api_1234567890abcdef"
17
17
  );
18
- expect(buildWorkerDatabaseName("api", "/tmp/state", "abcdef1234567890")).toMatch(
18
+ expect(buildRuntimeDatabaseName("api", "/tmp/state", "abcdef1234567890")).toMatch(
19
19
  /^tk_api_[a-f0-9]{10}_abcdef123456$/
20
20
  );
21
21
  expect(