@elench/testkit 0.1.143 → 0.1.145

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,6 +61,7 @@ npx @elench/testkit --type int --write-status
61
61
  npx @elench/testkit status
62
62
  npx @elench/testkit destroy
63
63
  npx @elench/testkit cleanup
64
+ npx @elench/testkit cleanup --resources --global --dry-run
64
65
 
65
66
  # Local production environment
66
67
  npx @elench/testkit local up
@@ -230,6 +231,7 @@ import {
230
231
  defineConfig,
231
232
  defineFile,
232
233
  environment,
234
+ resource,
233
235
  toolchain,
234
236
  } from "@elench/testkit/config";
235
237
 
@@ -257,9 +259,17 @@ export default defineConfig({
257
259
  }),
258
260
  },
259
261
  environments: {
260
- local: environment.local({
262
+ local: environment.productionLike({
261
263
  target: "frontend",
262
264
  data: "reuse",
265
+ resources: {
266
+ databases: {
267
+ api: resource.postgres({
268
+ version: "16",
269
+ extensions: ["vector"],
270
+ }),
271
+ },
272
+ },
263
273
  }),
264
274
  },
265
275
  services: {
@@ -342,10 +352,15 @@ right way to move expensive browser targets from `next dev` / watch mode to
342
352
  stable build-and-start flows.
343
353
 
344
354
  `testkit local` starts the same service graph as a persistent local production
345
- environment instead of a test run. It provisions local databases, runs template
346
- setup, runs `runtime.prepare`, starts dependent services, and records state
347
- under `.testkit/environments/<name>/` rather than `.testkit/_runs`. Local
348
- environment processes receive `TESTKIT_ACTIVE=1`, `TESTKIT_MODE=local`, and
355
+ environment instead of a test run. Service database declarations stay logical:
356
+ `database.postgres(...)` says the service needs Postgres, while the selected
357
+ environment decides where that database is materialized. Normal `testkit run`
358
+ uses transient host Docker Postgres. `environment.productionLike(...)` uses the
359
+ Kiln driver by default and maps `resources.databases.<serviceName>` to durable
360
+ Postgres appliances for `testkit local up`. It runs template setup,
361
+ `runtime.prepare`, starts dependent services, and records state under
362
+ `.testkit/environments/<name>/` rather than `.testkit/_runs`. Local environment
363
+ processes receive `TESTKIT_ACTIVE=1`, `TESTKIT_MODE=local`, and
349
364
  `TESTKIT_LOCAL_ENV=<name>`. Use `data: "reuse"` for fast restarts against the
350
365
  existing local runtime database, `data: "reset"` to refresh runtime databases
351
366
  from their templates on each launch, or `--rebuild` to destroy and recreate the
@@ -353,6 +368,15 @@ environment state. Testkit only supports local environments here; it does not
353
368
  copy production data and it refuses managed runtime database URLs that are not
354
369
  loopback PostgreSQL URLs.
355
370
 
371
+ Testkit-owned Docker resources are labelled at creation time so they can be
372
+ found again even if product-local `.testkit` state is deleted. `testkit destroy`
373
+ removes labelled resources for the current product after service state is
374
+ destroyed. `testkit cleanup --resources` cleans stale resources for the current
375
+ product; add `--global` to clean labelled resources whose product directory no
376
+ longer exists, and add `--include-legacy` only when you intentionally want to
377
+ remove old unlabelled `testkit_pg_*` containers from pre-ownership Testkit
378
+ versions. Use `--dry-run` first when cleaning globally.
379
+
356
380
  `database.template` is the database-side equivalent for reusable template DB
357
381
  state. When `database.sourceSchema` is configured, Testkit treats the configured
358
382
  source database as the schema source of truth. A normal `testkit run` resolves a
@@ -741,6 +765,9 @@ Git metadata.
741
765
  `@elench/testkit` provisions Docker-managed local Postgres automatically for
742
766
  services that define `database: database.postgres(...)`.
743
767
 
768
+ - normal test runs always use transient host-managed Postgres
769
+ - named production-like local environments can place the same logical service
770
+ database on a Kiln Postgres appliance via `resources.databases.<serviceName>`
744
771
  - template databases are cached
745
772
  - runtime databases are cloned from templates when binding is `per-runtime`
746
773
  - shared databases are reused when binding is `shared`
@@ -19,6 +19,18 @@ export default class CleanupCommand extends Command {
19
19
  multiple: true,
20
20
  options: ["runtime", "bundles", "assistant", "all"],
21
21
  }),
22
+ resources: Flags.boolean({
23
+ description: "Clean Testkit-owned Docker resources that are stale or orphaned",
24
+ default: false,
25
+ }),
26
+ global: Flags.boolean({
27
+ description: "Find orphaned Testkit-owned resources across all products",
28
+ default: false,
29
+ }),
30
+ "include-legacy": Flags.boolean({
31
+ description: "Include old unlabelled testkit_pg_* Docker containers in resource cleanup",
32
+ default: false,
33
+ }),
22
34
  };
23
35
 
24
36
  async run() {
@@ -9,5 +9,8 @@ export async function executeCleanupOperation(flags = {}) {
9
9
  serviceName: flags.service || null,
10
10
  dryRun: flags["dry-run"],
11
11
  cache: flags.cache || [],
12
+ resources: flags.resources,
13
+ globalResources: flags.global,
14
+ includeLegacyResources: flags["include-legacy"],
12
15
  });
13
16
  }
@@ -1,12 +1,17 @@
1
1
  import * as runner from "../../../runner/index.mjs";
2
2
  import { loadManagedConfigs } from "../../../app/configs.mjs";
3
+ import { destroyOwnedLocalDatabaseResources } from "../../../database/index.mjs";
3
4
 
4
5
  export async function executeDestroyOperation(flags = {}) {
5
- const { configs } = await loadManagedConfigs({ dir: flags.dir, service: flags.service });
6
+ const { allConfigs, configs } = await loadManagedConfigs({ dir: flags.dir, service: flags.service });
6
7
  const results = [];
7
8
  for (const config of configs) {
8
9
  await runner.destroy(config);
9
10
  results.push({ name: config.name, destroyed: true });
10
11
  }
12
+ const productDir = allConfigs[0]?.productDir || process.cwd();
13
+ if (!flags.service) {
14
+ await destroyOwnedLocalDatabaseResources(productDir);
15
+ }
11
16
  return results;
12
17
  }
@@ -0,0 +1,62 @@
1
+ const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
2
+ const DEFAULT_LOCAL_USER = "testkit";
3
+ const DEFAULT_LOCAL_PASSWORD = "testkit";
4
+
5
+ export function buildEnvironmentDatabaseMaterialization(environment = {}) {
6
+ const databases = environment.resources?.databases || {};
7
+ return {
8
+ resourcesByService: Object.fromEntries(
9
+ Object.keys(databases).map((serviceName) => [serviceName, serviceName])
10
+ ),
11
+ };
12
+ }
13
+
14
+ export function materializeDatabaseConfig(database, serviceName, materialization = {}) {
15
+ if (!database) return undefined;
16
+ if (database.provider) {
17
+ return materializeExplicitProviderDatabaseConfig(database, serviceName);
18
+ }
19
+
20
+ const resourceName = materialization.resourcesByService?.[serviceName] || null;
21
+ if (resourceName) {
22
+ return {
23
+ ...database,
24
+ provider: "resource",
25
+ selectedBackend: "resource",
26
+ resource: resourceName,
27
+ };
28
+ }
29
+
30
+ return {
31
+ ...database,
32
+ provider: "local",
33
+ selectedBackend: "local",
34
+ image: database.image || DEFAULT_LOCAL_IMAGE,
35
+ user: database.user || DEFAULT_LOCAL_USER,
36
+ password: database.password || DEFAULT_LOCAL_PASSWORD,
37
+ };
38
+ }
39
+
40
+ function materializeExplicitProviderDatabaseConfig(database, serviceName) {
41
+ if (database.provider === "resource") {
42
+ const resource = String(database.resource || "").trim();
43
+ if (!resource) {
44
+ throw new Error(`Service "${serviceName}" database.resource must be a non-empty string`);
45
+ }
46
+ return {
47
+ ...database,
48
+ resource,
49
+ selectedBackend: "resource",
50
+ };
51
+ }
52
+ if (database.provider === "local") {
53
+ return {
54
+ ...database,
55
+ selectedBackend: "local",
56
+ image: database.image || DEFAULT_LOCAL_IMAGE,
57
+ user: database.user || DEFAULT_LOCAL_USER,
58
+ password: database.password || DEFAULT_LOCAL_PASSWORD,
59
+ };
60
+ }
61
+ throw new Error(`Service "${serviceName}" database.provider must be "local" or "resource"`);
62
+ }
@@ -4,50 +4,30 @@ import {
4
4
  normalizeConfiguredSteps,
5
5
  } from "../shared/configured-steps.mjs";
6
6
 
7
- const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
8
- const DEFAULT_LOCAL_USER = "testkit";
9
- const DEFAULT_LOCAL_PASSWORD = "testkit";
10
-
11
7
  export function normalizeDatabaseConfig(explicitService, serviceName) {
12
8
  if (explicitService.databaseFrom) return undefined;
13
9
  if (!explicitService.database) return undefined;
14
10
 
15
11
  const rawDatabase = explicitService.database;
16
- const provider = rawDatabase.provider || (rawDatabase.resource ? "resource" : "local");
17
- if (!["local", "resource"].includes(provider)) {
18
- throw new Error(`Service "${serviceName}" database.provider must be "local" or "resource"`);
12
+ if (Object.prototype.hasOwnProperty.call(rawDatabase, "provider")) {
13
+ throw new Error(
14
+ `Service "${serviceName}" database.provider has been removed. Configure database placement in environments.<name>.resources.databases instead.`
15
+ );
16
+ }
17
+ if (Object.prototype.hasOwnProperty.call(rawDatabase, "resource")) {
18
+ throw new Error(
19
+ `Service "${serviceName}" database.resource has been removed. Configure database placement in environments.<name>.resources.databases instead.`
20
+ );
19
21
  }
20
22
 
21
- const base = {
23
+ return {
22
24
  ...rawDatabase,
23
- provider,
24
25
  binding: normalizeDatabaseBinding(rawDatabase.binding || "per-runtime", `Service "${serviceName}" database.binding`),
25
26
  reset: rawDatabase.reset !== false,
26
27
  sourceSchema: normalizeSourceSchemaConfig(rawDatabase.sourceSchema, serviceName),
27
28
  template: normalizeDatabaseTemplateConfig(rawDatabase.template, serviceName),
28
29
  serviceName,
29
30
  };
30
-
31
- if (provider === "resource") {
32
- const resource = String(rawDatabase.resource || "").trim();
33
- if (!resource) {
34
- throw new Error(`Service "${serviceName}" database.resource must be a non-empty string`);
35
- }
36
- return {
37
- ...base,
38
- resource,
39
- selectedBackend: "resource",
40
- };
41
- }
42
-
43
- return {
44
- ...base,
45
- provider: "local",
46
- selectedBackend: "local",
47
- image: rawDatabase.image || DEFAULT_LOCAL_IMAGE,
48
- user: rawDatabase.user || DEFAULT_LOCAL_USER,
49
- password: rawDatabase.password || DEFAULT_LOCAL_PASSWORD,
50
- };
51
31
  }
52
32
 
53
33
  export function normalizeDatabaseTemplateConfig(value, serviceName) {
@@ -244,19 +244,39 @@ function normalizeEnvironmentConfig(name, environment) {
244
244
  }
245
245
 
246
246
  function normalizeEnvironmentResources(environmentName, resources = {}) {
247
- if (resources == null) return {};
247
+ if (resources == null) return { databases: {}, servers: {} };
248
248
  if (typeof resources !== "object" || Array.isArray(resources)) {
249
249
  throw new Error(`Environment "${environmentName}" resources must be an object`);
250
250
  }
251
+ const allowedKeys = new Set(["databases", "servers"]);
252
+ const unexpectedKeys = Object.keys(resources).filter((key) => !allowedKeys.has(key));
253
+ if (unexpectedKeys.length > 0) {
254
+ throw new Error(
255
+ `Environment "${environmentName}" resources only supports "databases" and "servers". Received unexpected key(s): ${unexpectedKeys.join(", ")}`
256
+ );
257
+ }
258
+ return {
259
+ databases: normalizeEnvironmentResourceGroup(environmentName, "databases", resources.databases, ["postgres"]),
260
+ servers: normalizeEnvironmentResourceGroup(environmentName, "servers", resources.servers, ["server"]),
261
+ };
262
+ }
263
+
264
+ function normalizeEnvironmentResourceGroup(environmentName, groupName, group = {}, allowedKinds) {
265
+ if (group == null) return {};
266
+ if (typeof group !== "object" || Array.isArray(group)) {
267
+ throw new Error(`Environment "${environmentName}" resources.${groupName} must be an object`);
268
+ }
251
269
  return Object.fromEntries(
252
- Object.entries(resources).map(([name, resource]) => {
253
- const normalizedName = normalizeDatabaseEnvToken(name, `Environment "${environmentName}" resource name`, false);
270
+ Object.entries(group).map(([name, resource]) => {
271
+ const normalizedName = normalizeDatabaseEnvToken(name, `Environment "${environmentName}" resources.${groupName} name`, false);
254
272
  if (!resource || typeof resource !== "object" || Array.isArray(resource)) {
255
- throw new Error(`Environment "${environmentName}" resource "${name}" must be an object`);
273
+ throw new Error(`Environment "${environmentName}" resources.${groupName}.${name} must be an object`);
256
274
  }
257
275
  const kind = String(resource.kind || "").trim();
258
- if (!["postgres", "server"].includes(kind)) {
259
- throw new Error(`Environment "${environmentName}" resource "${name}" kind must be "postgres" or "server"`);
276
+ if (!allowedKinds.includes(kind)) {
277
+ throw new Error(
278
+ `Environment "${environmentName}" resources.${groupName}.${name} kind must be ${allowedKinds.map((entry) => `"${entry}"`).join(" or ")}`
279
+ );
260
280
  }
261
281
  return [normalizedName, { ...resource, kind }];
262
282
  })
@@ -310,7 +330,7 @@ function normalizeEnvironmentEnv(env) {
310
330
  const unexpectedKeys = Object.keys(env).filter((key) => !allowedKeys.has(key));
311
331
  if (unexpectedKeys.length > 0) {
312
332
  throw new Error(
313
- `Environment env only supports "values" and "databases". Received unexpected key(s): ${unexpectedKeys.join(", ")}`
333
+ `Environment env only supports "values", "databases", and "resources". Received unexpected key(s): ${unexpectedKeys.join(", ")}`
314
334
  );
315
335
  }
316
336
  const values = env.values && typeof env.values === "object" && !Array.isArray(env.values) ? env.values : {};
@@ -85,8 +85,7 @@ export interface StepsBuildConfig {
85
85
 
86
86
  export type BuildConfig = TscBuildConfig | ScriptBuildConfig | NextBuildConfig | StepsBuildConfig;
87
87
 
88
- export interface LocalDatabaseConfig {
89
- provider: "local";
88
+ export interface PostgresDatabaseConfig {
90
89
  binding?: "shared" | "per-runtime";
91
90
  image?: string;
92
91
  password?: string;
@@ -96,17 +95,15 @@ export interface LocalDatabaseConfig {
96
95
  user?: string;
97
96
  }
98
97
 
99
- export interface ResourceDatabaseConfig {
98
+ export interface MaterializedLocalDatabaseConfig extends PostgresDatabaseConfig {
99
+ provider: "local";
100
+ }
101
+
102
+ export interface MaterializedResourceDatabaseConfig extends PostgresDatabaseConfig {
100
103
  provider: "resource";
101
- binding?: "shared" | "per-runtime";
102
- reset?: boolean;
103
104
  resource: string;
104
- sourceSchema?: DatabaseSourceSchemaConfig | null;
105
- template?: DatabaseTemplateConfig;
106
105
  }
107
106
 
108
- export type PostgresDatabaseConfig = LocalDatabaseConfig | ResourceDatabaseConfig;
109
-
110
107
  export interface SkipFileRule {
111
108
  path: string;
112
109
  reason: string;
@@ -324,7 +321,10 @@ export interface ServerResourceConfig {
324
321
  vm?: KilnVMResourceConfig;
325
322
  }
326
323
 
327
- export type ResourceConfig = PostgresResourceConfig | ServerResourceConfig;
324
+ export interface EnvironmentResourceConfig {
325
+ databases?: Record<string, PostgresResourceConfig>;
326
+ servers?: Record<string, ServerResourceConfig>;
327
+ }
328
328
 
329
329
  export interface LocalEnvironmentConfig {
330
330
  kind: "local";
@@ -335,7 +335,7 @@ export interface LocalEnvironmentConfig {
335
335
  portOffset?: number;
336
336
  productionLike?: boolean;
337
337
  publicHost?: string;
338
- resources?: Record<string, ResourceConfig>;
338
+ resources?: EnvironmentResourceConfig;
339
339
  target: string;
340
340
  }
341
341
 
@@ -625,36 +625,16 @@ export declare const database: {
625
625
  postgresConnectionFromEnv(prefix: string): unknown;
626
626
  };
627
627
  postgres(
628
- options?: Omit<LocalDatabaseConfig, "provider" | "template"> & {
628
+ options?: Omit<PostgresDatabaseConfig, "template"> & {
629
629
  inputs?: string[];
630
630
  migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
631
631
  seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
632
632
  template?: DatabaseTemplateOptions;
633
633
  verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
634
634
  }
635
- ): LocalDatabaseConfig;
636
- postgres(
637
- options?: Omit<ResourceDatabaseConfig, "provider" | "template"> & {
638
- inputs?: string[];
639
- migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
640
- seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
641
- template?: DatabaseTemplateOptions;
642
- verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
643
- }
644
- ): ResourceDatabaseConfig;
645
- fixture(
646
- options?: Omit<LocalDatabaseConfig, "provider" | "template"> & {
647
- inputs?: string[];
648
- migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
649
- seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
650
- template?: DatabaseTemplateOptions;
651
- verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
652
- discovery?: DiscoveryConfig;
653
- envFiles?: string[];
654
- }
655
- ): ServiceConfig;
635
+ ): PostgresDatabaseConfig;
656
636
  fixture(
657
- options?: Omit<ResourceDatabaseConfig, "provider" | "template"> & {
637
+ options?: Omit<PostgresDatabaseConfig, "template"> & {
658
638
  inputs?: string[];
659
639
  migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
660
640
  seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
@@ -27,9 +27,14 @@ export function defineFile(metadata) {
27
27
  }
28
28
 
29
29
  function postgresDatabase(options = {}) {
30
- const provider = options.resource ? "resource" : options.provider || "local";
30
+ for (const removedKey of ["provider", "resource"]) {
31
+ if (Object.prototype.hasOwnProperty.call(options, removedKey)) {
32
+ throw new Error(
33
+ `database.postgres(...) no longer accepts "${removedKey}". Configure database placement in environments.<name>.resources.databases instead.`
34
+ );
35
+ }
36
+ }
31
37
  return {
32
- provider,
33
38
  ...options,
34
39
  };
35
40
  }
@@ -561,7 +566,7 @@ function normalizePresetEnv(env) {
561
566
  const unexpectedKeys = Object.keys(env).filter((key) => !allowedKeys.has(key));
562
567
  if (unexpectedKeys.length > 0) {
563
568
  throw new Error(
564
- `Preset env only supports "values" and "databases". Received unexpected key(s): ${unexpectedKeys.join(", ")}`
569
+ `Preset env only supports "values", "databases", and "resources". Received unexpected key(s): ${unexpectedKeys.join(", ")}`
565
570
  );
566
571
  }
567
572
 
@@ -28,6 +28,20 @@ import {
28
28
  runTemplateStage,
29
29
  runTemplateStep,
30
30
  } from "./template-steps.mjs";
31
+ import { materializeDatabaseConfig } from "../config/database-materialization.mjs";
32
+ import {
33
+ TESTKIT_PRODUCT_DIR_LABEL,
34
+ TESTKIT_PRODUCT_ID_LABEL,
35
+ TESTKIT_RESOURCE_KIND_LABEL,
36
+ TESTKIT_SCOPE_LABEL,
37
+ buildDockerResourceLabels,
38
+ buildProductIdentity,
39
+ dockerContainerSummary,
40
+ dockerLabelArgs,
41
+ listLegacyTestkitPostgresContainers,
42
+ listManagedDockerContainers,
43
+ removeDockerContainer,
44
+ } from "../ownership/docker.mjs";
31
45
  import {
32
46
  applySourceSchemaCache,
33
47
  createSourceSchemaMismatchError,
@@ -49,16 +63,29 @@ const LOCAL_DROP_DATABASE_TIMEOUT_MS = 15_000;
49
63
  const LOCAL_DROP_DATABASE_POLL_INTERVAL_MS = 250;
50
64
 
51
65
  export async function prepareDatabaseRuntime(config, options = {}) {
52
- const db = config.testkit.database;
66
+ const db = materializeDatabaseConfig(
67
+ config.testkit.database,
68
+ config.name,
69
+ options.databaseMaterialization || {}
70
+ );
53
71
  if (!db) return;
72
+ const runtimeConfig = db === config.testkit.database
73
+ ? config
74
+ : {
75
+ ...config,
76
+ testkit: {
77
+ ...config.testkit,
78
+ database: db,
79
+ },
80
+ };
54
81
 
55
- fs.mkdirSync(config.stateDir, { recursive: true });
82
+ fs.mkdirSync(runtimeConfig.stateDir, { recursive: true });
56
83
  if (db.provider === "local") {
57
- await prepareLocalDatabase(config, options);
84
+ await prepareLocalDatabase(runtimeConfig, options);
58
85
  return;
59
86
  }
60
87
  if (db.provider === "resource") {
61
- await prepareResourceDatabase(config, options);
88
+ await prepareResourceDatabase(runtimeConfig, options);
62
89
  return;
63
90
  }
64
91
 
@@ -116,15 +143,45 @@ export async function destroyServiceDatabaseCache(productDir, serviceName) {
116
143
 
117
144
  export async function cleanupOrphanedLocalInfrastructure(productDir) {
118
145
  const infraDir = getLocalInfraDir(productDir);
119
- if (!fs.existsSync(infraDir)) return;
120
146
 
121
147
  if (hasRemainingLocalArtifacts(productDir)) return;
122
148
 
123
- const containerName = readStateValue(path.join(infraDir, "container_name"));
124
- if (containerName) {
125
- await stopAndRemoveContainer(containerName);
149
+ if (fs.existsSync(infraDir)) {
150
+ const containerName = readStateValue(path.join(infraDir, "container_name"));
151
+ if (containerName) {
152
+ await stopAndRemoveContainer(containerName);
153
+ }
154
+ fs.rmSync(infraDir, { recursive: true, force: true });
126
155
  }
127
- fs.rmSync(infraDir, { recursive: true, force: true });
156
+
157
+ await cleanupOwnedLocalDatabaseResources(productDir);
158
+ }
159
+
160
+ export async function destroyOwnedLocalDatabaseResources(productDir, options = {}) {
161
+ return cleanupOwnedLocalDatabaseResources(productDir, {
162
+ ...options,
163
+ force: true,
164
+ });
165
+ }
166
+
167
+ export async function cleanupOwnedLocalDatabaseResources(productDir, options = {}) {
168
+ return cleanupLocalPostgresDockerResources({
169
+ productDir,
170
+ dryRun: Boolean(options.dryRun),
171
+ force: Boolean(options.force),
172
+ global: false,
173
+ includeLegacy: Boolean(options.includeLegacy),
174
+ });
175
+ }
176
+
177
+ export async function cleanupGlobalLocalDatabaseResources(options = {}) {
178
+ return cleanupLocalPostgresDockerResources({
179
+ productDir: options.productDir || process.cwd(),
180
+ dryRun: Boolean(options.dryRun),
181
+ force: false,
182
+ global: true,
183
+ includeLegacy: Boolean(options.includeLegacy),
184
+ });
128
185
  }
129
186
 
130
187
  export function isDatabaseStateDir(dir) {
@@ -458,11 +515,13 @@ async function ensureLocalContainer(productDir, database = {}) {
458
515
  }
459
516
 
460
517
  if (!inspect) {
518
+ const labels = buildLocalPostgresContainerLabels(productDir, containerName);
461
519
  await execa("docker", [
462
520
  "run",
463
521
  "-d",
464
522
  "--name",
465
523
  containerName,
524
+ ...dockerLabelArgs(labels),
466
525
  "-e",
467
526
  `POSTGRES_USER=${user}`,
468
527
  "-e",
@@ -851,6 +910,117 @@ function parsePostgresConnectionUrl(rawUrl) {
851
910
  };
852
911
  }
853
912
 
913
+ async function cleanupLocalPostgresDockerResources(options = {}) {
914
+ const product = buildProductIdentity(options.productDir || process.cwd());
915
+ const managed = await listManagedDockerContainers();
916
+ const targets = [];
917
+ const kept = [];
918
+
919
+ for (const container of managed) {
920
+ if (!isManagedLocalPostgresContainer(container)) continue;
921
+ const classification = classifyManagedLocalPostgresContainer(container, product, options);
922
+ if (classification.remove) {
923
+ targets.push({ ...container, reason: classification.reason, legacy: false });
924
+ } else if (classification.reason) {
925
+ kept.push({ ...container, reason: classification.reason, legacy: false });
926
+ }
927
+ }
928
+
929
+ if (options.includeLegacy) {
930
+ const currentLegacyName = buildContainerName(product.dir);
931
+ for (const container of await listLegacyTestkitPostgresContainers()) {
932
+ if (!options.global && container.name !== currentLegacyName) continue;
933
+ targets.push({ ...container, reason: "legacy-unlabelled", legacy: true });
934
+ }
935
+ }
936
+
937
+ if (!options.dryRun) {
938
+ for (const container of targets) {
939
+ await removeDockerContainer(container.name || container.id);
940
+ }
941
+ }
942
+
943
+ return {
944
+ removed: options.dryRun ? [] : targets,
945
+ targets,
946
+ kept,
947
+ };
948
+ }
949
+
950
+ function classifyManagedLocalPostgresContainer(container, product, options) {
951
+ const labels = container.labels || {};
952
+ const containerProductId = labels[TESTKIT_PRODUCT_ID_LABEL] || "";
953
+ const containerProductDir = labels[TESTKIT_PRODUCT_DIR_LABEL] || "";
954
+ const currentProduct = containerProductId === product.id;
955
+
956
+ if (options.force && currentProduct) {
957
+ return { remove: true, reason: "destroy-current-product" };
958
+ }
959
+
960
+ if (!options.global && !currentProduct) {
961
+ return { remove: false, reason: "different-product" };
962
+ }
963
+
964
+ if (currentProduct) {
965
+ if (!hasRemainingLocalArtifacts(product.dir)) {
966
+ return { remove: true, reason: "current-product-no-local-artifacts" };
967
+ }
968
+ if (!localArtifactsReferenceContainer(product.dir, container.name)) {
969
+ return { remove: true, reason: "current-product-unreferenced" };
970
+ }
971
+ return { remove: false, reason: "current-product-referenced" };
972
+ }
973
+
974
+ if (options.global && containerProductDir && !fs.existsSync(containerProductDir)) {
975
+ return { remove: true, reason: "product-dir-missing" };
976
+ }
977
+
978
+ if (options.global && containerProductDir && !hasRemainingLocalArtifacts(containerProductDir)) {
979
+ return { remove: true, reason: "product-has-no-local-artifacts" };
980
+ }
981
+
982
+ return { remove: false, reason: options.global ? "other-product-retained" : "different-product" };
983
+ }
984
+
985
+ function isManagedLocalPostgresContainer(container) {
986
+ const labels = container.labels || {};
987
+ return (
988
+ labels[TESTKIT_RESOURCE_KIND_LABEL] === "postgres-container" &&
989
+ labels[TESTKIT_SCOPE_LABEL] === "local-postgres"
990
+ );
991
+ }
992
+
993
+ function buildLocalPostgresContainerLabels(productDir, containerName) {
994
+ return buildDockerResourceLabels(productDir, {
995
+ kind: "postgres-container",
996
+ name: containerName,
997
+ scope: "local-postgres",
998
+ cachePolicy: "product-cache",
999
+ });
1000
+ }
1001
+
1002
+ function localArtifactsReferenceContainer(productDir, containerName) {
1003
+ if (!containerName) return false;
1004
+ const root = path.join(productDir, ".testkit");
1005
+ let referenced = false;
1006
+ visitDirs(root, (dir) => {
1007
+ if (referenced) return;
1008
+ for (const fileName of ["container_name", "local_container_name"]) {
1009
+ if (readStateValue(path.join(dir, fileName)) === containerName) {
1010
+ referenced = true;
1011
+ return;
1012
+ }
1013
+ }
1014
+ });
1015
+ return referenced;
1016
+ }
1017
+
1018
+ export function formatDatabaseResourceCleanupLine(entry, dryRun = false) {
1019
+ const action = dryRun ? "Would remove" : "Removed";
1020
+ const legacy = entry.legacy ? " legacy" : "";
1021
+ return `${action}${legacy} database resource ${dockerContainerSummary(entry)} reason=${entry.reason}`;
1022
+ }
1023
+
854
1024
  function buildContainerName(productDir) {
855
1025
  return buildContainerNameModel(productDir);
856
1026
  }
@@ -864,9 +1034,11 @@ function buildRuntimeDatabaseName(serviceName, bindingKey, fingerprint) {
864
1034
  }
865
1035
 
866
1036
  function writeLocalInfraState(infraDir, infra) {
1037
+ const product = buildProductIdentity(path.resolve(infraDir, "..", "..", ".."));
867
1038
  fs.writeFileSync(path.join(infraDir, "database_backend"), "local");
868
1039
  fs.writeFileSync(path.join(infraDir, "container_name"), infra.containerName);
869
1040
  fs.writeFileSync(path.join(infraDir, "container_id"), infra.containerId);
1041
+ fs.writeFileSync(path.join(infraDir, "ownership_product_id"), product.id);
870
1042
  fs.writeFileSync(path.join(infraDir, "image"), infra.image);
871
1043
  fs.writeFileSync(path.join(infraDir, "user"), infra.user);
872
1044
  fs.writeFileSync(path.join(infraDir, "password"), infra.password);
@@ -250,7 +250,7 @@ async function attachExistingVMs(client, network, vmRefs) {
250
250
  }
251
251
 
252
252
  async function ensureResources(client, environment, network) {
253
- const configured = environment.resources || {};
253
+ const configured = flattenEnvironmentResources(environment.resources || {});
254
254
  const manifest = {};
255
255
  const connections = {};
256
256
  for (const [name, resource] of Object.entries(configured)) {
@@ -282,6 +282,13 @@ async function ensureResources(client, environment, network) {
282
282
  return { manifest, connections };
283
283
  }
284
284
 
285
+ export function flattenEnvironmentResources(resources = {}) {
286
+ return {
287
+ ...(resources.databases || {}),
288
+ ...(resources.servers || {}),
289
+ };
290
+ }
291
+
285
292
  export function buildPostgresApplianceRequest(name, resource, environment, network) {
286
293
  const extensions = normalizePostgresExtensions(resource.extensions);
287
294
  const image = resolvePostgresResourceImage(resource, name);
@@ -1,6 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { spawn } from "child_process";
4
+ import { buildEnvironmentDatabaseMaterialization } from "../config/database-materialization.mjs";
4
5
  import { loadConfigContext } from "../config/index.mjs";
5
6
  import { prepareDatabaseRuntime } from "../database/index.mjs";
6
7
  import { prepareRuntimeServices } from "../runner/runtime-preparation.mjs";
@@ -186,6 +187,7 @@ export function buildLocalRuntimeConfigs(allConfigs, environment, runtimeDir) {
186
187
  graphDirName: runtimeConfigs.map((config) => config.name).sort().join("__"),
187
188
  portOffset: environment.portOffset || 0,
188
189
  publicHost: environment.publicHost || null,
190
+ databaseMaterialization: buildEnvironmentDatabaseMaterialization(environment),
189
191
  }).map((config) => applyLocalEnvironmentConfig(config, environment));
190
192
  }
191
193
 
@@ -0,0 +1,135 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { execa } from "execa";
5
+
6
+ export const TESTKIT_MANAGED_LABEL = "com.elench.testkit.managed";
7
+ export const TESTKIT_RESOURCE_KIND_LABEL = "com.elench.testkit.resource.kind";
8
+ export const TESTKIT_RESOURCE_NAME_LABEL = "com.elench.testkit.resource.name";
9
+ export const TESTKIT_PRODUCT_DIR_LABEL = "com.elench.testkit.product.dir";
10
+ export const TESTKIT_PRODUCT_ID_LABEL = "com.elench.testkit.product.id";
11
+ export const TESTKIT_SCOPE_LABEL = "com.elench.testkit.scope";
12
+ export const TESTKIT_CACHE_POLICY_LABEL = "com.elench.testkit.cache.policy";
13
+ export const TESTKIT_CREATED_AT_LABEL = "com.elench.testkit.created-at";
14
+
15
+ const TESTKIT_POSTGRES_CONTAINER_PREFIX = "testkit_pg_";
16
+
17
+ export function buildProductIdentity(productDir) {
18
+ const canonicalDir = canonicalizeProductDir(productDir);
19
+ return {
20
+ dir: canonicalDir,
21
+ id: hashString(canonicalDir, 24),
22
+ };
23
+ }
24
+
25
+ export function buildDockerResourceLabels(productDir, resource) {
26
+ const product = buildProductIdentity(productDir);
27
+ return {
28
+ [TESTKIT_MANAGED_LABEL]: "true",
29
+ [TESTKIT_RESOURCE_KIND_LABEL]: resource.kind,
30
+ [TESTKIT_RESOURCE_NAME_LABEL]: resource.name,
31
+ [TESTKIT_PRODUCT_DIR_LABEL]: product.dir,
32
+ [TESTKIT_PRODUCT_ID_LABEL]: product.id,
33
+ [TESTKIT_SCOPE_LABEL]: resource.scope || resource.kind,
34
+ [TESTKIT_CACHE_POLICY_LABEL]: resource.cachePolicy || "ephemeral",
35
+ [TESTKIT_CREATED_AT_LABEL]: resource.createdAt || new Date().toISOString(),
36
+ };
37
+ }
38
+
39
+ export function dockerLabelArgs(labels) {
40
+ return Object.entries(labels).flatMap(([key, value]) => ["--label", `${key}=${String(value)}`]);
41
+ }
42
+
43
+ export async function inspectDockerContainer(containerRef) {
44
+ try {
45
+ const { stdout } = await execa("docker", ["inspect", containerRef]);
46
+ const parsed = JSON.parse(stdout);
47
+ return parsed[0] || null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export async function listManagedDockerContainers() {
54
+ return listDockerContainers([
55
+ "--filter",
56
+ `label=${TESTKIT_MANAGED_LABEL}=true`,
57
+ ]);
58
+ }
59
+
60
+ export async function listLegacyTestkitPostgresContainers() {
61
+ const containers = await listDockerContainers([]);
62
+ return containers.filter((container) => {
63
+ if (!container.name.startsWith(TESTKIT_POSTGRES_CONTAINER_PREFIX)) return false;
64
+ return container.labels[TESTKIT_MANAGED_LABEL] !== "true";
65
+ });
66
+ }
67
+
68
+ export async function removeDockerContainer(containerRef) {
69
+ try {
70
+ await execa("docker", ["rm", "-f", containerRef]);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ export function dockerContainerSummary(container) {
78
+ const status = container.running ? "running" : "stopped";
79
+ return `${container.name} (${status})`;
80
+ }
81
+
82
+ async function listDockerContainers(filters) {
83
+ let stdout = "";
84
+ try {
85
+ const result = await execa("docker", [
86
+ "ps",
87
+ "-a",
88
+ "--format",
89
+ "{{.ID}}",
90
+ ...filters,
91
+ ]);
92
+ stdout = result.stdout;
93
+ } catch {
94
+ return [];
95
+ }
96
+
97
+ const ids = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
98
+ if (ids.length === 0) return [];
99
+
100
+ const containers = [];
101
+ for (const id of ids) {
102
+ const inspect = await inspectDockerContainer(id);
103
+ if (inspect) containers.push(inspect);
104
+ }
105
+ return containers.map(normalizeInspectContainer).filter(Boolean);
106
+ }
107
+
108
+ function normalizeInspectContainer(inspect) {
109
+ if (!inspect?.Id) return null;
110
+ const name = String(inspect.Name || "").replace(/^\//, "");
111
+ const labels = inspect.Config?.Labels || {};
112
+ return {
113
+ id: inspect.Id,
114
+ shortId: inspect.Id.slice(0, 12),
115
+ name,
116
+ image: inspect.Config?.Image || "",
117
+ createdAt: inspect.Created || null,
118
+ running: Boolean(inspect.State?.Running),
119
+ status: inspect.State?.Status || "unknown",
120
+ labels,
121
+ };
122
+ }
123
+
124
+ function canonicalizeProductDir(productDir) {
125
+ const resolved = path.resolve(productDir || process.cwd());
126
+ try {
127
+ return fs.realpathSync.native(resolved);
128
+ } catch {
129
+ return resolved;
130
+ }
131
+ }
132
+
133
+ function hashString(value, length = 24) {
134
+ return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, length);
135
+ }
@@ -1,9 +1,12 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import {
4
+ cleanupGlobalLocalDatabaseResources,
5
+ cleanupOwnedLocalDatabaseResources,
4
6
  cleanupOrphanedLocalInfrastructure,
5
7
  destroyRuntimeDatabase,
6
8
  destroyServiceDatabaseCache,
9
+ formatDatabaseResourceCleanupLine,
7
10
  isDatabaseStateDir,
8
11
  } from "../database/index.mjs";
9
12
  import { cleanupRuns, formatRunSummary, isPidRunning, listRunManifests } from "./lifecycle.mjs";
@@ -51,6 +54,7 @@ export async function cleanup(productDir, options = {}) {
51
54
  const allConfigs = options.allConfigs || [];
52
55
  const serviceName = options.serviceName || null;
53
56
  const cache = normalizeCacheSelection(options.cache);
57
+ const cleanResources = Boolean(options.resources || options.globalResources || options.includeLegacyResources);
54
58
  const summary = dryRun
55
59
  ? collectRunCleanupPreview(productDir)
56
60
  : await cleanupRuns(productDir, { includeActive: false });
@@ -66,6 +70,7 @@ export async function cleanup(productDir, options = {}) {
66
70
  const runtimeCleaned = [];
67
71
  const bundleCleaned = [];
68
72
  const assistantCleaned = [];
73
+ let resourceCleanup = { targets: [], removed: [], kept: [] };
69
74
 
70
75
  if (!dryRun) {
71
76
  for (const target of targets.runtime) {
@@ -83,6 +88,19 @@ export async function cleanup(productDir, options = {}) {
83
88
  pruneKnownEmptyDirs(productDir);
84
89
  }
85
90
 
91
+ if (cleanResources) {
92
+ resourceCleanup = options.globalResources
93
+ ? await cleanupGlobalLocalDatabaseResources({
94
+ productDir,
95
+ dryRun,
96
+ includeLegacy: Boolean(options.includeLegacyResources),
97
+ })
98
+ : await cleanupOwnedLocalDatabaseResources(productDir, {
99
+ dryRun,
100
+ includeLegacy: Boolean(options.includeLegacyResources),
101
+ });
102
+ }
103
+
86
104
  const lines = [];
87
105
  for (const manifest of summary.cleaned) {
88
106
  lines.push(`${dryRun ? "Would clean" : "Cleaned"} stale run ${formatRunSummary(manifest)}`);
@@ -108,6 +126,9 @@ export async function cleanup(productDir, options = {}) {
108
126
  label: "assistant session",
109
127
  dryRun,
110
128
  });
129
+ for (const target of resourceCleanup.targets) {
130
+ lines.push(formatDatabaseResourceCleanupLine(target, dryRun));
131
+ }
111
132
 
112
133
  if (lines.length === 0) {
113
134
  return {
@@ -118,6 +139,7 @@ export async function cleanup(productDir, options = {}) {
118
139
  bundleCleaned,
119
140
  assistantCleaned,
120
141
  localCleaned,
142
+ resourceCleanup,
121
143
  lines: ["No stale runs to clean."],
122
144
  };
123
145
  }
@@ -130,6 +152,7 @@ export async function cleanup(productDir, options = {}) {
130
152
  bundleCleaned,
131
153
  assistantCleaned,
132
154
  localCleaned,
155
+ resourceCleanup,
133
156
  lines,
134
157
  };
135
158
  }
@@ -8,7 +8,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
8
8
  if (plan.skipped) {
9
9
  trackers.set(plan.config.name, {
10
10
  name: plan.config.name,
11
- dbBackend: plan.config.testkit.database?.selectedBackend || null,
11
+ dbBackend: inferDatabaseBackend(plan.config),
12
12
  skipped: true,
13
13
  suiteCount: 0,
14
14
  suites: [],
@@ -73,7 +73,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
73
73
 
74
74
  trackers.set(plan.config.name, {
75
75
  name: plan.config.name,
76
- dbBackend: plan.config.testkit.database?.selectedBackend || null,
76
+ dbBackend: inferDatabaseBackend(plan.config),
77
77
  skipped: false,
78
78
  suiteCount: suites.length,
79
79
  suites,
@@ -90,6 +90,12 @@ export function buildServiceTrackers(servicePlans, startedAt) {
90
90
  return trackers;
91
91
  }
92
92
 
93
+ function inferDatabaseBackend(config) {
94
+ const database = config.testkit.database;
95
+ if (!database) return null;
96
+ return database.selectedBackend || database.provider || "local";
97
+ }
98
+
93
99
  export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now()) {
94
100
  const tracker = trackers.get(task.serviceName);
95
101
  if (!tracker || tracker.skipped) return;
@@ -3,6 +3,7 @@ import {
3
3
  finalizeConfiguredInputs,
4
4
  finalizeConfiguredSteps,
5
5
  } from "../shared/configured-steps.mjs";
6
+ import { materializeDatabaseConfig } from "../config/database-materialization.mjs";
6
7
  import { readDatabaseInfo } from "./state-io.mjs";
7
8
 
8
9
  const PORT_STRIDE = 100;
@@ -75,7 +76,8 @@ export function resolveRuntimeInstanceConfigs(runtimeConfigs, runtimeId, runtime
75
76
  publicBaseUrlByService,
76
77
  options.publicHost || null,
77
78
  stateDirByService,
78
- urlMappings
79
+ urlMappings,
80
+ options.databaseMaterialization || {}
79
81
  )
80
82
  );
81
83
  }
@@ -123,7 +125,8 @@ export function resolveRuntimeConfig(
123
125
  publicBaseUrlByService,
124
126
  publicHost,
125
127
  stateDirByService,
126
- urlMappings
128
+ urlMappings,
129
+ databaseMaterialization = {}
127
130
  ) {
128
131
  const stateDir = resolveServiceStateDir(runtimeDir, config);
129
132
  const prepareDir = resolveServicePrepareDir(runtimeDir, config);
@@ -147,11 +150,15 @@ export function resolveRuntimeConfig(
147
150
  };
148
151
 
149
152
  const database = config.testkit.database
150
- ? {
151
- ...config.testkit.database,
152
- sourceSchema: finalizeSourceSchema(config.testkit.database.sourceSchema, context),
153
- template: finalizeDatabaseTemplate(config.testkit.database.template, context),
154
- }
153
+ ? materializeDatabaseConfig(
154
+ {
155
+ ...config.testkit.database,
156
+ sourceSchema: finalizeSourceSchema(config.testkit.database.sourceSchema, context),
157
+ template: finalizeDatabaseTemplate(config.testkit.database.template, context),
158
+ },
159
+ config.name,
160
+ databaseMaterialization
161
+ )
155
162
  : undefined;
156
163
 
157
164
  const runtime = {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/next-analysis",
3
- "version": "0.1.143",
3
+ "version": "0.1.145",
4
4
  "description": "SWC-backed Next.js source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-bridge",
3
- "version": "0.1.143",
3
+ "version": "0.1.145",
4
4
  "description": "Browser bridge helpers for testkit",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc -p tsconfig.json --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@elench/testkit-protocol": "0.1.143"
25
+ "@elench/testkit-protocol": "0.1.145"
26
26
  },
27
27
  "private": false
28
28
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.143",
3
+ "version": "0.1.145",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/ts-analysis",
3
- "version": "0.1.143",
3
+ "version": "0.1.145",
4
4
  "description": "TypeScript compiler-backed source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.143",
3
+ "version": "0.1.145",
4
4
  "description": "Assistant-first CLI for running, inspecting, and debugging local testkit suites",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -98,10 +98,10 @@
98
98
  },
99
99
  "dependencies": {
100
100
  "@babel/code-frame": "^7.29.0",
101
- "@elench/next-analysis": "0.1.143",
102
- "@elench/testkit-bridge": "0.1.143",
103
- "@elench/testkit-protocol": "0.1.143",
104
- "@elench/ts-analysis": "0.1.143",
101
+ "@elench/next-analysis": "0.1.145",
102
+ "@elench/testkit-bridge": "0.1.145",
103
+ "@elench/testkit-protocol": "0.1.145",
104
+ "@elench/ts-analysis": "0.1.145",
105
105
  "@oclif/core": "^4.10.6",
106
106
  "@playwright/test": "^1.52.0",
107
107
  "esbuild": "^0.25.11",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.142",
3
+ "version": "0.1.144",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",