@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 +27 -12
- package/lib/cli/assistant/context-pack.mjs +31 -11
- package/lib/cli/operations/db/schema/refresh/operation.mjs +4 -2
- package/lib/cli/renderers/db-schema/text.mjs +3 -0
- package/lib/config/database.mjs +9 -13
- package/lib/config-api/index.d.ts +1 -2
- package/lib/config-api/index.mjs +5 -0
- package/lib/database/fingerprint.mjs +2 -2
- package/lib/database/index.mjs +4 -4
- package/lib/database/schema-source.mjs +107 -14
- package/lib/repo/state.mjs +164 -0
- package/lib/runner/metadata.mjs +11 -24
- package/lib/runner/template.mjs +0 -3
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +6 -5
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
- 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`
|
|
329
|
-
|
|
330
|
-
applies that cached schema to the local
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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 =
|
|
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
|
}
|
package/lib/config/database.mjs
CHANGED
|
@@ -66,8 +66,7 @@ function normalizeSourceSchemaConfig(value, serviceName) {
|
|
|
66
66
|
if (value === undefined) {
|
|
67
67
|
return {
|
|
68
68
|
kind: "auto",
|
|
69
|
-
|
|
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 === "
|
|
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
|
-
|
|
19
|
-
refresh?: "always" | { ttlSeconds: number };
|
|
18
|
+
refresh?: "auto" | "always" | { ttlSeconds: number };
|
|
20
19
|
unavailable?: "auto" | "fail" | "warn-cache";
|
|
21
20
|
verify?: boolean;
|
|
22
21
|
}
|
package/lib/config-api/index.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/database/index.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
16
|
-
|
|
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
|
|
23
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|