@elench/testkit 0.1.117 → 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.
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
 
@@ -349,11 +360,12 @@ exiting cannot be refreshed at the midpoint.
349
360
  Source schema refreshes are intentionally single-connection and pooler-safe.
350
361
  If a Neon pooled source URL is configured, Testkit rewrites it to the matching
351
362
  direct Neon endpoint before running `pg_dump` and records the original/resolved
352
- host classifications in `.testkit/db/<service>/source-schema.meta.json`. Unknown
353
- PgBouncer/pooler URLs fail closed; configure a direct source URL for those
354
- providers. Concurrent refreshes for the same service are serialized with a
355
- cache-local lock so multiple Testkit processes do not stampede the source
356
- database.
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.
357
369
 
358
370
  For most repos, prefer declarative step objects directly inside
359
371
  `database.postgres({ template: ... })` and `runtime.prepare.steps`.
@@ -695,6 +707,8 @@ services that define `database: database.postgres(...)`.
695
707
  - runtime databases are cloned from templates when binding is `per-runtime`
696
708
  - shared databases are reused when binding is `shared`
697
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
698
712
  - template fingerprints are derived automatically from env files, source schema
699
713
  cache, migrate/seed config, and repo contents
700
714
 
@@ -711,6 +725,7 @@ npm test
711
725
  npm run test:unit
712
726
  npm run test:integration
713
727
  npm run test:system
728
+ npm run test:live:github
714
729
  npm run test:live:neon
715
730
  npm run test:database-version:compat
716
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,13 +40,15 @@ 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,
51
53
  sourceUrl: state.refreshInfo?.metadata?.sourceUrl || null,
52
54
  reusedExistingRefresh: Boolean(state.refreshInfo?.reusedExistingRefresh),
@@ -1,5 +1,8 @@
1
1
  export function renderDatabaseSchemaRefreshResult(result) {
2
2
  const lines = [`Refreshed ${result.outputLabel}`];
3
+ if (result.cacheKey) {
4
+ lines.push(`Cache key ${result.cacheKey}`);
5
+ }
3
6
  if (result.sourceUrl?.rewritten && result.sourceUrl.originalClassification === "neon-pooler") {
4
7
  lines.push("Source schema URL uses Neon pooler; Testkit used the direct endpoint for pg_dump.");
5
8
  }
@@ -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,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 {