@elench/testkit 0.1.117 → 0.1.119

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 (37) hide show
  1. package/README.md +27 -12
  2. package/lib/app/doctor.mjs +11 -113
  3. package/lib/cli/assistant/command-observer.mjs +1 -1
  4. package/lib/cli/assistant/context-pack.mjs +31 -11
  5. package/lib/cli/assistant/state.mjs +2 -0
  6. package/lib/cli/commands/lint.mjs +37 -0
  7. package/lib/cli/entrypoint.mjs +1 -0
  8. package/lib/cli/operations/db/schema/refresh/operation.mjs +4 -2
  9. package/lib/cli/operations/lint/operation.mjs +12 -0
  10. package/lib/cli/renderers/db-schema/text.mjs +3 -0
  11. package/lib/cli/renderers/doctor/text.mjs +5 -0
  12. package/lib/cli/renderers/lint/text.mjs +20 -0
  13. package/lib/config/database.mjs +9 -13
  14. package/lib/config-api/database-steps.mjs +132 -0
  15. package/lib/config-api/index.d.ts +37 -5
  16. package/lib/config-api/index.mjs +123 -12
  17. package/lib/database/fingerprint.mjs +2 -2
  18. package/lib/database/index.mjs +4 -4
  19. package/lib/database/schema-source.mjs +107 -14
  20. package/lib/lint/index.mjs +569 -0
  21. package/lib/repo/state.mjs +164 -0
  22. package/lib/runner/metadata.mjs +11 -24
  23. package/lib/runner/template-steps.mjs +8 -0
  24. package/lib/runner/template.mjs +0 -3
  25. package/lib/runtime/index.d.ts +43 -0
  26. package/lib/runtime/index.mjs +24 -0
  27. package/lib/runtime-src/k6/http-assertions.js +82 -0
  28. package/lib/shared/configured-steps.mjs +16 -0
  29. package/lib/ui/index.d.ts +46 -0
  30. package/lib/ui/index.mjs +11 -0
  31. package/lib/ui/sandbox.mjs +115 -0
  32. package/node_modules/@elench/next-analysis/package.json +1 -1
  33. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  34. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  35. package/node_modules/@elench/ts-analysis/package.json +1 -1
  36. package/package.json +6 -5
  37. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +1 -1
@@ -9,14 +9,13 @@ export interface DatabaseTemplateConfig {
9
9
 
10
10
  export interface DatabaseTemplateOptions {
11
11
  inputs?: string[];
12
- migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[];
13
- seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[];
14
- verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[];
12
+ migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
13
+ seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
14
+ verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
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
  }
@@ -27,6 +26,7 @@ export interface DatabaseSourceSchemaConfig extends DatabaseSourceSchemaOptions
27
26
  }
28
27
 
29
28
  export interface TemplateStepBaseConfig {
29
+ args?: unknown;
30
30
  cwd?: string;
31
31
  inputs?: string[];
32
32
  }
@@ -51,6 +51,8 @@ export type TemplateLifecycleStepConfig =
51
51
  | TemplateSqlFileStepConfig
52
52
  | TemplateModuleStepConfig;
53
53
 
54
+ export interface TemplateLifecycleStepOptions extends TemplateStepBaseConfig {}
55
+
54
56
  export interface TscBuildConfig {
55
57
  kind: "tsc";
56
58
  cwd?: string;
@@ -380,6 +382,8 @@ export interface NodeAppOptions extends Omit<ServiceConfig, "local" | "runtime"
380
382
  }
381
383
 
382
384
  export interface NextAppOptions extends Omit<ServiceConfig, "local" | "runtime" | "env"> {
385
+ api?: string | { baseUrl: string };
386
+ auth?: "disabled-clerk" | { kind: "disabled-clerk" };
383
387
  baseUrl?: string;
384
388
  build?: BuildConfig | null;
385
389
  buildInputs?: string[];
@@ -414,6 +418,14 @@ export interface TestkitConfig {
414
418
  };
415
419
  }
416
420
 
421
+ export interface NodeNextPresetOptions {
422
+ fileTimeoutSeconds?: number;
423
+ install?: "require-host" | "download";
424
+ node?: string;
425
+ npm?: string;
426
+ workers?: number;
427
+ }
428
+
417
429
  export declare function defineConfig<T extends TestkitConfig>(config: T): T;
418
430
  export declare function defineFile<T extends TestkitFileMetadata>(metadata: T): T;
419
431
  export declare const app: {
@@ -424,19 +436,39 @@ export declare const database: {
424
436
  schema: {
425
437
  fromEnv(envName: string, options?: DatabaseSourceSchemaOptions): DatabaseSourceSchemaConfig;
426
438
  };
439
+ steps: {
440
+ materializePostgresBinding(options?: unknown): TemplateModuleStepConfig;
441
+ verifySeed(options?: unknown): TemplateModuleStepConfig;
442
+ };
427
443
  postgres(
428
444
  options?: Omit<LocalDatabaseConfig, "provider" | "template"> & {
445
+ inputs?: string[];
446
+ migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
447
+ seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
429
448
  template?: DatabaseTemplateOptions;
449
+ verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
430
450
  }
431
451
  ): LocalDatabaseConfig;
432
452
  fixture(
433
453
  options?: Omit<LocalDatabaseConfig, "provider" | "template"> & {
454
+ inputs?: string[];
455
+ migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
456
+ seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
434
457
  template?: DatabaseTemplateOptions;
458
+ verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
435
459
  discovery?: DiscoveryConfig;
436
460
  envFiles?: string[];
437
461
  }
438
462
  ): ServiceConfig;
439
463
  };
464
+ export declare const step: {
465
+ command(run: string, options?: TemplateLifecycleStepOptions): TemplateCommandStepConfig;
466
+ module(target: string, options?: TemplateLifecycleStepOptions): TemplateModuleStepConfig;
467
+ sqlFile(path: string, options?: TemplateLifecycleStepOptions): TemplateSqlFileStepConfig;
468
+ };
469
+ export declare const presets: {
470
+ nodeNext(options?: NodeNextPresetOptions): Pick<TestkitConfig, "execution" | "toolchains">;
471
+ };
440
472
  export declare const toolchain: {
441
473
  node(options?: NodeToolchainConfig): NodeToolchainConfig;
442
474
  };
@@ -53,10 +53,47 @@ function buildDatabaseTemplateConfig(options = {}) {
53
53
  };
54
54
  }
55
55
 
56
+ function commandStep(run, options = {}) {
57
+ return {
58
+ kind: "command",
59
+ run,
60
+ ...copyStepOptions(options),
61
+ };
62
+ }
63
+
64
+ function sqlFileStep(filePath, options = {}) {
65
+ return {
66
+ kind: "sql-file",
67
+ path: filePath,
68
+ ...copyStepOptions(options),
69
+ };
70
+ }
71
+
72
+ function moduleStep(target, options = {}) {
73
+ return {
74
+ kind: "module",
75
+ target,
76
+ ...copyStepOptions(options),
77
+ };
78
+ }
79
+
80
+ function copyStepOptions(options = {}) {
81
+ return {
82
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
83
+ ...(options.inputs !== undefined ? { inputs: [...options.inputs] } : {}),
84
+ ...(options.args !== undefined ? { args: options.args } : {}),
85
+ };
86
+ }
87
+
56
88
  function sourceSchemaFromEnv(envName, options = {}) {
57
89
  if (typeof envName !== "string" || envName.trim().length === 0) {
58
90
  throw new Error("database.schema.fromEnv(...) requires a non-empty env var name");
59
91
  }
92
+ if (Object.prototype.hasOwnProperty.call(options, "cachePath")) {
93
+ throw new Error(
94
+ "database.schema.fromEnv(...) no longer accepts cachePath. Testkit now manages commit-scoped source schema caches automatically."
95
+ );
96
+ }
60
97
  return {
61
98
  kind: "env",
62
99
  env: envName.trim(),
@@ -64,15 +101,30 @@ function sourceSchemaFromEnv(envName, options = {}) {
64
101
  };
65
102
  }
66
103
 
104
+ function databaseStepTarget(exportName) {
105
+ return `@elench/testkit/config/database-steps#${exportName}`;
106
+ }
107
+
108
+ function verifySeedStep(options = {}) {
109
+ return moduleStep(databaseStepTarget("verifySeed"), { args: options });
110
+ }
111
+
112
+ function materializePostgresBindingStep(options = {}) {
113
+ return moduleStep(databaseStepTarget("materializePostgresBinding"), { args: options });
114
+ }
115
+
67
116
  function postgresFixture(options = {}) {
68
- const { discovery, envFiles, template, ...databaseOptions } = options;
69
- for (const legacyKey of ["inputs", "schema", "migrate", "seed", "verify"]) {
117
+ const { discovery, envFiles, template, inputs, migrate, seed, verify, ...databaseOptions } = options;
118
+ for (const legacyKey of ["schema"]) {
70
119
  if (Object.prototype.hasOwnProperty.call(options, legacyKey)) {
71
120
  throw new Error(
72
- `database.fixture(...) no longer accepts top-level "${legacyKey}". Move lifecycle config under database.fixture({ template: { ... } }).`
121
+ `database.fixture(...) no longer accepts "${legacyKey}". Configure database.sourceSchema instead.`
73
122
  );
74
123
  }
75
124
  }
125
+ const resolvedTemplate = template || hasTemplateLifecycle(options)
126
+ ? buildDatabaseTemplateConfig({ inputs, migrate, seed, verify, ...(template || {}) })
127
+ : undefined;
76
128
  return {
77
129
  discovery: discovery || {
78
130
  roots: [".testkit-fixture"],
@@ -80,16 +132,22 @@ function postgresFixture(options = {}) {
80
132
  envFiles,
81
133
  local: false,
82
134
  database: postgresDatabase(
83
- template
135
+ resolvedTemplate
84
136
  ? {
85
137
  ...databaseOptions,
86
- template: buildDatabaseTemplateConfig(template),
138
+ template: resolvedTemplate,
87
139
  }
88
140
  : databaseOptions
89
141
  ),
90
142
  };
91
143
  }
92
144
 
145
+ function hasTemplateLifecycle(options = {}) {
146
+ return ["inputs", "migrate", "seed", "verify"].some((key) =>
147
+ Object.prototype.hasOwnProperty.call(options, key)
148
+ );
149
+ }
150
+
93
151
  function nodeToolchain(options = {}) {
94
152
  return {
95
153
  kind: "node",
@@ -190,6 +248,8 @@ function nodeApp(options = {}) {
190
248
 
191
249
  function nextApp(options = {}) {
192
250
  const {
251
+ api,
252
+ auth: authMode,
193
253
  baseUrl: explicitBaseUrl,
194
254
  browser,
195
255
  build: explicitBuild,
@@ -209,7 +269,10 @@ function nextApp(options = {}) {
209
269
  } = options;
210
270
 
211
271
  const normalizedPort = requiredNumber(port, "app.next port");
212
- const normalizedEnv = normalizePresetEnv(env);
272
+ const normalizedEnv = {
273
+ ...normalizeNextPresetEnv({ api, authMode }),
274
+ ...normalizePresetEnv(env),
275
+ };
213
276
  const baseUrl = explicitBaseUrl || "http://127.0.0.1:{port}";
214
277
  const build =
215
278
  explicitBuild === undefined
@@ -261,12 +324,16 @@ export const database = {
261
324
  schema: {
262
325
  fromEnv: sourceSchemaFromEnv,
263
326
  },
327
+ steps: {
328
+ materializePostgresBinding: materializePostgresBindingStep,
329
+ verifySeed: verifySeedStep,
330
+ },
264
331
  postgres(options = {}) {
265
- const { template, sourceSchema, ...databaseOptions } = options;
266
- for (const legacyKey of ["inputs", "schema", "migrate", "seed", "verify"]) {
332
+ const { template, sourceSchema, inputs, migrate, seed, verify, ...databaseOptions } = options;
333
+ for (const legacyKey of ["schema"]) {
267
334
  if (Object.prototype.hasOwnProperty.call(options, legacyKey)) {
268
335
  throw new Error(
269
- `database.postgres(...) no longer accepts top-level "${legacyKey}". Move lifecycle config under database.postgres({ template: { ... } }).`
336
+ `database.postgres(...) no longer accepts "${legacyKey}". Use database.postgres({ sourceSchema: database.schema.fromEnv(...) }) instead.`
270
337
  );
271
338
  }
272
339
  }
@@ -276,11 +343,14 @@ export const database = {
276
343
  ...databaseOptions,
277
344
  sourceSchema,
278
345
  };
346
+ const resolvedTemplate = template || hasTemplateLifecycle(options)
347
+ ? buildDatabaseTemplateConfig({ inputs, migrate, seed, verify, ...(template || {}) })
348
+ : undefined;
279
349
  return postgresDatabase(
280
- template
350
+ resolvedTemplate
281
351
  ? {
282
352
  ...normalizedDatabaseOptions,
283
- template: buildDatabaseTemplateConfig(template),
353
+ template: resolvedTemplate,
284
354
  }
285
355
  : normalizedDatabaseOptions
286
356
  );
@@ -288,6 +358,30 @@ export const database = {
288
358
  fixture: postgresFixture,
289
359
  };
290
360
 
361
+ export const step = {
362
+ command: commandStep,
363
+ module: moduleStep,
364
+ sqlFile: sqlFileStep,
365
+ };
366
+
367
+ export const presets = {
368
+ nodeNext(options = {}) {
369
+ return {
370
+ execution: {
371
+ workers: options.workers ?? 1,
372
+ fileTimeoutSeconds: options.fileTimeoutSeconds ?? 120,
373
+ },
374
+ toolchains: {
375
+ node: nodeToolchain({
376
+ install: options.install ?? "download",
377
+ ...(options.node ? { node: options.node } : {}),
378
+ ...(options.npm ? { npm: options.npm } : {}),
379
+ }),
380
+ },
381
+ };
382
+ },
383
+ };
384
+
291
385
  export const toolchain = {
292
386
  node: nodeToolchain,
293
387
  };
@@ -378,6 +472,22 @@ function normalizeDatabaseEnvToken(value, label, sanitize = true) {
378
472
  return normalized;
379
473
  }
380
474
 
475
+ function normalizeNextPresetEnv({ api, authMode } = {}) {
476
+ const env = {};
477
+ if (api) {
478
+ const baseUrlToken = typeof api === "string" ? api : api.baseUrl;
479
+ if (baseUrlToken) {
480
+ env.NEXT_PUBLIC_API_URL = baseUrlToken;
481
+ }
482
+ }
483
+ if (authMode === "disabled-clerk" || authMode?.kind === "disabled-clerk") {
484
+ env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = "testkit_disabled";
485
+ env.CLERK_SECRET_KEY = "testkit_disabled";
486
+ env.CLERK_WEBHOOK_SECRET = "testkit_disabled";
487
+ }
488
+ return env;
489
+ }
490
+
381
491
  function resolveNodeAppStart(build, entry) {
382
492
  if (build?.kind === "tsc") {
383
493
  const compiled = compiledEntryFromSource(entry || build.entry || "src/index.ts", build.outDir || "dist");
@@ -388,7 +498,8 @@ function resolveNodeAppStart(build, entry) {
388
498
 
389
499
  function normalizeTemplateStepList(value) {
390
500
  if (value == null) return [];
391
- return Array.isArray(value) ? [...value] : [value];
501
+ const values = Array.isArray(value) ? value : [value];
502
+ return values.map((entry) => typeof entry === "string" ? commandStep(entry) : entry);
392
503
  }
393
504
 
394
505
  function compiledEntryFromSource(entry, outDir) {
@@ -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,33 +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
8
  import { getSourceSchemaRefreshLockPath, withSourceSchemaRefreshLock } from "./source-refresh-lock.mjs";
9
9
  import { resolveSourceSchemaDumpUrl } from "./source-url.mjs";
10
10
 
11
11
  const SOURCE_SCHEMA_DIR = path.join(".testkit", "db");
12
+ const SOURCE_SCHEMA_CACHE_DIR = "source-schemas";
12
13
  const SOURCE_SCHEMA_FILE = "source-schema.sql";
13
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;
14
17
 
15
- export function getSourceSchemaCachePath(config) {
16
- const configured = config.testkit.database?.sourceSchema?.cachePath || null;
17
- return configured
18
- ? resolveServiceCwd(config.productDir, configured)
19
- : 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);
20
20
  }
21
21
 
22
- export function getSourceSchemaMetadataPath(config) {
23
- 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);
24
28
  return path.join(path.dirname(cachePath), SOURCE_SCHEMA_META_FILE);
25
29
  }
26
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
+
27
47
  export function resolveSourceSchemaState(config, options = {}) {
28
48
  const sourceSchema = config.testkit.database?.sourceSchema || null;
29
49
  if (!sourceSchema) return { active: false };
30
50
 
31
- const cachePath = getSourceSchemaCachePath(config);
51
+ const location = resolveSourceSchemaCacheLocation(config, options);
52
+ const cachePath = location.cachePath;
32
53
  const cacheExists = fs.existsSync(cachePath);
33
54
  const env = buildExecutionEnv(config, {}, options.env || process.env);
34
55
  const envNames = sourceSchema.kind === "env"
@@ -43,12 +64,19 @@ export function resolveSourceSchemaState(config, options = {}) {
43
64
  return {
44
65
  active: true,
45
66
  cachePath,
46
- 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,
47
74
  envName,
48
75
  sourceUrl,
49
76
  sourceSchema,
50
77
  cacheExists,
51
78
  refreshed: false,
79
+ ci: env.CI === "true",
52
80
  unavailableMode: resolveUnavailableMode(sourceSchema.unavailable, options.env || process.env),
53
81
  };
54
82
  }
@@ -60,8 +88,11 @@ export async function prepareSourceSchemaCache(config, options = {}) {
60
88
  if (state.sourceUrl) {
61
89
  if (shouldRefreshSourceSchema(state)) {
62
90
  const refreshInfo = await refreshSourceSchemaCache(config, state, options);
63
- return { ...state, refreshed: true, cacheExists: true, refreshInfo };
91
+ const refreshedState = { ...state, refreshed: true, cacheExists: true, refreshInfo };
92
+ updateSourceSchemaIndex(config, refreshedState, { refreshed: true });
93
+ return refreshedState;
64
94
  }
95
+ updateSourceSchemaIndex(config, state);
65
96
  options.setupRegistry?.recordCached({
66
97
  config,
67
98
  stage: "source-schema",
@@ -72,6 +103,7 @@ export async function prepareSourceSchemaCache(config, options = {}) {
72
103
  }
73
104
 
74
105
  if (state.cacheExists && state.unavailableMode === "warn-cache") {
106
+ updateSourceSchemaIndex(config, state);
75
107
  options.setupRegistry?.recordCached({
76
108
  config,
77
109
  stage: "source-schema",
@@ -96,7 +128,9 @@ export async function forceRefreshSourceSchemaCache(config, previousState, optio
96
128
  throw new Error(`Cannot refresh source schema for service "${config.name}" because ${envLabel} is unavailable.`);
97
129
  }
98
130
  const refreshInfo = await refreshSourceSchemaCache(config, state, options);
99
- return { ...state, refreshed: true, cacheExists: true, refreshInfo };
131
+ const refreshedState = { ...state, refreshed: true, cacheExists: true, refreshInfo };
132
+ updateSourceSchemaIndex(config, refreshedState, { refreshed: true });
133
+ return refreshedState;
100
134
  }
101
135
 
102
136
  export async function applySourceSchemaCache(config, databaseUrl, state, options = {}) {
@@ -158,10 +192,12 @@ export function createSourceSchemaMismatchError(config, state, verification) {
158
192
  return new Error(parts.join("\n"));
159
193
  }
160
194
 
161
- export function appendSourceSchemaCacheToHash(hash, config) {
195
+ export function appendSourceSchemaCacheToHash(hash, config, state = null) {
162
196
  const sourceSchema = config.testkit.database?.sourceSchema || null;
163
197
  if (!sourceSchema) return;
164
- 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}`);
165
201
  hash.update(`source-schema-cache:${path.relative(config.productDir, cachePath)}`);
166
202
  if (!fs.existsSync(cachePath)) {
167
203
  hash.update(":missing");
@@ -255,7 +291,10 @@ function buildSourceSchemaMetadata(config, state, result) {
255
291
  refreshedAt: new Date().toISOString(),
256
292
  serviceName: config.name,
257
293
  envName: state.envName,
294
+ cacheKey: state.cacheKey,
295
+ cacheKind: state.cacheKind,
258
296
  cachePath: path.relative(config.productDir, state.cachePath),
297
+ repo: summarizeRepoStateForMetadata(state.repoState),
259
298
  sourceUrl: result.resolution.metadata,
260
299
  pgDump: result.pgDump,
261
300
  };
@@ -280,6 +319,9 @@ function readSourceSchemaCacheText(cachePath) {
280
319
 
281
320
  function shouldRefreshSourceSchema(state) {
282
321
  if (!state.cacheExists) return true;
322
+ if (state.sourceSchema.refresh?.mode === "auto") {
323
+ return state.ci;
324
+ }
283
325
  if (state.sourceSchema.refresh?.mode === "ttl") {
284
326
  const meta = readJson(state.metadataPath);
285
327
  const refreshedAt = meta?.refreshedAt ? Date.parse(meta.refreshedAt) : 0;
@@ -289,11 +331,62 @@ function shouldRefreshSourceSchema(state) {
289
331
  return true;
290
332
  }
291
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
+
292
380
  function writeSourceSchemaMetadata(metadataPath, metadata) {
293
381
  fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
294
382
  fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
295
383
  }
296
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
+
297
390
  function readJson(filePath) {
298
391
  if (!filePath || !fs.existsSync(filePath)) return null;
299
392
  try {