@elench/testkit 0.1.37 → 0.1.39

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.
@@ -3,86 +3,77 @@ import path from "path";
3
3
  import { execaCommand } from "execa";
4
4
  import { resolveServiceCwd } from "../config/index.mjs";
5
5
  import { prepareDatabaseRuntime } from "../database/index.mjs";
6
- import { buildExecutionEnv, resolveWorkerRuntimeConfigs } from "./template.mjs";
6
+ import { buildExecutionEnv, resolveStackRuntimeConfigs } from "./template.mjs";
7
7
  import { writeGraphMetadata } from "./state.mjs";
8
8
  import { startLocalServices, stopLocalServices } from "./services.mjs";
9
9
 
10
- export function createGraphContext(worker, graph) {
11
- const graphDir = path.join(worker.productDir, ".testkit", "_graphs", graph.dirName);
12
- const workerStateDir = path.join(graphDir, "workers", `worker-${worker.workerId}`);
13
- fs.mkdirSync(workerStateDir, { recursive: true });
10
+ export function createStackContext(stackId, graph, productDir) {
11
+ const graphDir = path.join(productDir, ".testkit", "_graphs", graph.dirName);
12
+ const stackStateDir = path.join(graphDir, "stacks", stackId);
13
+ fs.mkdirSync(stackStateDir, { recursive: true });
14
14
  writeGraphMetadata(graphDir, graph);
15
15
 
16
- const runtimeConfigs = resolveWorkerRuntimeConfigs(
16
+ const runtimeConfigs = resolveStackRuntimeConfigs(
17
17
  graph.rootConfig,
18
18
  graph.runtimeConfigs,
19
- worker.workerId,
20
- workerStateDir
19
+ stackId,
20
+ stackStateDir
21
21
  );
22
22
 
23
23
  return {
24
24
  graphKey: graph.key,
25
+ assignedTargets: [...graph.assignedTargets],
25
26
  graphDir,
26
- workerStateDir,
27
+ stackId,
28
+ stackStateDir,
27
29
  runtimeConfigs,
28
30
  configByName: new Map(runtimeConfigs.map((config) => [config.name, config])),
29
31
  prepared: false,
32
+ preparationPromise: null,
30
33
  started: false,
34
+ startupPromise: null,
31
35
  startedServices: [],
32
36
  };
33
37
  }
34
38
 
35
- export async function ensureWorkerGraph(worker, batch, graphByKey, lifecycle) {
36
- const graph = graphByKey.get(batch.graphKey);
37
- if (!graph) {
38
- throw new Error(`Unknown graph "${batch.graphKey}"`);
39
- }
40
-
41
- if (worker.currentGraphKey && worker.currentGraphKey !== batch.graphKey) {
42
- await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey), lifecycle);
43
- worker.graphSwitches += 1;
44
- worker.currentGraphKey = null;
45
- }
46
-
47
- let context = worker.graphContexts.get(batch.graphKey);
48
- if (!context) {
49
- context = createGraphContext(worker, graph);
50
- worker.graphContexts.set(batch.graphKey, context);
51
- lifecycle.trackGraphContext(context);
52
- }
53
-
39
+ export async function ensureStackContextReady(context, batch, lifecycle) {
54
40
  if (!context.prepared) {
55
- await prepareDatabases(context.runtimeConfigs);
56
- context.prepared = true;
41
+ if (!context.preparationPromise) {
42
+ context.preparationPromise = (async () => {
43
+ await prepareDatabases(context.runtimeConfigs);
44
+ context.prepared = true;
45
+ })().finally(() => {
46
+ context.preparationPromise = null;
47
+ });
48
+ }
49
+ await context.preparationPromise;
57
50
  }
58
51
 
59
52
  if (batchNeedsLocalRuntime(batch) && !context.started) {
60
- context.startedServices = await startLocalServices(context.runtimeConfigs, lifecycle);
61
- context.started = true;
53
+ if (!context.startupPromise) {
54
+ context.startupPromise = (async () => {
55
+ context.startedServices = await startLocalServices(context.runtimeConfigs, lifecycle);
56
+ context.started = true;
57
+ })().finally(() => {
58
+ context.startupPromise = null;
59
+ });
60
+ }
61
+ await context.startupPromise;
62
62
  }
63
63
 
64
- worker.currentGraphKey = batch.graphKey;
65
64
  return context;
66
65
  }
67
66
 
68
- export async function deactivateGraphContext(context, lifecycle) {
67
+ export async function deactivateStackContext(context, lifecycle) {
69
68
  if (!context?.started) return;
70
69
  await stopLocalServices(context.startedServices, lifecycle);
71
70
  context.started = false;
72
71
  context.startedServices = [];
73
72
  }
74
73
 
75
- export async function resetCurrentGraph(worker, lifecycle) {
76
- if (!worker.currentGraphKey) return;
77
- await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey), lifecycle);
78
- worker.currentGraphKey = null;
79
- }
80
-
81
- export async function cleanupWorker(worker, lifecycle) {
82
- for (const context of worker.graphContexts.values()) {
83
- await deactivateGraphContext(context, lifecycle);
84
- }
85
- worker.currentGraphKey = null;
74
+ export async function cleanupStackContext(context, lifecycle) {
75
+ if (!context) return;
76
+ await deactivateStackContext(context, lifecycle);
86
77
  }
87
78
 
88
79
  export async function prepareDatabases(runtimeConfigs) {
@@ -103,7 +94,7 @@ async function runMigrate(config, databaseUrl) {
103
94
  const env = buildExecutionEnv(config, {}, process.env);
104
95
  if (databaseUrl) env.DATABASE_URL = databaseUrl;
105
96
 
106
- console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
97
+ console.log(`\n── migrate:${config.stackLabel}:${config.name} ──`);
107
98
  await execaCommand(migrate.cmd, {
108
99
  cwd: resolveServiceCwd(config.productDir, migrate.cwd),
109
100
  env,
@@ -119,7 +110,7 @@ async function runSeed(config, databaseUrl) {
119
110
  const env = buildExecutionEnv(config, {}, process.env);
120
111
  if (databaseUrl) env.DATABASE_URL = databaseUrl;
121
112
 
122
- console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
113
+ console.log(`\n── seed:${config.stackLabel}:${config.name} ──`);
123
114
  await execaCommand(seed.cmd, {
124
115
  cwd: resolveServiceCwd(config.productDir, seed.cwd),
125
116
  env,
@@ -36,12 +36,12 @@ export async function startLocalService(config, lifecycle) {
36
36
 
37
37
  await assertLocalServicePortsAvailable(config, isPortInUse);
38
38
 
39
- console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
39
+ console.log(`Starting ${config.stackLabel}:${config.name}: ${config.testkit.local.start}`);
40
40
  const child = startDetachedCommand(config.testkit.local.start, cwd, env);
41
41
 
42
42
  const outputDrains = [
43
- pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`),
44
- pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`),
43
+ pipeOutput(child.stdout, `[${config.stackLabel}:${config.name}]`),
44
+ pipeOutput(child.stderr, `[${config.stackLabel}:${config.name}]`),
45
45
  ];
46
46
  lifecycle.registerService(config, child, cwd);
47
47
 
@@ -49,7 +49,7 @@ export async function startLocalService(config, lifecycle) {
49
49
 
50
50
  try {
51
51
  await waitForReady({
52
- name: `${config.workerLabel}:${config.name}`,
52
+ name: `${config.stackLabel}:${config.name}`,
53
53
  url: config.testkit.local.readyUrl,
54
54
  timeoutMs: readyTimeoutMs,
55
55
  process: child,
@@ -0,0 +1,146 @@
1
+ import { buildStackIds } from "./execution-config.mjs";
2
+ import {
3
+ cleanupStackContext,
4
+ createStackContext,
5
+ ensureStackContextReady,
6
+ } from "./runtime-contexts.mjs";
7
+
8
+ export function createStackManager({ productDir, graphs, execution, lifecycle }) {
9
+ const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
10
+ const pools = new Map();
11
+
12
+ return {
13
+ async acquire(batch) {
14
+ const pool = getPool(pools, graphByKey, batch, execution, productDir, lifecycle);
15
+ while (true) {
16
+ if (lifecycle.isStopRequested()) {
17
+ throw lifecycle.signal.reason || new Error("testkit run interrupted");
18
+ }
19
+
20
+ const slot = claimSlot(pool, batch.accessMode);
21
+ if (!slot) {
22
+ await sleep(25);
23
+ continue;
24
+ }
25
+
26
+ try {
27
+ const context = await getReadyContext(slot, graphByKey, productDir, batch, lifecycle);
28
+ return {
29
+ slot,
30
+ context,
31
+ };
32
+ } catch (error) {
33
+ releaseSlot(slot, batch.accessMode);
34
+ await invalidateSlot(slot, lifecycle);
35
+ throw error;
36
+ }
37
+ }
38
+ },
39
+ async release(lease, options = {}) {
40
+ if (!lease?.slot) return;
41
+ releaseSlot(lease.slot, options.accessMode || lease.slot.lastAccessMode);
42
+ if (options.invalidate) {
43
+ await invalidateSlot(lease.slot, lifecycle);
44
+ }
45
+ },
46
+ async cleanupAll() {
47
+ for (const pool of pools.values()) {
48
+ for (const slot of pool.slots) {
49
+ await invalidateSlot(slot, lifecycle);
50
+ }
51
+ }
52
+ },
53
+ };
54
+ }
55
+
56
+ function getPool(pools, graphByKey, batch, execution, productDir, lifecycle) {
57
+ const key = `${batch.graphKey}:${batch.stackMode}`;
58
+ const existing = pools.get(key);
59
+ if (existing) return existing;
60
+
61
+ const graph = graphByKey.get(batch.graphKey);
62
+ if (!graph) {
63
+ throw new Error(`Unknown graph "${batch.graphKey}"`);
64
+ }
65
+
66
+ const slots = buildPoolStackIds(batch.stackMode, execution).map((stackId) => ({
67
+ graph,
68
+ stackId,
69
+ context: null,
70
+ activeSharedCount: 0,
71
+ exclusiveActive: false,
72
+ lastAccessMode: null,
73
+ contextPromise: null,
74
+ }));
75
+ const pool = {
76
+ productDir,
77
+ lifecycle,
78
+ slots,
79
+ };
80
+ pools.set(key, pool);
81
+ return pool;
82
+ }
83
+
84
+ function buildPoolStackIds(stackMode, execution) {
85
+ if (stackMode === "isolated") {
86
+ return Array.from({ length: execution.workers }, (_unused, index) => `isolated-${index + 1}`);
87
+ }
88
+ if (stackMode === "shared") {
89
+ return ["shared"];
90
+ }
91
+ return buildStackIds({
92
+ stackMode: "pooled",
93
+ stackCount: execution.stackCount,
94
+ });
95
+ }
96
+
97
+ function claimSlot(pool, accessMode) {
98
+ if (accessMode === "shared") {
99
+ const candidates = pool.slots.filter((slot) => !slot.exclusiveActive);
100
+ if (candidates.length === 0) return null;
101
+ const slot = [...candidates].sort((left, right) => left.activeSharedCount - right.activeSharedCount)[0];
102
+ slot.activeSharedCount += 1;
103
+ slot.lastAccessMode = "shared";
104
+ return slot;
105
+ }
106
+
107
+ const slot = pool.slots.find((candidate) => !candidate.exclusiveActive && candidate.activeSharedCount === 0);
108
+ if (!slot) return null;
109
+ slot.exclusiveActive = true;
110
+ slot.lastAccessMode = "exclusive";
111
+ return slot;
112
+ }
113
+
114
+ async function getReadyContext(slot, graphByKey, productDir, batch, lifecycle) {
115
+ if (!slot.context) {
116
+ slot.context = createStackContext(slot.stackId, slot.graph, productDir);
117
+ lifecycle.trackGraphContext(slot.context);
118
+ }
119
+ if (!slot.contextPromise) {
120
+ slot.contextPromise = Promise.resolve(slot.context);
121
+ }
122
+ await slot.contextPromise;
123
+ await ensureStackContextReady(slot.context, batch, lifecycle);
124
+ return slot.context;
125
+ }
126
+
127
+ function releaseSlot(slot, accessMode) {
128
+ if (accessMode === "shared") {
129
+ slot.activeSharedCount = Math.max(0, slot.activeSharedCount - 1);
130
+ return;
131
+ }
132
+ slot.exclusiveActive = false;
133
+ }
134
+
135
+ async function invalidateSlot(slot, lifecycle) {
136
+ if (!slot.context) return;
137
+ await cleanupStackContext(slot.context, lifecycle);
138
+ slot.context = null;
139
+ slot.contextPromise = null;
140
+ slot.activeSharedCount = 0;
141
+ slot.exclusiveActive = false;
142
+ }
143
+
144
+ function sleep(ms) {
145
+ return new Promise((resolve) => setTimeout(resolve, ms));
146
+ }
@@ -5,6 +5,29 @@ export function readDatabaseUrl(stateDir) {
5
5
  return readStateValue(path.join(stateDir, "database_url"));
6
6
  }
7
7
 
8
+ export function readDatabaseInfo(stateDir) {
9
+ return parseDatabaseUrl(readDatabaseUrl(stateDir));
10
+ }
11
+
12
+ export function parseDatabaseUrl(databaseUrl) {
13
+ if (!databaseUrl) return null;
14
+
15
+ try {
16
+ const parsed = new URL(databaseUrl);
17
+ const port = Number(parsed.port || "5432");
18
+ return {
19
+ url: databaseUrl,
20
+ host: parsed.hostname,
21
+ port: Number.isInteger(port) && port > 0 ? port : 5432,
22
+ database: parsed.pathname.replace(/^\//, ""),
23
+ user: decodeURIComponent(parsed.username || ""),
24
+ password: decodeURIComponent(parsed.password || ""),
25
+ };
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
8
31
  export function readStateValue(filePath) {
9
32
  if (!fs.existsSync(filePath)) return null;
10
33
  return fs.readFileSync(filePath, "utf8").trim();
@@ -1,30 +1,43 @@
1
1
  import path from "path";
2
+ import { readDatabaseInfo } from "./state-io.mjs";
2
3
 
3
4
  const PORT_STRIDE = 100;
4
5
 
5
- export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, workerId, workerStateDir) {
6
- const portMap = buildPortMap(runtimeConfigs, workerId);
6
+ export function resolveStackRuntimeConfigs(targetConfig, runtimeConfigs, stackId, stackStateDir) {
7
+ const portMap = buildPortMap(runtimeConfigs, stackId);
7
8
  const baseUrlByService = new Map();
8
9
  const readyUrlByService = new Map();
10
+ const stateDirByService = new Map();
11
+
12
+ for (const config of runtimeConfigs) {
13
+ stateDirByService.set(
14
+ config.name,
15
+ resolveServiceStateDir(stackStateDir, targetConfig.name, config)
16
+ );
17
+ }
9
18
 
10
19
  for (const config of runtimeConfigs) {
11
20
  if (!config.testkit.local) continue;
12
21
  baseUrlByService.set(
13
22
  config.name,
14
- resolveRuntimeUrl(config.testkit.local.baseUrl, config.name, targetConfig, workerId, {
15
- workerStateDir,
23
+ resolveRuntimeUrl(config.testkit.local.baseUrl, config.name, targetConfig, stackId, {
24
+ stackId,
25
+ stackStateDir,
16
26
  portMap,
17
27
  baseUrlByService,
18
28
  readyUrlByService,
29
+ stateDirByService,
19
30
  })
20
31
  );
21
32
  readyUrlByService.set(
22
33
  config.name,
23
- resolveRuntimeUrl(config.testkit.local.readyUrl, config.name, targetConfig, workerId, {
24
- workerStateDir,
34
+ resolveRuntimeUrl(config.testkit.local.readyUrl, config.name, targetConfig, stackId, {
35
+ stackId,
36
+ stackStateDir,
25
37
  portMap,
26
38
  baseUrlByService,
27
39
  readyUrlByService,
40
+ stateDirByService,
28
41
  })
29
42
  );
30
43
  }
@@ -43,23 +56,24 @@ export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, worker
43
56
  }
44
57
 
45
58
  return runtimeConfigs.map((config) =>
46
- resolveWorkerConfig(
59
+ resolveStackConfig(
47
60
  config,
48
61
  targetConfig,
49
- workerId,
50
- workerStateDir,
62
+ stackId,
63
+ stackStateDir,
51
64
  portMap,
52
65
  baseUrlByService,
53
66
  readyUrlByService,
67
+ stateDirByService,
54
68
  urlMappings
55
69
  )
56
70
  );
57
71
  }
58
72
 
59
- export function buildPortMap(runtimeConfigs, workerId) {
73
+ export function buildPortMap(runtimeConfigs, stackId) {
60
74
  const portMap = new Map();
61
75
  const seen = new Map();
62
- const offset = PORT_STRIDE * (workerId - 1);
76
+ const offset = stackPortOffset(stackId);
63
77
 
64
78
  for (const config of runtimeConfigs) {
65
79
  if (!config.testkit.local) continue;
@@ -71,7 +85,7 @@ export function buildPortMap(runtimeConfigs, workerId) {
71
85
  const existing = seen.get(actualPort);
72
86
  if (existing) {
73
87
  throw new Error(
74
- `Worker port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
88
+ `Stack port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
75
89
  `Assign distinct local.port/baseUrl ports in testkit.setup.ts.`
76
90
  );
77
91
  }
@@ -82,25 +96,27 @@ export function buildPortMap(runtimeConfigs, workerId) {
82
96
  return portMap;
83
97
  }
84
98
 
85
- export function resolveWorkerConfig(
99
+ export function resolveStackConfig(
86
100
  config,
87
101
  targetConfig,
88
- workerId,
89
- workerStateDir,
102
+ stackId,
103
+ stackStateDir,
90
104
  portMap,
91
105
  baseUrlByService,
92
106
  readyUrlByService,
107
+ stateDirByService,
93
108
  urlMappings
94
109
  ) {
95
- const stateDir = resolveServiceStateDir(workerStateDir, targetConfig.name, config);
110
+ const stateDir = resolveServiceStateDir(stackStateDir, targetConfig.name, config);
96
111
  const context = {
97
- workerId,
98
- serviceName: config.name,
112
+ stackId,
99
113
  targetName: targetConfig.name,
114
+ serviceName: config.name,
100
115
  serviceStateDir: stateDir,
101
116
  portMap,
102
117
  baseUrlByService,
103
118
  readyUrlByService,
119
+ stateDirByService,
104
120
  urlMappings,
105
121
  };
106
122
 
@@ -143,51 +159,48 @@ export function resolveWorkerConfig(
143
159
  port: portMap.get(config.name) || config.testkit.local.port,
144
160
  baseUrl: baseUrlByService.get(config.name),
145
161
  readyUrl: readyUrlByService.get(config.name),
146
- env: Object.fromEntries(
147
- Object.entries(config.testkit.local.env || {}).map(([key, value]) => [
148
- key,
149
- finalizeString(String(value), context),
150
- ])
151
- ),
162
+ env: { ...(config.testkit.local.env || {}) },
152
163
  }
153
164
  : undefined;
154
165
 
155
166
  return {
156
167
  ...config,
157
168
  stateDir,
158
- workerId,
159
- workerLabel: `w${workerId}`,
169
+ stackId,
170
+ stackLabel: stackId,
160
171
  targetName: targetConfig.name,
161
172
  testkit: {
162
173
  ...config.testkit,
163
174
  database,
164
175
  migrate,
165
176
  seed,
177
+ templateContext: context,
166
178
  local,
167
179
  },
168
180
  };
169
181
  }
170
182
 
171
- export function resolveServiceStateDir(workerStateDir, targetName, config) {
183
+ export function resolveServiceStateDir(stackStateDir, targetName, config) {
172
184
  const dbSource = config.testkit.databaseFrom || config.name;
173
- return getWorkerServiceStateDir(workerStateDir, targetName, dbSource);
185
+ return getStackServiceStateDir(stackStateDir, targetName, dbSource);
174
186
  }
175
187
 
176
- export function getWorkerServiceStateDir(workerStateDir, targetName, serviceName) {
188
+ export function getStackServiceStateDir(stackStateDir, targetName, serviceName) {
177
189
  if (targetName === serviceName) {
178
- return workerStateDir;
190
+ return stackStateDir;
179
191
  }
180
- return path.join(workerStateDir, "deps", serviceName);
192
+ return path.join(stackStateDir, "deps", serviceName);
181
193
  }
182
194
 
183
195
  export function buildExecutionEnv(config, extraEnv = {}, processEnv = process.env) {
184
196
  const inheritedEnv = { ...processEnv };
197
+ const templateContext = config.testkit?.templateContext;
185
198
  const env = {
186
199
  ...inheritedEnv,
187
- ...(config.testkit.serviceEnv || {}),
188
- ...extraEnv,
200
+ ...resolveEnvTemplates(config.testkit.serviceEnv || {}, templateContext),
201
+ ...resolveEnvTemplates(extraEnv, templateContext),
189
202
  TESTKIT_ACTIVE: "1",
190
- ...(config.workerId ? { TESTKIT_WORKER_ID: String(config.workerId) } : {}),
203
+ ...(config.stackId ? { TESTKIT_STACK_ID: String(config.stackId) } : {}),
191
204
  };
192
205
  delete env.DATABASE_URL;
193
206
  return env;
@@ -202,17 +215,17 @@ export function buildPlaywrightEnv(config, baseUrl, processEnv = process.env) {
202
215
  PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS:
203
216
  processEnv.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS || "1",
204
217
  TESTKIT_MANAGED_SERVERS: "1",
205
- TESTKIT_WORKER_ID: String(config.workerId),
218
+ TESTKIT_STACK_ID: String(config.stackId),
206
219
  },
207
220
  processEnv
208
221
  );
209
222
  }
210
223
 
211
- export function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
224
+ export function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, stackId, context) {
212
225
  const resolved = resolveTemplateString(rawUrl, {
213
226
  ...context,
214
227
  targetName: targetConfig.name,
215
- workerId,
228
+ stackId,
216
229
  serviceName,
217
230
  });
218
231
  const actualPort = context.portMap.get(serviceName);
@@ -234,8 +247,8 @@ export function resolveTemplateString(value, context) {
234
247
 
235
248
  return value.replace(/\{([a-zA-Z]+)(?::([a-zA-Z0-9_-]+))?\}/g, (_match, token, arg) => {
236
249
  switch (token) {
237
- case "worker":
238
- return String(context.workerId);
250
+ case "stack":
251
+ return String(context.stackId);
239
252
  case "target":
240
253
  return context.targetName;
241
254
  case "service":
@@ -266,12 +279,61 @@ export function resolveTemplateString(value, context) {
266
279
  }
267
280
  return readyUrl;
268
281
  }
282
+ case "dbUrl":
283
+ case "dbHost":
284
+ case "dbPort":
285
+ case "dbName":
286
+ case "dbUser":
287
+ case "dbPassword": {
288
+ const serviceName = arg || context.serviceName;
289
+ return resolveDatabaseTemplateValue(token, serviceName, context);
290
+ }
269
291
  default:
270
292
  throw new Error(`Unsupported template token "{${token}${arg ? `:${arg}` : ""}}"`);
271
293
  }
272
294
  });
273
295
  }
274
296
 
297
+ function resolveEnvTemplates(values, templateContext) {
298
+ return Object.fromEntries(
299
+ Object.entries(values || {}).map(([key, value]) => [
300
+ key,
301
+ typeof value === "string" && templateContext ? finalizeString(value, templateContext) : value,
302
+ ])
303
+ );
304
+ }
305
+
306
+ function resolveDatabaseTemplateValue(token, serviceName, context) {
307
+ const stateDir = context.stateDirByService?.get(serviceName);
308
+ if (!stateDir) {
309
+ throw new Error(`Unknown database placeholder for service "${serviceName}"`);
310
+ }
311
+
312
+ const info = readDatabaseInfo(stateDir);
313
+ if (!info) {
314
+ throw new Error(
315
+ `Database placeholder "{${token}:${serviceName}}" is unavailable before "${serviceName}" database preparation`
316
+ );
317
+ }
318
+
319
+ switch (token) {
320
+ case "dbUrl":
321
+ return info.url;
322
+ case "dbHost":
323
+ return info.host;
324
+ case "dbPort":
325
+ return String(info.port);
326
+ case "dbName":
327
+ return info.database;
328
+ case "dbUser":
329
+ return info.user;
330
+ case "dbPassword":
331
+ return info.password;
332
+ default:
333
+ throw new Error(`Unsupported database placeholder "{${token}:${serviceName}}"`);
334
+ }
335
+ }
336
+
275
337
  export function rewriteUrlPort(rawUrl, port) {
276
338
  try {
277
339
  const original = new URL(rawUrl);
@@ -318,3 +380,9 @@ export function normalizeSocketHost(hostname) {
318
380
  if (hostname === "[::1]") return "::1";
319
381
  return hostname;
320
382
  }
383
+
384
+ function stackPortOffset(stackId) {
385
+ const match = String(stackId).match(/(\d+)$/);
386
+ if (!match) return 0;
387
+ return PORT_STRIDE * (Number.parseInt(match[1], 10) - 1);
388
+ }