@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.
- package/README.md +31 -7
- package/lib/cli/assistant/context-pack.mjs +31 -11
- package/lib/cli/operations/db/schema/refresh/operation.mjs +6 -2
- package/lib/cli/renderers/db-schema/text.mjs +11 -1
- 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 +174 -27
- package/lib/database/source-refresh-lock.mjs +69 -0
- package/lib/database/source-url.mjs +110 -0
- package/lib/database/template-steps.mjs +16 -8
- 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/node_modules/es-toolkit/CHANGELOG.md +0 -801
- package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +0 -1
- package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +0 -3
- package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +0 -4
- package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +0 -4
- package/node_modules/esprima/ChangeLog +0 -235
- 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
|
|
|
@@ -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
|
|
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,14 +40,18 @@ 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,
|
|
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
|
-
|
|
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) {
|
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,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 {
|
|
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
|
|
14
|
-
|
|
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
|
|
21
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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,
|
|
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 {
|