@elench/testkit 0.1.39 → 0.1.41

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 (41) hide show
  1. package/README.md +43 -13
  2. package/lib/cli/args.mjs +5 -3
  3. package/lib/cli/args.test.mjs +5 -5
  4. package/lib/cli/index.mjs +9 -15
  5. package/lib/config/index.mjs +72 -24
  6. package/lib/database/index.mjs +19 -7
  7. package/lib/database/naming.mjs +2 -2
  8. package/lib/database/naming.test.mjs +2 -2
  9. package/lib/runner/default-runtime-runner.mjs +63 -43
  10. package/lib/runner/execution-config.mjs +24 -64
  11. package/lib/runner/execution-config.test.mjs +30 -72
  12. package/lib/runner/formatting.mjs +0 -15
  13. package/lib/runner/formatting.test.mjs +0 -18
  14. package/lib/runner/lifecycle.mjs +7 -7
  15. package/lib/runner/orchestrator.mjs +9 -10
  16. package/lib/runner/planning.mjs +42 -136
  17. package/lib/runner/planning.test.mjs +70 -174
  18. package/lib/runner/playwright-config.mjs +8 -2
  19. package/lib/runner/playwright-config.test.mjs +20 -5
  20. package/lib/runner/playwright-runner.mjs +32 -54
  21. package/lib/runner/readiness.mjs +2 -2
  22. package/lib/runner/reporting.mjs +3 -3
  23. package/lib/runner/reporting.test.mjs +4 -5
  24. package/lib/runner/results.mjs +1 -1
  25. package/lib/runner/results.test.mjs +1 -1
  26. package/lib/runner/runtime-contexts.mjs +20 -24
  27. package/lib/runner/runtime-manager.mjs +181 -0
  28. package/lib/runner/runtime-manager.test.mjs +181 -0
  29. package/lib/runner/services.mjs +4 -4
  30. package/lib/runner/state.mjs +1 -2
  31. package/lib/runner/state.test.mjs +2 -4
  32. package/lib/runner/template.mjs +90 -60
  33. package/lib/runner/template.test.mjs +59 -27
  34. package/lib/runner/worker-loop.mjs +29 -32
  35. package/lib/runtime/index.d.ts +11 -0
  36. package/lib/runtime/index.mjs +34 -0
  37. package/lib/setup/index.d.ts +15 -10
  38. package/lib/shared/file-timeout.mjs +107 -0
  39. package/lib/shared/file-timeout.test.mjs +64 -0
  40. package/package.json +1 -1
  41. 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,11 +21,11 @@ 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
- # Two reusable local stacks for browser-heavy suites
28
- npx @elench/testkit --workers 6 --stack-mode pooled --stack-count 2
27
+ # One file-level wall clock budget for every suite file
28
+ npx @elench/testkit --file-timeout-seconds 60
29
29
 
30
30
  # Run a deterministic shard
31
31
  npx @elench/testkit --shard 1/3
@@ -66,8 +66,7 @@ import {
66
66
  export default defineTestkitSetup({
67
67
  execution: {
68
68
  workers: 8,
69
- stackMode: "shared",
70
- stackCount: 1,
69
+ fileTimeoutSeconds: 60,
71
70
  },
72
71
  services: {
73
72
  api: service({
@@ -82,6 +81,17 @@ export default defineTestkitSetup({
82
81
  migrate: lifecycle("npm run db:migrate", {
83
82
  testkitCmd: "npm run db:migrate:testkit",
84
83
  }),
84
+ runtime: {
85
+ instances: 1,
86
+ },
87
+ requirements: {
88
+ files: [
89
+ {
90
+ path: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
91
+ locks: ["global-worker-loop"],
92
+ },
93
+ ],
94
+ },
85
95
  }),
86
96
  frontend: service({
87
97
  ...nextService({
@@ -93,6 +103,9 @@ export default defineTestkitSetup({
93
103
  }),
94
104
  dependsOn: ["api"],
95
105
  envFiles: ["frontend/.env.testkit"],
106
+ runtime: {
107
+ instances: 1,
108
+ },
96
109
  }),
97
110
  billing: service({
98
111
  skip: {
@@ -117,9 +130,12 @@ export default defineTestkitSetup({
117
130
  `testkit.setup.ts` is optional for simple repos, but it is the primary escape hatch
118
131
  for:
119
132
 
120
- - worker and stack topology
133
+ - worker count and per-file runtime budget
134
+ - per-file wall clock timeout budget
121
135
  - multi-service graphs
122
- - local DB configuration
136
+ - local runtime instance counts
137
+ - local DB binding configuration
138
+ - explicit per-file or per-suite locks
123
139
  - migrate / seed commands
124
140
  - test-local migrate / seed overrides
125
141
  - named HTTP suite profiles
@@ -168,7 +184,20 @@ export default suite;
168
184
  Low-level runtime primitives remain available:
169
185
 
170
186
  ```ts
171
- import { check, group, http } from "@elench/testkit/runtime";
187
+ import { check, group, http, waitFor } from "@elench/testkit/runtime";
188
+ ```
189
+
190
+ `waitFor()` consumes the file budget configured by `execution.fileTimeoutSeconds`.
191
+ Consumers should not set local timeout values in test files.
192
+
193
+ ```ts
194
+ import { waitFor } from "@elench/testkit/runtime";
195
+
196
+ const response = waitFor(
197
+ () => req("GET", "/api/v1/jobs/123", setupData),
198
+ (res) => JSON.parse(res.body).data?.status === "completed",
199
+ { description: "job 123 to complete" }
200
+ );
172
201
  ```
173
202
 
174
203
  ## Discovery
@@ -180,7 +209,7 @@ Example layouts:
180
209
  - `src/api/routes/__testkit__/auth/me.int.testkit.ts`
181
210
  - `src/db/__testkit__/sessions/count-type.dal.testkit.ts`
182
211
  - `frontend/__testkit__/navigation/navigation.pw.testkit.ts`
183
- - `avocado_api/internal/handler/__testkit__/repos/crud.int.testkit.ts`
212
+ - `src/internal/handler/__testkit__/repos/crud.int.testkit.ts`
184
213
 
185
214
  `testkit` uses these suffixes automatically:
186
215
 
@@ -206,7 +235,8 @@ Suite names are inferred from the colocated path:
206
235
  services that define `database: localDatabase(...)`.
207
236
 
208
237
  - template databases are cached
209
- - stack databases are cloned from templates
238
+ - runtime databases are cloned from templates when binding is `per-runtime`
239
+ - shared databases are reused when binding is `shared`
210
240
  - template fingerprints are derived automatically from env files, migrate/seed
211
241
  config, and repo contents
212
242
 
package/lib/cli/args.mjs CHANGED
@@ -1,8 +1,7 @@
1
1
  import path from "path";
2
2
  import { normalizeTypeValues, parseSuiteSelectors } from "../runner/suite-selection.mjs";
3
3
  import {
4
- parseStackCountOption,
5
- parseStackModeOption,
4
+ parseFileTimeoutOption,
6
5
  parseWorkersOption,
7
6
  } from "../runner/execution-config.mjs";
8
7
 
@@ -45,7 +44,10 @@ export function parseSuiteOption(values) {
45
44
  return parseSuiteSelectors(input);
46
45
  }
47
46
 
48
- export { parseWorkersOption, parseStackModeOption, parseStackCountOption };
47
+ export {
48
+ parseFileTimeoutOption,
49
+ parseWorkersOption,
50
+ };
49
51
 
50
52
  export function parseShardOption(value) {
51
53
  if (!value) return null;
@@ -1,8 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
+ parseFileTimeoutOption,
3
4
  parseShardOption,
4
- parseStackCountOption,
5
- parseStackModeOption,
6
5
  parseSuiteOption,
7
6
  parseTypeOption,
8
7
  parseWorkersOption,
@@ -59,10 +58,11 @@ describe("cli-args", () => {
59
58
 
60
59
  it("parses and validates execution options", () => {
61
60
  expect(parseWorkersOption("3")).toBe(3);
62
- expect(parseStackCountOption("2")).toBe(2);
63
- expect(parseStackModeOption("pooled")).toBe("pooled");
61
+ expect(parseFileTimeoutOption("45")).toBe(45);
64
62
  expect(() => parseWorkersOption("0")).toThrow("Invalid --workers value");
65
- expect(() => parseStackModeOption("legacy")).toThrow("Invalid --stack-mode value");
63
+ expect(() => parseFileTimeoutOption("0")).toThrow(
64
+ "Invalid --file-timeout-seconds value"
65
+ );
66
66
  });
67
67
 
68
68
  it("parses and validates shards", () => {
package/lib/cli/index.mjs CHANGED
@@ -1,9 +1,8 @@
1
1
  import { cac } from "cac";
2
2
  import { loadConfigs } from "../config/index.mjs";
3
3
  import {
4
+ parseFileTimeoutOption,
4
5
  parseShardOption,
5
- parseStackCountOption,
6
- parseStackModeOption,
7
6
  parseSuiteOption,
8
7
  parseTypeOption,
9
8
  parseWorkersOption,
@@ -24,13 +23,8 @@ export function run() {
24
23
  .option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
25
24
  .option("-f, --file <path>", "Run specific file(s)", { default: [] })
26
25
  .option("--dir <path>", "Explicit product directory")
27
- .option("--workers <n>", "Number of test executors for the whole run", {
28
- default: "1",
29
- })
30
- .option("--stack-mode <mode>", "Stack topology: shared, pooled, or isolated", {
31
- default: "isolated",
32
- })
33
- .option("--stack-count <n>", "Number of prepared stacks when stack-mode=pooled")
26
+ .option("--workers <n>", "Number of test executors for the whole run")
27
+ .option("--file-timeout-seconds <n>", "Per-file wall-clock timeout in seconds")
34
28
  .option("--shard <i/n>", "Run only shard i of n at suite granularity")
35
29
  .option("--write-status", "Write a deterministic testkit.status.json snapshot")
36
30
  .option("--allow-partial-status", "Allow --write-status for filtered runs")
@@ -68,10 +62,11 @@ export function run() {
68
62
  return;
69
63
  }
70
64
 
71
- const workers = parseWorkersOption(options.workers);
72
- const stackMode = parseStackModeOption(options.stackMode);
73
- const stackCount =
74
- options.stackCount == null ? null : parseStackCountOption(options.stackCount);
65
+ const workers = options.workers == null ? null : parseWorkersOption(options.workers);
66
+ const fileTimeoutSeconds =
67
+ options.fileTimeoutSeconds == null
68
+ ? null
69
+ : parseFileTimeoutOption(options.fileTimeoutSeconds);
75
70
  const shard = parseShardOption(options.shard);
76
71
  const typeValues = parseTypeOption(options.type, positionalType);
77
72
  const suiteSelectors = parseSuiteOption(options.suite);
@@ -87,8 +82,7 @@ export function run() {
87
82
  typeValues,
88
83
  fileNames,
89
84
  workers,
90
- stackMode,
91
- stackCount,
85
+ fileTimeoutSeconds,
92
86
  shard,
93
87
  serviceFilter: options.service || null,
94
88
  },
@@ -8,7 +8,12 @@ import {
8
8
  parseSuiteSelectors,
9
9
  suiteSelectionType,
10
10
  } from "../runner/suite-selection.mjs";
11
- import { normalizeExecutionConfig, normalizeStackModeValue } from "../runner/execution-config.mjs";
11
+ import {
12
+ DEFAULT_FILE_TIMEOUT_SECONDS,
13
+ normalizeDatabaseBinding,
14
+ normalizeExecutionConfig,
15
+ normalizeRuntimeInstances,
16
+ } from "../runner/execution-config.mjs";
12
17
 
13
18
  const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
14
19
  const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
@@ -112,12 +117,13 @@ function normalizeServiceConfig({
112
117
  const database = normalizeDatabaseConfig(explicitService, name);
113
118
  const migrate = normalizeLifecycle(explicitService.migrate);
114
119
  const seed = normalizeLifecycle(explicitService.seed);
120
+ const runtime = normalizeRuntimeConfig(explicitService.runtime, name);
115
121
  const skip = normalizeSkipConfig(explicitService.skip, {
116
122
  name,
117
123
  productDir,
118
124
  suites,
119
125
  });
120
- const serviceExecution = normalizeServiceExecution(explicitService.execution, {
126
+ const requirements = normalizeServiceRequirements(explicitService.requirements, {
121
127
  name,
122
128
  suites,
123
129
  });
@@ -133,6 +139,7 @@ function normalizeServiceConfig({
133
139
  local,
134
140
  database,
135
141
  databaseFrom: explicitService.databaseFrom,
142
+ runtime,
136
143
  migrate,
137
144
  seed,
138
145
  dependsOn: explicitService.dependsOn || [],
@@ -153,11 +160,12 @@ function normalizeServiceConfig({
153
160
  database,
154
161
  databaseFrom: explicitService.databaseFrom,
155
162
  envFiles,
156
- serviceExecution,
163
+ requirements,
157
164
  serviceEnv,
158
165
  migrate,
159
166
  seed,
160
167
  skip,
168
+ runtime,
161
169
  local,
162
170
  },
163
171
  };
@@ -236,6 +244,10 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
236
244
 
237
245
  return {
238
246
  ...database,
247
+ binding: normalizeDatabaseBinding(
248
+ database.binding || "per-runtime",
249
+ `Service "${serviceName}" database.binding`
250
+ ),
239
251
  provider: "local",
240
252
  selectedBackend: "local",
241
253
  reset: database.reset !== false,
@@ -249,6 +261,21 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
249
261
  };
250
262
  }
251
263
 
264
+ function normalizeRuntimeConfig(value, serviceName) {
265
+ if (!value) {
266
+ return {
267
+ instances: 1,
268
+ };
269
+ }
270
+
271
+ return {
272
+ instances: normalizeRuntimeInstances(
273
+ value.instances ?? 1,
274
+ `Service "${serviceName}" runtime.instances`
275
+ ),
276
+ };
277
+ }
278
+
252
279
  function normalizeLifecycle(value) {
253
280
  if (!value) return undefined;
254
281
  if (!value.cmd && !value.testkitCmd) {
@@ -351,8 +378,8 @@ function normalizeSkipConfig(value, { name, productDir, suites }) {
351
378
  };
352
379
  }
353
380
 
354
- function normalizeServiceExecution(value, { name, suites }) {
355
- if (!value) return { suites: [], files: [], fileStackModeByPath: new Map() };
381
+ function normalizeServiceRequirements(value, { name, suites }) {
382
+ if (!value) return { suites: [], files: [], fileLocksByPath: new Map() };
356
383
 
357
384
  const discoveredSuites = [];
358
385
  const discoveredFiles = new Set();
@@ -372,25 +399,25 @@ function normalizeServiceExecution(value, { name, suites }) {
372
399
  const seenSelectors = new Set();
373
400
  for (const rule of value.suites || []) {
374
401
  if (!rule || typeof rule !== "object") {
375
- throw new Error(`Service "${name}" execution.suites entries must be objects`);
402
+ throw new Error(`Service "${name}" requirements.suites entries must be objects`);
376
403
  }
377
404
  const selector = String(rule.selector || "").trim();
378
405
  if (!selector) {
379
- throw new Error(`Service "${name}" execution.suites entries require a selector`);
406
+ throw new Error(`Service "${name}" requirements.suites entries require a selector`);
380
407
  }
381
408
  const parsed = parseSuiteSelectors([selector]);
382
409
  if (parsed.length !== 1) {
383
- throw new Error(`Service "${name}" execution.suites["${selector}"] is invalid`);
410
+ throw new Error(`Service "${name}" requirements.suites["${selector}"] is invalid`);
384
411
  }
385
412
  const parsedSelector = parsed[0];
386
413
  if (parsedSelector.kind !== "typed") {
387
414
  throw new Error(
388
- `Service "${name}" execution.suites["${selector}"] must use a typed selector like int:health`
415
+ `Service "${name}" requirements.suites["${selector}"] must use a typed selector like int:health`
389
416
  );
390
417
  }
391
418
  if (seenSelectors.has(parsedSelector.raw)) {
392
419
  throw new Error(
393
- `Service "${name}" defines duplicate execution.suites selector "${parsedSelector.raw}"`
420
+ `Service "${name}" defines duplicate requirements.suites selector "${parsedSelector.raw}"`
394
421
  );
395
422
  }
396
423
  const matched = discoveredSuites.some((suite) =>
@@ -398,15 +425,15 @@ function normalizeServiceExecution(value, { name, suites }) {
398
425
  );
399
426
  if (!matched) {
400
427
  throw new Error(
401
- `Service "${name}" execution.suites selector "${parsedSelector.raw}" did not match any discovered suite`
428
+ `Service "${name}" requirements.suites selector "${parsedSelector.raw}" did not match any discovered suite`
402
429
  );
403
430
  }
404
431
  seenSelectors.add(parsedSelector.raw);
405
432
  suiteRules.push({
406
433
  selector: parsedSelector,
407
- stackMode: normalizeStackModeValue(
408
- rule.stackMode,
409
- `Service "${name}" execution.suites["${parsedSelector.raw}"].stackMode`
434
+ locks: normalizeRequirementLocks(
435
+ rule.locks,
436
+ `Service "${name}" requirements.suites["${parsedSelector.raw}"].locks`
410
437
  ),
411
438
  });
412
439
  }
@@ -415,28 +442,28 @@ function normalizeServiceExecution(value, { name, suites }) {
415
442
  const seenFiles = new Set();
416
443
  for (const [index, rule] of (value.files || []).entries()) {
417
444
  if (!rule || typeof rule !== "object") {
418
- throw new Error(`Service "${name}" execution.files entries must be objects`);
445
+ throw new Error(`Service "${name}" requirements.files entries must be objects`);
419
446
  }
420
447
 
421
448
  const filePath = String(rule.path || "").trim();
422
449
  if (!filePath) {
423
- throw new Error(`Service "${name}" execution.files[${index}] requires a path`);
450
+ throw new Error(`Service "${name}" requirements.files[${index}] requires a path`);
424
451
  }
425
452
  if (!discoveredFiles.has(filePath)) {
426
453
  throw new Error(
427
- `Service "${name}" execution.files["${filePath}"] did not match any discovered test file`
454
+ `Service "${name}" requirements.files["${filePath}"] did not match any discovered test file`
428
455
  );
429
456
  }
430
457
  if (seenFiles.has(filePath)) {
431
- throw new Error(`Service "${name}" defines duplicate execution.files path "${filePath}"`);
458
+ throw new Error(`Service "${name}" defines duplicate requirements.files path "${filePath}"`);
432
459
  }
433
460
 
434
461
  seenFiles.add(filePath);
435
462
  fileRules.push({
436
463
  path: filePath,
437
- stackMode: normalizeStackModeValue(
438
- rule.stackMode,
439
- `Service "${name}" execution.files["${filePath}"].stackMode`
464
+ locks: normalizeRequirementLocks(
465
+ rule.locks,
466
+ `Service "${name}" requirements.files["${filePath}"].locks`
440
467
  ),
441
468
  });
442
469
  }
@@ -444,10 +471,28 @@ function normalizeServiceExecution(value, { name, suites }) {
444
471
  return {
445
472
  suites: suiteRules,
446
473
  files: fileRules,
447
- fileStackModeByPath: new Map(fileRules.map((rule) => [rule.path, rule.stackMode])),
474
+ fileLocksByPath: new Map(fileRules.map((rule) => [rule.path, rule.locks])),
448
475
  };
449
476
  }
450
477
 
478
+ function normalizeRequirementLocks(value, label) {
479
+ const input = Array.isArray(value) ? value : value == null ? [] : [value];
480
+ const seen = new Set();
481
+ const locks = [];
482
+
483
+ for (const rawLock of input) {
484
+ const lockName = String(rawLock || "").trim();
485
+ if (!lockName) {
486
+ throw new Error(`${label} entries must be non-empty strings`);
487
+ }
488
+ if (seen.has(lockName)) continue;
489
+ seen.add(lockName);
490
+ locks.push(lockName);
491
+ }
492
+
493
+ return locks.sort();
494
+ }
495
+
451
496
  function inferEnvFiles(productDir, explicitService, local) {
452
497
  if (explicitService.envFile || explicitService.envFiles) {
453
498
  const files = [];
@@ -508,6 +553,7 @@ function validateServiceConfig({
508
553
  local,
509
554
  database,
510
555
  databaseFrom,
556
+ runtime,
511
557
  migrate,
512
558
  seed,
513
559
  dependsOn,
@@ -529,6 +575,9 @@ function validateServiceConfig({
529
575
  if (database && databaseFrom) {
530
576
  throw new Error(`Service "${name}" cannot define both database and databaseFrom`);
531
577
  }
578
+ if (runtime.instances < 1) {
579
+ throw new Error(`Service "${name}" runtime.instances must be a positive integer`);
580
+ }
532
581
 
533
582
  for (const depName of dependsOn || []) {
534
583
  if (depName === name) {
@@ -587,8 +636,7 @@ function normalizeRepoExecution(execution) {
587
636
  if (!execution) {
588
637
  return normalizeExecutionConfig({
589
638
  workers: 1,
590
- stackMode: "isolated",
591
- stackCount: 1,
639
+ fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
592
640
  });
593
641
  }
594
642
  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(