@elench/testkit 0.1.116 → 0.1.118

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 (32) hide show
  1. package/README.md +31 -7
  2. package/lib/cli/assistant/context-pack.mjs +31 -11
  3. package/lib/cli/operations/db/schema/refresh/operation.mjs +6 -2
  4. package/lib/cli/renderers/db-schema/text.mjs +11 -1
  5. package/lib/config/database.mjs +9 -13
  6. package/lib/config-api/index.d.ts +1 -2
  7. package/lib/config-api/index.mjs +5 -0
  8. package/lib/database/fingerprint.mjs +2 -2
  9. package/lib/database/index.mjs +4 -4
  10. package/lib/database/schema-source.mjs +174 -27
  11. package/lib/database/source-refresh-lock.mjs +69 -0
  12. package/lib/database/source-url.mjs +110 -0
  13. package/lib/database/template-steps.mjs +16 -8
  14. package/lib/repo/state.mjs +164 -0
  15. package/lib/runner/metadata.mjs +11 -24
  16. package/lib/runner/template.mjs +0 -3
  17. package/node_modules/@elench/next-analysis/package.json +1 -1
  18. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  19. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  20. package/node_modules/@elench/ts-analysis/package.json +1 -1
  21. package/package.json +6 -5
  22. package/node_modules/es-toolkit/CHANGELOG.md +0 -801
  23. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +0 -1
  24. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +0 -3
  25. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +0 -4
  26. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +0 -4
  27. package/node_modules/esprima/ChangeLog +0 -235
  28. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
  29. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
  30. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
  31. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
  32. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +0 -25
package/README.md CHANGED
@@ -325,13 +325,24 @@ stable build-and-start flows.
325
325
 
326
326
  `database.template` is the database-side equivalent for reusable template DB
327
327
  state. When `database.sourceSchema` is configured, Testkit treats the configured
328
- source database as the schema source of truth. A normal `testkit run` refreshes
329
- `.testkit/db/<service>/source-schema.sql` from the source database when needed,
330
- applies that cached schema to the local template DB, runs local template setup,
331
- and verifies that the replayed local schema still matches the source dump. If
332
- local replay differs, Testkit refreshes from the source once and retries. If it
333
- still differs, the run fails with schema diagnostics under
334
- `.testkit/results/schema`.
328
+ source database as the schema source of truth. A normal `testkit run` resolves a
329
+ commit-aware source schema cache under
330
+ `.testkit/db/<service>/source-schemas/`, applies that cached schema to the local
331
+ template DB, runs local template setup, and verifies that the replayed local
332
+ schema still matches the source dump. If local replay differs, Testkit refreshes
333
+ from the source once for the current cache key and retries. If it still differs,
334
+ the run fails with schema diagnostics under `.testkit/results/schema`.
335
+
336
+ Source schema cache keys are derived automatically from repo state:
337
+
338
+ - clean git worktrees use `commits/<sha>`
339
+ - dirty git worktrees use `dirty/<sha>-<fingerprint>`
340
+ - non-git directories use `nogit/<fingerprint>`
341
+
342
+ Branch names and worktree paths are recorded as metadata but do not affect clean
343
+ commit cache keys, so branch renames and clean worktrees at the same commit
344
+ reuse the same source schema. Dirty worktrees are isolated by content
345
+ fingerprint so local experiments cannot overwrite a clean commit baseline.
335
346
 
336
347
  Template setup executes in three explicit phases:
337
348
 
@@ -346,6 +357,16 @@ is never written into the baseline. Keep schema-changing setup in its own step
346
357
  where possible; a single command that changes schema and then fails before
347
358
  exiting cannot be refreshed at the midpoint.
348
359
 
360
+ Source schema refreshes are intentionally single-connection and pooler-safe.
361
+ If a Neon pooled source URL is configured, Testkit rewrites it to the matching
362
+ direct Neon endpoint before running `pg_dump` and records the original/resolved
363
+ host classifications beside the resolved cache entry. Unknown PgBouncer/pooler
364
+ URLs fail closed; configure a direct source URL for those providers. Concurrent
365
+ refreshes for the same service and cache key are serialized with a cache-local
366
+ lock so multiple Testkit processes do not stampede the source database. Testkit
367
+ also maintains `.testkit/db/<service>/source-schemas/index.json` and prunes old
368
+ inactive cache entries automatically.
369
+
349
370
  For most repos, prefer declarative step objects directly inside
350
371
  `database.postgres({ template: ... })` and `runtime.prepare.steps`.
351
372
  The supported shapes are:
@@ -686,6 +707,8 @@ services that define `database: database.postgres(...)`.
686
707
  - runtime databases are cloned from templates when binding is `per-runtime`
687
708
  - shared databases are reused when binding is `shared`
688
709
  - source schema caches are refreshed only from the configured source database
710
+ - clean commits, dirty worktrees, and non-git directories get separate source
711
+ schema cache entries automatically
689
712
  - template fingerprints are derived automatically from env files, source schema
690
713
  cache, migrate/seed config, and repo contents
691
714
 
@@ -702,6 +725,7 @@ npm test
702
725
  npm run test:unit
703
726
  npm run test:integration
704
727
  npm run test:system
728
+ npm run test:live:github
705
729
  npm run test:live:neon
706
730
  npm run test:database-version:compat
707
731
  ```
@@ -1,6 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { fileURLToPath, pathToFileURL } from "url";
3
+ import { fileURLToPath } from "url";
4
4
  import { readContextContent, buildContextSelection } from "../../results/context.mjs";
5
5
  import { assistantSessionPaths, createAssistantSessionId } from "./session-paths.mjs";
6
6
  import {
@@ -88,7 +88,6 @@ export function prepareAssistantContextPack({
88
88
  );
89
89
  if (!fs.existsSync(wrapperPath)) fs.writeFileSync(wrapperPath, buildWrapperScript({
90
90
  cliPath: resolveCliPath(),
91
- classifierUrl: resolveClassifierUrl(),
92
91
  sessionId,
93
92
  resultDir,
94
93
  commandLogPath,
@@ -165,16 +164,11 @@ function resolveCliPath() {
165
164
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "bin", "testkit.mjs");
166
165
  }
167
166
 
168
- function resolveClassifierUrl() {
169
- return pathToFileURL(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "command-classifier.mjs")).href;
170
- }
171
-
172
- function buildWrapperScript({ cliPath, classifierUrl, sessionId, resultDir, commandLogPath } = {}) {
167
+ function buildWrapperScript({ cliPath, sessionId, resultDir, commandLogPath } = {}) {
173
168
  return `#!/usr/bin/env node
174
- import { spawnSync } from "child_process";
175
- import fs from "fs";
176
- import path from "path";
177
- import { classifyAssistantCommandKind } from ${JSON.stringify(classifierUrl)};
169
+ const { spawnSync } = require("child_process");
170
+ const fs = require("fs");
171
+ const path = require("path");
178
172
 
179
173
  const commandId = process.env.${ASSISTANT_COMMAND_ID_ENV} || \`cmd-\${Date.now()}-\${Math.random().toString(36).slice(2, 10)}\`;
180
174
  const commandLogPath = process.env.${ASSISTANT_COMMAND_LOG_ENV} || ${JSON.stringify(commandLogPath)};
@@ -231,6 +225,32 @@ function appendCommandLog(event) {
231
225
  // Command observation must not affect command execution.
232
226
  }
233
227
  }
228
+
229
+ function classifyAssistantCommandKind(argv = []) {
230
+ const runShortcuts = new Set(["ui", "e2e", "scenario", "int", "dal", "load", "all"]);
231
+ const valueFlags = new Set([
232
+ "--dir",
233
+ "--service",
234
+ "--type",
235
+ "--suite",
236
+ "--file",
237
+ "--workers",
238
+ "--file-timeout-seconds",
239
+ "--seed",
240
+ "--output-mode",
241
+ ]);
242
+ for (let index = 0; index < argv.length; index += 1) {
243
+ const value = String(argv[index] || "");
244
+ if (valueFlags.has(value)) {
245
+ index += 1;
246
+ continue;
247
+ }
248
+ if (!value.startsWith("-")) {
249
+ return runShortcuts.has(value) ? "run" : value;
250
+ }
251
+ }
252
+ return "run";
253
+ }
234
254
  `;
235
255
  }
236
256
 
@@ -4,7 +4,7 @@ import path from "path";
4
4
  import { loadManagedConfigs, resolveTargetConfig, collectRequiredConfigs, topologicallySortConfigs } from "../../../../../app/configs.mjs";
5
5
  import { resolveProductDir } from "../../../../../config/index.mjs";
6
6
  import { prepareDatabaseRuntime } from "../../../../../database/index.mjs";
7
- import { forceRefreshSourceSchemaCache, getSourceSchemaCachePath } from "../../../../../database/schema-source.mjs";
7
+ import { forceRefreshSourceSchemaCache } from "../../../../../database/schema-source.mjs";
8
8
  import { createRunReporter } from "../../../../renderers/run/text-reporter.mjs";
9
9
  import { createRunLogRegistry } from "../../../../../runner/logs.mjs";
10
10
  import { createSetupOperationRegistry } from "../../../../../runner/setup-operations.mjs";
@@ -40,14 +40,18 @@ export async function executeDatabaseSchemaRefreshOperation(options = {}) {
40
40
  logRegistry,
41
41
  setupRegistry,
42
42
  });
43
- const outputPath = getSourceSchemaCachePath(resolvedTarget);
43
+ const outputPath = state.cachePath;
44
44
  return {
45
45
  ok: true,
46
46
  productDir,
47
47
  service: target.name,
48
48
  outputPath,
49
49
  outputLabel: path.relative(productDir, outputPath) || path.basename(outputPath),
50
+ cacheKey: state.cacheKey,
51
+ cacheKind: state.cacheKind,
50
52
  envName: state.envName || null,
53
+ sourceUrl: state.refreshInfo?.metadata?.sourceUrl || null,
54
+ reusedExistingRefresh: Boolean(state.refreshInfo?.reusedExistingRefresh),
51
55
  };
52
56
  } finally {
53
57
  logRegistry.closeAll();
@@ -1,5 +1,15 @@
1
1
  export function renderDatabaseSchemaRefreshResult(result) {
2
- return [`Refreshed ${result.outputLabel}`];
2
+ const lines = [`Refreshed ${result.outputLabel}`];
3
+ if (result.cacheKey) {
4
+ lines.push(`Cache key ${result.cacheKey}`);
5
+ }
6
+ if (result.sourceUrl?.rewritten && result.sourceUrl.originalClassification === "neon-pooler") {
7
+ lines.push("Source schema URL uses Neon pooler; Testkit used the direct endpoint for pg_dump.");
8
+ }
9
+ if (result.reusedExistingRefresh) {
10
+ lines.push("Another Testkit process refreshed this source schema cache first; reused that result.");
11
+ }
12
+ return lines;
3
13
  }
4
14
 
5
15
  export function renderDatabaseSchemaVerifyResult(result) {
@@ -66,8 +66,7 @@ function normalizeSourceSchemaConfig(value, serviceName) {
66
66
  if (value === undefined) {
67
67
  return {
68
68
  kind: "auto",
69
- cachePath: null,
70
- refresh: { mode: "always" },
69
+ refresh: { mode: "auto" },
71
70
  unavailable: "auto",
72
71
  verify: true,
73
72
  };
@@ -79,13 +78,17 @@ function normalizeSourceSchemaConfig(value, serviceName) {
79
78
  if (kind !== "env") {
80
79
  throw new Error(`Service "${serviceName}" database.sourceSchema.kind must be "env"`);
81
80
  }
81
+ if (Object.prototype.hasOwnProperty.call(value, "cachePath")) {
82
+ throw new Error(
83
+ `Service "${serviceName}" database.sourceSchema.cachePath has been removed. Testkit now manages commit-scoped source schema caches automatically.`
84
+ );
85
+ }
82
86
  if (typeof value.env !== "string" || value.env.trim().length === 0) {
83
87
  throw new Error(`Service "${serviceName}" database.sourceSchema.env must be a non-empty string`);
84
88
  }
85
89
  return {
86
90
  kind,
87
91
  env: value.env.trim(),
88
- cachePath: normalizeOptionalString(value.cachePath, `Service "${serviceName}" database.sourceSchema.cachePath`),
89
92
  refresh: normalizeSourceSchemaRefresh(value.refresh, serviceName),
90
93
  unavailable: normalizeSourceSchemaUnavailable(value.unavailable, serviceName),
91
94
  verify: value.verify !== false,
@@ -93,7 +96,8 @@ function normalizeSourceSchemaConfig(value, serviceName) {
93
96
  }
94
97
 
95
98
  function normalizeSourceSchemaRefresh(value, serviceName) {
96
- if (value == null || value === "always") return { mode: "always" };
99
+ if (value == null || value === "auto") return { mode: "auto" };
100
+ if (value === "always") return { mode: "always" };
97
101
  if (typeof value === "object" && !Array.isArray(value)) {
98
102
  const ttlSeconds = value.ttlSeconds;
99
103
  if (!Number.isInteger(ttlSeconds) || ttlSeconds < 0) {
@@ -101,7 +105,7 @@ function normalizeSourceSchemaRefresh(value, serviceName) {
101
105
  }
102
106
  return { mode: "ttl", ttlSeconds };
103
107
  }
104
- throw new Error(`Service "${serviceName}" database.sourceSchema.refresh must be "always" or { ttlSeconds }`);
108
+ throw new Error(`Service "${serviceName}" database.sourceSchema.refresh must be "auto", "always", or { ttlSeconds }`);
105
109
  }
106
110
 
107
111
  function normalizeSourceSchemaUnavailable(value, serviceName) {
@@ -110,11 +114,3 @@ function normalizeSourceSchemaUnavailable(value, serviceName) {
110
114
  }
111
115
  throw new Error(`Service "${serviceName}" database.sourceSchema.unavailable must be "auto", "fail", or "warn-cache"`);
112
116
  }
113
-
114
- function normalizeOptionalString(value, label) {
115
- if (value == null) return null;
116
- if (typeof value !== "string" || value.trim().length === 0) {
117
- throw new Error(`${label} must be a non-empty string`);
118
- }
119
- return value.trim();
120
- }
@@ -15,8 +15,7 @@ export interface DatabaseTemplateOptions {
15
15
  }
16
16
 
17
17
  export interface DatabaseSourceSchemaOptions {
18
- cachePath?: string;
19
- refresh?: "always" | { ttlSeconds: number };
18
+ refresh?: "auto" | "always" | { ttlSeconds: number };
20
19
  unavailable?: "auto" | "fail" | "warn-cache";
21
20
  verify?: boolean;
22
21
  }
@@ -57,6 +57,11 @@ function sourceSchemaFromEnv(envName, options = {}) {
57
57
  if (typeof envName !== "string" || envName.trim().length === 0) {
58
58
  throw new Error("database.schema.fromEnv(...) requires a non-empty env var name");
59
59
  }
60
+ if (Object.prototype.hasOwnProperty.call(options, "cachePath")) {
61
+ throw new Error(
62
+ "database.schema.fromEnv(...) no longer accepts cachePath. Testkit now manages commit-scoped source schema caches automatically."
63
+ );
64
+ }
60
65
  return {
61
66
  kind: "env",
62
67
  env: envName.trim(),
@@ -8,7 +8,7 @@ import { appendSourceSchemaCacheToHash } from "./schema-source.mjs";
8
8
  const LOCAL_IMAGE = "pgvector/pgvector:pg16";
9
9
  const LOCAL_USER = "testkit";
10
10
 
11
- export async function computeTemplateFingerprint(config) {
11
+ export async function computeTemplateFingerprint(config, options = {}) {
12
12
  const hash = crypto.createHash("sha256");
13
13
  const db = config.testkit.database;
14
14
  hash.update(JSON.stringify({
@@ -25,7 +25,7 @@ export async function computeTemplateFingerprint(config) {
25
25
  for (const input of collectTemplateInputs(config.productDir, db.template || {})) {
26
26
  appendResolvedInputToHash(hash, config.productDir, input);
27
27
  }
28
- appendSourceSchemaCacheToHash(hash, config);
28
+ appendSourceSchemaCacheToHash(hash, config, options.sourceSchemaState || null);
29
29
 
30
30
  return hash.digest("hex");
31
31
  }
@@ -147,7 +147,7 @@ async function prepareLocalDatabase(config, options = {}) {
147
147
 
148
148
  await withLock(path.join(lockDir, `template-${serviceName}.lock`), async () => {
149
149
  const sourceSchemaState = await prepareSourceSchemaCache(config, options);
150
- templateFingerprint = await computeTemplateFingerprint(config);
150
+ templateFingerprint = await computeTemplateFingerprint(config, { sourceSchemaState });
151
151
  templateFingerprint = await ensureTemplateDatabase(
152
152
  config,
153
153
  infra,
@@ -209,7 +209,7 @@ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerpri
209
209
  await dropDatabaseIfExists(infra, desiredDbName);
210
210
  sourceSchemaState = await forceRefreshSourceSchemaCache(config, sourceSchemaState, options);
211
211
  refreshedSourceAfterMismatch = true;
212
- activeFingerprint = await computeTemplateFingerprint(config);
212
+ activeFingerprint = await computeTemplateFingerprint(config, { sourceSchemaState });
213
213
  continue;
214
214
  }
215
215
 
@@ -638,8 +638,8 @@ function isUnsupportedForceDropError(error) {
638
638
  );
639
639
  }
640
640
 
641
- async function computeTemplateFingerprint(config) {
642
- return computeTemplateFingerprintModel(config);
641
+ async function computeTemplateFingerprint(config, options = {}) {
642
+ return computeTemplateFingerprintModel(config, options);
643
643
  }
644
644
 
645
645
  function appendInputToHash(hash, productDir, input) {
@@ -2,31 +2,54 @@ import fs from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
4
  import { execa } from "execa";
5
- import { resolveServiceCwd } from "../config/paths.mjs";
5
+ import { collectRepoState, summarizeRepoStateForMetadata } from "../repo/state.mjs";
6
6
  import { buildExecutionEnv } from "../runner/template.mjs";
7
7
  import { dumpPostgresSchemaToFile, captureTemplateSnapshotText, runTemplateStep, sanitizeSnapshotText } from "./template-steps.mjs";
8
+ import { getSourceSchemaRefreshLockPath, withSourceSchemaRefreshLock } from "./source-refresh-lock.mjs";
9
+ import { resolveSourceSchemaDumpUrl } from "./source-url.mjs";
8
10
 
9
11
  const SOURCE_SCHEMA_DIR = path.join(".testkit", "db");
12
+ const SOURCE_SCHEMA_CACHE_DIR = "source-schemas";
10
13
  const SOURCE_SCHEMA_FILE = "source-schema.sql";
11
14
  const SOURCE_SCHEMA_META_FILE = "source-schema.meta.json";
15
+ const SOURCE_SCHEMA_INDEX_FILE = "index.json";
16
+ const SOURCE_SCHEMA_MAX_ENTRIES = 20;
12
17
 
13
- export function getSourceSchemaCachePath(config) {
14
- const configured = config.testkit.database?.sourceSchema?.cachePath || null;
15
- return configured
16
- ? resolveServiceCwd(config.productDir, configured)
17
- : path.join(config.productDir, SOURCE_SCHEMA_DIR, config.name, SOURCE_SCHEMA_FILE);
18
+ export function getSourceSchemaRootDir(config) {
19
+ return path.join(config.productDir, SOURCE_SCHEMA_DIR, config.name, SOURCE_SCHEMA_CACHE_DIR);
18
20
  }
19
21
 
20
- export function getSourceSchemaMetadataPath(config) {
21
- const cachePath = getSourceSchemaCachePath(config);
22
+ export function getSourceSchemaCachePath(config, options = {}) {
23
+ return resolveSourceSchemaCacheLocation(config, options).cachePath;
24
+ }
25
+
26
+ export function getSourceSchemaMetadataPath(config, options = {}) {
27
+ const cachePath = getSourceSchemaCachePath(config, options);
22
28
  return path.join(path.dirname(cachePath), SOURCE_SCHEMA_META_FILE);
23
29
  }
24
30
 
31
+ export function resolveSourceSchemaCacheLocation(config, options = {}) {
32
+ const repoState = options.repoState || collectRepoState(config.productDir);
33
+ const rootDir = getSourceSchemaRootDir(config);
34
+ const cacheDir = path.join(rootDir, ...repoState.cacheKey.split("/").map(sanitizePathSegment));
35
+ return {
36
+ rootDir,
37
+ cacheDir,
38
+ cachePath: path.join(cacheDir, SOURCE_SCHEMA_FILE),
39
+ metadataPath: path.join(cacheDir, SOURCE_SCHEMA_META_FILE),
40
+ indexPath: path.join(rootDir, SOURCE_SCHEMA_INDEX_FILE),
41
+ cacheKey: repoState.cacheKey,
42
+ cacheKind: repoState.kind,
43
+ repoState,
44
+ };
45
+ }
46
+
25
47
  export function resolveSourceSchemaState(config, options = {}) {
26
48
  const sourceSchema = config.testkit.database?.sourceSchema || null;
27
49
  if (!sourceSchema) return { active: false };
28
50
 
29
- const cachePath = getSourceSchemaCachePath(config);
51
+ const location = resolveSourceSchemaCacheLocation(config, options);
52
+ const cachePath = location.cachePath;
30
53
  const cacheExists = fs.existsSync(cachePath);
31
54
  const env = buildExecutionEnv(config, {}, options.env || process.env);
32
55
  const envNames = sourceSchema.kind === "env"
@@ -41,12 +64,19 @@ export function resolveSourceSchemaState(config, options = {}) {
41
64
  return {
42
65
  active: true,
43
66
  cachePath,
44
- metadataPath: getSourceSchemaMetadataPath(config),
67
+ metadataPath: location.metadataPath,
68
+ indexPath: location.indexPath,
69
+ rootDir: location.rootDir,
70
+ cacheDir: location.cacheDir,
71
+ cacheKey: location.cacheKey,
72
+ cacheKind: location.cacheKind,
73
+ repoState: location.repoState,
45
74
  envName,
46
75
  sourceUrl,
47
76
  sourceSchema,
48
77
  cacheExists,
49
78
  refreshed: false,
79
+ ci: env.CI === "true",
50
80
  unavailableMode: resolveUnavailableMode(sourceSchema.unavailable, options.env || process.env),
51
81
  };
52
82
  }
@@ -57,9 +87,12 @@ export async function prepareSourceSchemaCache(config, options = {}) {
57
87
 
58
88
  if (state.sourceUrl) {
59
89
  if (shouldRefreshSourceSchema(state)) {
60
- await refreshSourceSchemaCache(config, state, options);
61
- return { ...state, refreshed: true, cacheExists: true };
90
+ const refreshInfo = await refreshSourceSchemaCache(config, state, options);
91
+ const refreshedState = { ...state, refreshed: true, cacheExists: true, refreshInfo };
92
+ updateSourceSchemaIndex(config, refreshedState, { refreshed: true });
93
+ return refreshedState;
62
94
  }
95
+ updateSourceSchemaIndex(config, state);
63
96
  options.setupRegistry?.recordCached({
64
97
  config,
65
98
  stage: "source-schema",
@@ -70,6 +103,7 @@ export async function prepareSourceSchemaCache(config, options = {}) {
70
103
  }
71
104
 
72
105
  if (state.cacheExists && state.unavailableMode === "warn-cache") {
106
+ updateSourceSchemaIndex(config, state);
73
107
  options.setupRegistry?.recordCached({
74
108
  config,
75
109
  stage: "source-schema",
@@ -93,8 +127,10 @@ export async function forceRefreshSourceSchemaCache(config, previousState, optio
93
127
  const envLabel = previousState?.envName || previousState?.sourceSchema?.env || "source schema env";
94
128
  throw new Error(`Cannot refresh source schema for service "${config.name}" because ${envLabel} is unavailable.`);
95
129
  }
96
- await refreshSourceSchemaCache(config, state, options);
97
- return { ...state, refreshed: true, cacheExists: true };
130
+ const refreshInfo = await refreshSourceSchemaCache(config, state, options);
131
+ const refreshedState = { ...state, refreshed: true, cacheExists: true, refreshInfo };
132
+ updateSourceSchemaIndex(config, refreshedState, { refreshed: true });
133
+ return refreshedState;
98
134
  }
99
135
 
100
136
  export async function applySourceSchemaCache(config, databaseUrl, state, options = {}) {
@@ -156,10 +192,12 @@ export function createSourceSchemaMismatchError(config, state, verification) {
156
192
  return new Error(parts.join("\n"));
157
193
  }
158
194
 
159
- export function appendSourceSchemaCacheToHash(hash, config) {
195
+ export function appendSourceSchemaCacheToHash(hash, config, state = null) {
160
196
  const sourceSchema = config.testkit.database?.sourceSchema || null;
161
197
  if (!sourceSchema) return;
162
- const cachePath = getSourceSchemaCachePath(config);
198
+ const cachePath = state?.cachePath || getSourceSchemaCachePath(config);
199
+ const cacheKey = state?.cacheKey || path.relative(getSourceSchemaRootDir(config), path.dirname(cachePath));
200
+ hash.update(`source-schema-key:${cacheKey}`);
163
201
  hash.update(`source-schema-cache:${path.relative(config.productDir, cachePath)}`);
164
202
  if (!fs.existsSync(cachePath)) {
165
203
  hash.update(":missing");
@@ -171,6 +209,7 @@ export function appendSourceSchemaCacheToHash(hash, config) {
171
209
  }
172
210
 
173
211
  async function refreshSourceSchemaCache(config, state, options = {}) {
212
+ let refreshInfo = null;
174
213
  const operation = options.setupRegistry?.start({
175
214
  config,
176
215
  stage: "source-schema:refresh",
@@ -178,16 +217,31 @@ async function refreshSourceSchemaCache(config, state, options = {}) {
178
217
  summary: `source schema refresh: ${path.relative(config.productDir, state.cachePath)}`,
179
218
  });
180
219
  try {
181
- await writeSourceSchemaCacheFromUrl(config, state.sourceUrl, state.cachePath, {
182
- reporter: options.reporter || null,
183
- logRecord: operation?._logRecord || null,
184
- });
185
- writeSourceSchemaMetadata(state.metadataPath, {
186
- refreshedAt: new Date().toISOString(),
187
- serviceName: config.name,
188
- envName: state.envName,
189
- cachePath: path.relative(config.productDir, state.cachePath),
190
- });
220
+ refreshInfo = await withSourceSchemaRefreshLock(
221
+ getSourceSchemaRefreshLockPath(state.cachePath),
222
+ async ({ waited, requestedAt }) => {
223
+ const cachedAfterWaiting = readRecentSourceSchemaMetadata(state, requestedAt);
224
+ if (cachedAfterWaiting) {
225
+ return {
226
+ reusedExistingRefresh: true,
227
+ waitedForLock: waited,
228
+ metadata: cachedAfterWaiting,
229
+ };
230
+ }
231
+ const result = await writeSourceSchemaCacheFromUrl(config, state.sourceUrl, state.cachePath, {
232
+ reporter: options.reporter || null,
233
+ logRecord: operation?._logRecord || null,
234
+ env: options.env || process.env,
235
+ });
236
+ const metadata = buildSourceSchemaMetadata(config, state, result);
237
+ writeSourceSchemaMetadata(state.metadataPath, metadata);
238
+ return {
239
+ reusedExistingRefresh: false,
240
+ waitedForLock: waited,
241
+ metadata,
242
+ };
243
+ }
244
+ );
191
245
  const finished = operation
192
246
  ? options.setupRegistry.finish(operation, {
193
247
  status: "passed",
@@ -195,6 +249,7 @@ async function refreshSourceSchemaCache(config, state, options = {}) {
195
249
  })
196
250
  : null;
197
251
  if (finished) options.reporter?.setupOperationFinished?.(finished);
252
+ return refreshInfo;
198
253
  } catch (error) {
199
254
  const finished = operation
200
255
  ? options.setupRegistry.finish(operation, {
@@ -209,23 +264,64 @@ async function refreshSourceSchemaCache(config, state, options = {}) {
209
264
  }
210
265
 
211
266
  async function writeSourceSchemaCacheFromUrl(config, sourceUrl, cachePath, options = {}) {
267
+ const resolution = resolveSourceSchemaDumpUrl(sourceUrl);
212
268
  fs.mkdirSync(path.dirname(cachePath), { recursive: true });
213
269
  const tempDir = fs.mkdtempSync(path.join(path.dirname(cachePath), ".source-schema-"));
214
270
  const tempPath = path.join(tempDir, path.basename(cachePath));
215
271
  try {
216
- await dumpPostgresSchemaToFile(config, tempPath, sourceUrl, options);
272
+ await dumpPostgresSchemaToFile(config, tempPath, resolution.dumpUrl, {
273
+ ...options,
274
+ pgApplicationName: sourceSchemaPgApplicationName(config.name),
275
+ });
217
276
  fs.renameSync(tempPath, cachePath);
277
+ return {
278
+ resolution,
279
+ pgDump: {
280
+ args: ["--schema-only", "--no-owner", "--no-privileges"],
281
+ applicationName: sourceSchemaPgApplicationName(config.name),
282
+ },
283
+ };
218
284
  } finally {
219
285
  fs.rmSync(tempDir, { recursive: true, force: true });
220
286
  }
221
287
  }
222
288
 
289
+ function buildSourceSchemaMetadata(config, state, result) {
290
+ return {
291
+ refreshedAt: new Date().toISOString(),
292
+ serviceName: config.name,
293
+ envName: state.envName,
294
+ cacheKey: state.cacheKey,
295
+ cacheKind: state.cacheKind,
296
+ cachePath: path.relative(config.productDir, state.cachePath),
297
+ repo: summarizeRepoStateForMetadata(state.repoState),
298
+ sourceUrl: result.resolution.metadata,
299
+ pgDump: result.pgDump,
300
+ };
301
+ }
302
+
303
+ function readRecentSourceSchemaMetadata(state, requestedAt) {
304
+ if (!fs.existsSync(state.cachePath)) return null;
305
+ const metadata = readJson(state.metadataPath);
306
+ const refreshedAt = metadata?.refreshedAt ? Date.parse(metadata.refreshedAt) : 0;
307
+ if (!Number.isFinite(refreshedAt) || refreshedAt < requestedAt) return null;
308
+ return metadata;
309
+ }
310
+
311
+ function sourceSchemaPgApplicationName(serviceName) {
312
+ const suffix = String(serviceName).replace(/[^a-zA-Z0-9._:-]+/g, "-").slice(0, 24) || "service";
313
+ return `testkit:schema-source:${suffix}`;
314
+ }
315
+
223
316
  function readSourceSchemaCacheText(cachePath) {
224
317
  return sanitizeSnapshotText(fs.readFileSync(cachePath, "utf8"));
225
318
  }
226
319
 
227
320
  function shouldRefreshSourceSchema(state) {
228
321
  if (!state.cacheExists) return true;
322
+ if (state.sourceSchema.refresh?.mode === "auto") {
323
+ return state.ci;
324
+ }
229
325
  if (state.sourceSchema.refresh?.mode === "ttl") {
230
326
  const meta = readJson(state.metadataPath);
231
327
  const refreshedAt = meta?.refreshedAt ? Date.parse(meta.refreshedAt) : 0;
@@ -235,11 +331,62 @@ function shouldRefreshSourceSchema(state) {
235
331
  return true;
236
332
  }
237
333
 
334
+ function updateSourceSchemaIndex(config, state, options = {}) {
335
+ if (!state?.active || !state.indexPath) return;
336
+ const now = new Date().toISOString();
337
+ const index = readJson(state.indexPath) || { version: 1, entries: [] };
338
+ const entries = Array.isArray(index.entries) ? index.entries.filter((entry) => entry?.cacheKey !== state.cacheKey) : [];
339
+ const existingMeta = readJson(state.metadataPath) || {};
340
+ entries.push({
341
+ cacheKey: state.cacheKey,
342
+ kind: state.cacheKind,
343
+ commitSha: state.repoState?.commitSha || null,
344
+ branch: state.repoState?.branch || null,
345
+ detached: Boolean(state.repoState?.detached),
346
+ dirty: Boolean(state.repoState?.dirty),
347
+ dirtyFingerprint: state.repoState?.dirtyFingerprint || null,
348
+ contentFingerprint: state.repoState?.contentFingerprint || null,
349
+ remoteUrl: state.repoState?.remoteUrl || null,
350
+ repoSlug: state.repoState?.repoSlug || null,
351
+ cachePath: path.relative(config.productDir, state.cachePath),
352
+ refreshedAt: options.refreshed ? now : existingMeta.refreshedAt || null,
353
+ lastUsedAt: now,
354
+ });
355
+ const prunedEntries = pruneSourceSchemaEntries(config, state, entries);
356
+ writeJson(state.indexPath, { version: 1, entries: prunedEntries });
357
+ }
358
+
359
+ function pruneSourceSchemaEntries(config, currentState, entries) {
360
+ if (entries.length <= SOURCE_SCHEMA_MAX_ENTRIES) return entries;
361
+ const sorted = [...entries].sort((a, b) => Date.parse(b.lastUsedAt || 0) - Date.parse(a.lastUsedAt || 0));
362
+ const keep = new Set(sorted.slice(0, SOURCE_SCHEMA_MAX_ENTRIES).map((entry) => entry.cacheKey));
363
+ keep.add(currentState.cacheKey);
364
+ for (const entry of entries) {
365
+ if (keep.has(entry.cacheKey)) continue;
366
+ const cachePath = entry.cachePath ? path.join(config.productDir, entry.cachePath) : null;
367
+ const resolvedCachePath = cachePath ? path.resolve(cachePath) : null;
368
+ const resolvedRoot = path.resolve(currentState.rootDir);
369
+ if (
370
+ !resolvedCachePath ||
371
+ (resolvedCachePath !== resolvedRoot && !resolvedCachePath.startsWith(`${resolvedRoot}${path.sep}`))
372
+ ) {
373
+ continue;
374
+ }
375
+ fs.rmSync(path.dirname(cachePath), { recursive: true, force: true });
376
+ }
377
+ return entries.filter((entry) => keep.has(entry.cacheKey));
378
+ }
379
+
238
380
  function writeSourceSchemaMetadata(metadataPath, metadata) {
239
381
  fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
240
382
  fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
241
383
  }
242
384
 
385
+ function writeJson(filePath, value) {
386
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
387
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
388
+ }
389
+
243
390
  function readJson(filePath) {
244
391
  if (!filePath || !fs.existsSync(filePath)) return null;
245
392
  try {