@elench/testkit 0.1.115 → 0.1.116
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 +33 -8
- package/lib/cli/args.mjs +3 -3
- package/lib/cli/command-flags.mjs +4 -0
- package/lib/cli/commands/db/schema/refresh.mjs +21 -0
- package/lib/cli/commands/db/schema/verify.mjs +27 -0
- package/lib/cli/entrypoint.mjs +1 -0
- package/lib/cli/operations/db/schema/refresh/operation.mjs +56 -0
- package/lib/cli/operations/db/{snapshot/capture → schema/verify}/operation.mjs +6 -27
- package/lib/cli/operations/run/operation.mjs +1 -0
- package/lib/cli/renderers/db-schema/text.mjs +7 -0
- package/lib/config/database.mjs +64 -0
- package/lib/config-api/index.d.ts +16 -1
- package/lib/config-api/index.mjs +31 -16
- package/lib/database/fingerprint.mjs +2 -0
- package/lib/database/index.mjs +142 -104
- package/lib/database/schema-source.mjs +295 -0
- package/lib/database/template-steps.mjs +158 -38
- package/lib/runner/orchestrator.mjs +4 -3
- package/lib/runner/template-steps.mjs +12 -1
- package/lib/runner/template.mjs +16 -1
- 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/node_modules/es-toolkit/CHANGELOG.md +801 -0
- package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
- package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
- package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
- package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
- package/node_modules/esprima/ChangeLog +235 -0
- package/package.json +8 -5
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/lib/cli/commands/db/snapshot/capture.mjs +0 -26
- package/lib/cli/renderers/db-snapshot-capture/text.mjs +0 -3
package/lib/database/index.mjs
CHANGED
|
@@ -25,7 +25,17 @@ import {
|
|
|
25
25
|
readStateValue as readStateValueModel,
|
|
26
26
|
visitDirs as visitDirsModel,
|
|
27
27
|
} from "./state.mjs";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
runTemplateStage,
|
|
30
|
+
runTemplateStep,
|
|
31
|
+
} from "./template-steps.mjs";
|
|
32
|
+
import {
|
|
33
|
+
applySourceSchemaCache,
|
|
34
|
+
createSourceSchemaMismatchError,
|
|
35
|
+
forceRefreshSourceSchemaCache,
|
|
36
|
+
prepareSourceSchemaCache,
|
|
37
|
+
verifyLocalSchemaMatchesSource,
|
|
38
|
+
} from "./schema-source.mjs";
|
|
29
39
|
import { collectStateDirLines } from "../runner/state-io.mjs";
|
|
30
40
|
|
|
31
41
|
const LOCAL_IMAGE = "pgvector/pgvector:pg16";
|
|
@@ -50,60 +60,6 @@ export async function prepareDatabaseRuntime(config, options = {}) {
|
|
|
50
60
|
throw new Error(`Unsupported database provider "${db.provider}"`);
|
|
51
61
|
}
|
|
52
62
|
|
|
53
|
-
export async function captureDatabaseTemplateSnapshot(config, outputPath, options = {}) {
|
|
54
|
-
if (!config.testkit.database || config.testkit.database.provider !== "local") {
|
|
55
|
-
throw new Error(`Service "${config.name}" does not use a local testkit database`);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
await prepareDatabaseRuntime(config, options);
|
|
59
|
-
const cacheDir = getLocalServiceCacheDir(config.productDir, config.name);
|
|
60
|
-
const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
|
|
61
|
-
if (!templateDbName) {
|
|
62
|
-
throw new Error(`Missing template database for service "${config.name}"`);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const infra = await loadExistingLocalContainer(config.productDir);
|
|
66
|
-
if (!infra) {
|
|
67
|
-
throw new Error(`Missing local database container for service "${config.name}"`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const snapshotOperation = options.setupRegistry?.start({
|
|
71
|
-
config,
|
|
72
|
-
stage: "template:snapshot",
|
|
73
|
-
kind: "database-snapshot",
|
|
74
|
-
summary: `snapshot: ${path.relative(config.productDir, outputPath)}`,
|
|
75
|
-
});
|
|
76
|
-
try {
|
|
77
|
-
const output = await captureTemplateSnapshot(
|
|
78
|
-
config,
|
|
79
|
-
outputPath,
|
|
80
|
-
buildDatabaseUrl(infra, templateDbName),
|
|
81
|
-
{
|
|
82
|
-
reporter: options.reporter || null,
|
|
83
|
-
logRecord: snapshotOperation?._logRecord || null,
|
|
84
|
-
}
|
|
85
|
-
);
|
|
86
|
-
const finished = snapshotOperation
|
|
87
|
-
? options.setupRegistry.finish(snapshotOperation, {
|
|
88
|
-
status: "passed",
|
|
89
|
-
summary: `snapshot: ${path.relative(config.productDir, outputPath)}`,
|
|
90
|
-
})
|
|
91
|
-
: null;
|
|
92
|
-
if (finished) options.reporter?.setupOperationFinished?.(finished);
|
|
93
|
-
return output;
|
|
94
|
-
} catch (error) {
|
|
95
|
-
const finished = snapshotOperation
|
|
96
|
-
? options.setupRegistry.finish(snapshotOperation, {
|
|
97
|
-
status: "failed",
|
|
98
|
-
summary: `snapshot: ${path.relative(config.productDir, outputPath)}`,
|
|
99
|
-
error: error?.message || error,
|
|
100
|
-
})
|
|
101
|
-
: null;
|
|
102
|
-
if (finished) options.reporter?.setupOperationFinished?.(finished);
|
|
103
|
-
throw error;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
63
|
export async function destroyRuntimeDatabase({ productDir, stateDir }) {
|
|
108
64
|
const backend = readStateValue(path.join(stateDir, "database_backend"));
|
|
109
65
|
if (backend === "local") {
|
|
@@ -184,13 +140,24 @@ async function prepareLocalDatabase(config, options = {}) {
|
|
|
184
140
|
fs.mkdirSync(lockDir, { recursive: true });
|
|
185
141
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
186
142
|
|
|
187
|
-
|
|
143
|
+
let templateFingerprint = null;
|
|
188
144
|
const infra = await withLock(path.join(lockDir, "container.lock"), () =>
|
|
189
145
|
ensureLocalContainer(productDir, db)
|
|
190
146
|
);
|
|
191
147
|
|
|
192
148
|
await withLock(path.join(lockDir, `template-${serviceName}.lock`), async () => {
|
|
193
|
-
await
|
|
149
|
+
const sourceSchemaState = await prepareSourceSchemaCache(config, options);
|
|
150
|
+
templateFingerprint = await computeTemplateFingerprint(config);
|
|
151
|
+
templateFingerprint = await ensureTemplateDatabase(
|
|
152
|
+
config,
|
|
153
|
+
infra,
|
|
154
|
+
cacheDir,
|
|
155
|
+
templateFingerprint,
|
|
156
|
+
{
|
|
157
|
+
...options,
|
|
158
|
+
sourceSchemaState,
|
|
159
|
+
}
|
|
160
|
+
);
|
|
194
161
|
});
|
|
195
162
|
|
|
196
163
|
await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(bindingKey, 10)}.lock`), async () => {
|
|
@@ -200,34 +167,58 @@ async function prepareLocalDatabase(config, options = {}) {
|
|
|
200
167
|
|
|
201
168
|
async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, options = {}) {
|
|
202
169
|
const serviceName = config.name;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
existingFingerprint
|
|
209
|
-
existingDbName
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
170
|
+
let activeFingerprint = templateFingerprint;
|
|
171
|
+
let sourceSchemaState = options.sourceSchemaState || null;
|
|
172
|
+
let refreshedSourceAfterMismatch = false;
|
|
173
|
+
|
|
174
|
+
while (true) {
|
|
175
|
+
const existingFingerprint = readStateValue(path.join(cacheDir, "template_fingerprint"));
|
|
176
|
+
const existingDbName = readStateValue(path.join(cacheDir, "template_database_name"));
|
|
177
|
+
const desiredDbName = buildTemplateDatabaseName(serviceName, activeFingerprint);
|
|
178
|
+
|
|
179
|
+
if (
|
|
180
|
+
existingFingerprint === activeFingerprint &&
|
|
181
|
+
existingDbName &&
|
|
182
|
+
(await databaseExists(infra, existingDbName))
|
|
183
|
+
) {
|
|
184
|
+
options.setupRegistry?.recordCached({
|
|
185
|
+
config,
|
|
186
|
+
stage: "template",
|
|
187
|
+
kind: "database-template",
|
|
188
|
+
summary: "template cache hit",
|
|
189
|
+
});
|
|
190
|
+
writeLocalCacheState(cacheDir, infra, existingDbName, activeFingerprint);
|
|
191
|
+
return activeFingerprint;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (existingDbName && existingDbName !== desiredDbName) {
|
|
195
|
+
await dropDatabaseIfExists(infra, existingDbName);
|
|
196
|
+
}
|
|
197
|
+
if (await databaseExists(infra, desiredDbName)) {
|
|
198
|
+
await dropDatabaseIfExists(infra, desiredDbName);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const templateUrl = buildDatabaseUrl(infra, desiredDbName);
|
|
202
|
+
await createEmptyDatabase(infra, desiredDbName);
|
|
203
|
+
const buildResult = await rebuildTemplateDatabase(config, infra, desiredDbName, templateUrl, {
|
|
204
|
+
...options,
|
|
205
|
+
sourceSchemaState,
|
|
206
|
+
refreshedSourceAfterMismatch,
|
|
217
207
|
});
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
208
|
+
if (buildResult.refreshSourceSchema) {
|
|
209
|
+
await dropDatabaseIfExists(infra, desiredDbName);
|
|
210
|
+
sourceSchemaState = await forceRefreshSourceSchemaCache(config, sourceSchemaState, options);
|
|
211
|
+
refreshedSourceAfterMismatch = true;
|
|
212
|
+
activeFingerprint = await computeTemplateFingerprint(config);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
221
215
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
if (await databaseExists(infra, desiredDbName)) {
|
|
226
|
-
await dropDatabaseIfExists(infra, desiredDbName);
|
|
216
|
+
writeLocalCacheState(cacheDir, infra, desiredDbName, activeFingerprint);
|
|
217
|
+
return activeFingerprint;
|
|
227
218
|
}
|
|
219
|
+
}
|
|
228
220
|
|
|
229
|
-
|
|
230
|
-
await createEmptyDatabase(infra, desiredDbName);
|
|
221
|
+
async function rebuildTemplateDatabase(config, infra, templateDbName, templateUrl, options = {}) {
|
|
231
222
|
const templateOperation = options.setupRegistry?.start({
|
|
232
223
|
config,
|
|
233
224
|
stage: "template",
|
|
@@ -236,42 +227,89 @@ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerpri
|
|
|
236
227
|
recordLog: false,
|
|
237
228
|
});
|
|
238
229
|
try {
|
|
239
|
-
await
|
|
240
|
-
reporter: options.reporter || null,
|
|
241
|
-
setupRegistry: options.setupRegistry || null,
|
|
242
|
-
parentOperation: templateOperation,
|
|
243
|
-
});
|
|
244
|
-
await runTemplateStage(config, "seed", templateUrl, {
|
|
230
|
+
await applySourceSchemaCache(config, templateUrl, options.sourceSchemaState, {
|
|
245
231
|
reporter: options.reporter || null,
|
|
246
232
|
setupRegistry: options.setupRegistry || null,
|
|
247
233
|
parentOperation: templateOperation,
|
|
248
234
|
});
|
|
235
|
+
|
|
236
|
+
const verifySchema = async () => {
|
|
237
|
+
const verification = await verifyLocalSchemaMatchesSource(
|
|
238
|
+
config,
|
|
239
|
+
templateUrl,
|
|
240
|
+
options.sourceSchemaState,
|
|
241
|
+
{
|
|
242
|
+
reporter: options.reporter || null,
|
|
243
|
+
setupRegistry: options.setupRegistry || null,
|
|
244
|
+
skipSchemaSourceVerify: options.skipSchemaSourceVerify,
|
|
245
|
+
}
|
|
246
|
+
);
|
|
247
|
+
if (verification.status !== "mismatch") return null;
|
|
248
|
+
if (options.refreshedSourceAfterMismatch) {
|
|
249
|
+
throw createSourceSchemaMismatchError(config, options.sourceSchemaState, verification);
|
|
250
|
+
}
|
|
251
|
+
return verification;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const runStageWithDriftChecks = async (stageName) => {
|
|
255
|
+
const steps = config.testkit.database?.template?.[stageName] || [];
|
|
256
|
+
for (const [index, step] of steps.entries()) {
|
|
257
|
+
await runTemplateStep(config, stageName, step, index, templateUrl, {
|
|
258
|
+
reporter: options.reporter || null,
|
|
259
|
+
setupRegistry: options.setupRegistry || null,
|
|
260
|
+
parentOperation: templateOperation,
|
|
261
|
+
});
|
|
262
|
+
if (await verifySchema()) return true;
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
if (await runStageWithDriftChecks("migrate")) {
|
|
268
|
+
finishTemplateOperation(templateOperation, options, {
|
|
269
|
+
status: "passed",
|
|
270
|
+
summary: "source schema refresh requested",
|
|
271
|
+
});
|
|
272
|
+
return { refreshSourceSchema: true };
|
|
273
|
+
}
|
|
274
|
+
if (await runStageWithDriftChecks("seed")) {
|
|
275
|
+
finishTemplateOperation(templateOperation, options, {
|
|
276
|
+
status: "passed",
|
|
277
|
+
summary: "source schema refresh requested",
|
|
278
|
+
});
|
|
279
|
+
return { refreshSourceSchema: true };
|
|
280
|
+
}
|
|
281
|
+
if (await verifySchema()) {
|
|
282
|
+
finishTemplateOperation(templateOperation, options, {
|
|
283
|
+
status: "passed",
|
|
284
|
+
summary: "source schema refresh requested",
|
|
285
|
+
});
|
|
286
|
+
return { refreshSourceSchema: true };
|
|
287
|
+
}
|
|
288
|
+
|
|
249
289
|
await runTemplateStage(config, "verify", templateUrl, {
|
|
250
290
|
reporter: options.reporter || null,
|
|
251
291
|
setupRegistry: options.setupRegistry || null,
|
|
252
292
|
parentOperation: templateOperation,
|
|
253
293
|
});
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
: null;
|
|
260
|
-
if (finished) options.reporter?.setupOperationFinished?.(finished);
|
|
294
|
+
finishTemplateOperation(templateOperation, options, {
|
|
295
|
+
status: "passed",
|
|
296
|
+
summary: "template rebuild",
|
|
297
|
+
});
|
|
298
|
+
return { refreshSourceSchema: false };
|
|
261
299
|
} catch (error) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
: null;
|
|
269
|
-
if (finished) options.reporter?.setupOperationFinished?.(finished);
|
|
270
|
-
await dropDatabaseIfExists(infra, desiredDbName);
|
|
300
|
+
finishTemplateOperation(templateOperation, options, {
|
|
301
|
+
status: "failed",
|
|
302
|
+
summary: "template rebuild",
|
|
303
|
+
error: error?.message || error,
|
|
304
|
+
});
|
|
305
|
+
await dropDatabaseIfExists(infra, templateDbName);
|
|
271
306
|
throw error;
|
|
272
307
|
}
|
|
308
|
+
}
|
|
273
309
|
|
|
274
|
-
|
|
310
|
+
function finishTemplateOperation(templateOperation, options, result) {
|
|
311
|
+
const finished = templateOperation ? options.setupRegistry.finish(templateOperation, result) : null;
|
|
312
|
+
if (finished) options.reporter?.setupOperationFinished?.(finished);
|
|
275
313
|
}
|
|
276
314
|
|
|
277
315
|
async function ensureRuntimeClone(config, infra, cacheDir, templateFingerprint, bindingKey) {
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { execa } from "execa";
|
|
5
|
+
import { resolveServiceCwd } from "../config/paths.mjs";
|
|
6
|
+
import { buildExecutionEnv } from "../runner/template.mjs";
|
|
7
|
+
import { dumpPostgresSchemaToFile, captureTemplateSnapshotText, runTemplateStep, sanitizeSnapshotText } from "./template-steps.mjs";
|
|
8
|
+
|
|
9
|
+
const SOURCE_SCHEMA_DIR = path.join(".testkit", "db");
|
|
10
|
+
const SOURCE_SCHEMA_FILE = "source-schema.sql";
|
|
11
|
+
const SOURCE_SCHEMA_META_FILE = "source-schema.meta.json";
|
|
12
|
+
|
|
13
|
+
export function getSourceSchemaCachePath(config) {
|
|
14
|
+
const configured = config.testkit.database?.sourceSchema?.cachePath || null;
|
|
15
|
+
return configured
|
|
16
|
+
? resolveServiceCwd(config.productDir, configured)
|
|
17
|
+
: path.join(config.productDir, SOURCE_SCHEMA_DIR, config.name, SOURCE_SCHEMA_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getSourceSchemaMetadataPath(config) {
|
|
21
|
+
const cachePath = getSourceSchemaCachePath(config);
|
|
22
|
+
return path.join(path.dirname(cachePath), SOURCE_SCHEMA_META_FILE);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveSourceSchemaState(config, options = {}) {
|
|
26
|
+
const sourceSchema = config.testkit.database?.sourceSchema || null;
|
|
27
|
+
if (!sourceSchema) return { active: false };
|
|
28
|
+
|
|
29
|
+
const cachePath = getSourceSchemaCachePath(config);
|
|
30
|
+
const cacheExists = fs.existsSync(cachePath);
|
|
31
|
+
const env = buildExecutionEnv(config, {}, options.env || process.env);
|
|
32
|
+
const envNames = sourceSchema.kind === "env"
|
|
33
|
+
? [sourceSchema.env]
|
|
34
|
+
: [serviceSourceSchemaEnvName(config.name), "TESTKIT_SCHEMA_SOURCE_DATABASE_URL"];
|
|
35
|
+
const envName = envNames.find((name) => typeof env[name] === "string" && env[name].trim().length > 0) || null;
|
|
36
|
+
const sourceUrl = envName ? env[envName].trim() : null;
|
|
37
|
+
const explicit = sourceSchema.kind === "env";
|
|
38
|
+
const active = explicit || Boolean(sourceUrl) || cacheExists;
|
|
39
|
+
if (!active) return { active: false, cachePath };
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
active: true,
|
|
43
|
+
cachePath,
|
|
44
|
+
metadataPath: getSourceSchemaMetadataPath(config),
|
|
45
|
+
envName,
|
|
46
|
+
sourceUrl,
|
|
47
|
+
sourceSchema,
|
|
48
|
+
cacheExists,
|
|
49
|
+
refreshed: false,
|
|
50
|
+
unavailableMode: resolveUnavailableMode(sourceSchema.unavailable, options.env || process.env),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function prepareSourceSchemaCache(config, options = {}) {
|
|
55
|
+
const state = resolveSourceSchemaState(config, options);
|
|
56
|
+
if (!state.active) return state;
|
|
57
|
+
|
|
58
|
+
if (state.sourceUrl) {
|
|
59
|
+
if (shouldRefreshSourceSchema(state)) {
|
|
60
|
+
await refreshSourceSchemaCache(config, state, options);
|
|
61
|
+
return { ...state, refreshed: true, cacheExists: true };
|
|
62
|
+
}
|
|
63
|
+
options.setupRegistry?.recordCached({
|
|
64
|
+
config,
|
|
65
|
+
stage: "source-schema",
|
|
66
|
+
kind: "database-source-schema",
|
|
67
|
+
summary: "source schema cache hit",
|
|
68
|
+
});
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (state.cacheExists && state.unavailableMode === "warn-cache") {
|
|
73
|
+
options.setupRegistry?.recordCached({
|
|
74
|
+
config,
|
|
75
|
+
stage: "source-schema",
|
|
76
|
+
kind: "database-source-schema",
|
|
77
|
+
summary: "source unavailable; using cached source schema",
|
|
78
|
+
});
|
|
79
|
+
return state;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const envLabel = state.sourceSchema.kind === "env"
|
|
83
|
+
? state.sourceSchema.env
|
|
84
|
+
: `${serviceSourceSchemaEnvName(config.name)} or TESTKIT_SCHEMA_SOURCE_DATABASE_URL`;
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Source schema database URL is unavailable for service "${config.name}". Set ${envLabel}.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function forceRefreshSourceSchemaCache(config, previousState, options = {}) {
|
|
91
|
+
const state = resolveSourceSchemaState(config, options);
|
|
92
|
+
if (!state.active || !state.sourceUrl) {
|
|
93
|
+
const envLabel = previousState?.envName || previousState?.sourceSchema?.env || "source schema env";
|
|
94
|
+
throw new Error(`Cannot refresh source schema for service "${config.name}" because ${envLabel} is unavailable.`);
|
|
95
|
+
}
|
|
96
|
+
await refreshSourceSchemaCache(config, state, options);
|
|
97
|
+
return { ...state, refreshed: true, cacheExists: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function applySourceSchemaCache(config, databaseUrl, state, options = {}) {
|
|
101
|
+
if (!state?.active) return false;
|
|
102
|
+
if (!fs.existsSync(state.cachePath)) {
|
|
103
|
+
throw new Error(`Missing source schema cache for service "${config.name}": ${state.cachePath}`);
|
|
104
|
+
}
|
|
105
|
+
await runTemplateStep(
|
|
106
|
+
config,
|
|
107
|
+
"source-schema",
|
|
108
|
+
{
|
|
109
|
+
kind: "sql-file",
|
|
110
|
+
path: path.relative(config.productDir, state.cachePath).split(path.sep).join("/"),
|
|
111
|
+
cwd: null,
|
|
112
|
+
inputs: [],
|
|
113
|
+
},
|
|
114
|
+
0,
|
|
115
|
+
databaseUrl,
|
|
116
|
+
options
|
|
117
|
+
);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function verifyLocalSchemaMatchesSource(config, databaseUrl, state, options = {}) {
|
|
122
|
+
if (!state?.active) return { status: "disabled" };
|
|
123
|
+
if (state.sourceSchema.verify === false || options.skipSchemaSourceVerify) {
|
|
124
|
+
options.setupRegistry?.recordCached({
|
|
125
|
+
config,
|
|
126
|
+
stage: "source-schema:verify",
|
|
127
|
+
kind: "database-source-schema",
|
|
128
|
+
summary: "source schema verification skipped",
|
|
129
|
+
});
|
|
130
|
+
return { status: "skipped" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const sourceSchema = readSourceSchemaCacheText(state.cachePath);
|
|
134
|
+
const localSchema = await captureTemplateSnapshotText(config, databaseUrl, {
|
|
135
|
+
reporter: options.reporter || null,
|
|
136
|
+
});
|
|
137
|
+
if (localSchema === sourceSchema) {
|
|
138
|
+
return { status: "matched" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const diagnostics = await writeSchemaMismatchDiagnostics(config, state.cachePath, sourceSchema, localSchema);
|
|
142
|
+
return {
|
|
143
|
+
status: "mismatch",
|
|
144
|
+
diagnostics,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createSourceSchemaMismatchError(config, state, verification) {
|
|
149
|
+
const parts = [
|
|
150
|
+
`Local schema replay does not match source schema for service "${config.name}".`,
|
|
151
|
+
`Source cache: ${path.relative(config.productDir, state.cachePath)}`,
|
|
152
|
+
];
|
|
153
|
+
if (verification?.diagnostics?.diffPath) {
|
|
154
|
+
parts.push(`Diff: ${path.relative(config.productDir, verification.diagnostics.diffPath)}`);
|
|
155
|
+
}
|
|
156
|
+
return new Error(parts.join("\n"));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function appendSourceSchemaCacheToHash(hash, config) {
|
|
160
|
+
const sourceSchema = config.testkit.database?.sourceSchema || null;
|
|
161
|
+
if (!sourceSchema) return;
|
|
162
|
+
const cachePath = getSourceSchemaCachePath(config);
|
|
163
|
+
hash.update(`source-schema-cache:${path.relative(config.productDir, cachePath)}`);
|
|
164
|
+
if (!fs.existsSync(cachePath)) {
|
|
165
|
+
hash.update(":missing");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const stat = fs.statSync(cachePath);
|
|
169
|
+
hash.update(`:${stat.size}:${stat.mtimeMs}`);
|
|
170
|
+
hash.update(fs.readFileSync(cachePath));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function refreshSourceSchemaCache(config, state, options = {}) {
|
|
174
|
+
const operation = options.setupRegistry?.start({
|
|
175
|
+
config,
|
|
176
|
+
stage: "source-schema:refresh",
|
|
177
|
+
kind: "database-source-schema",
|
|
178
|
+
summary: `source schema refresh: ${path.relative(config.productDir, state.cachePath)}`,
|
|
179
|
+
});
|
|
180
|
+
try {
|
|
181
|
+
await writeSourceSchemaCacheFromUrl(config, state.sourceUrl, state.cachePath, {
|
|
182
|
+
reporter: options.reporter || null,
|
|
183
|
+
logRecord: operation?._logRecord || null,
|
|
184
|
+
});
|
|
185
|
+
writeSourceSchemaMetadata(state.metadataPath, {
|
|
186
|
+
refreshedAt: new Date().toISOString(),
|
|
187
|
+
serviceName: config.name,
|
|
188
|
+
envName: state.envName,
|
|
189
|
+
cachePath: path.relative(config.productDir, state.cachePath),
|
|
190
|
+
});
|
|
191
|
+
const finished = operation
|
|
192
|
+
? options.setupRegistry.finish(operation, {
|
|
193
|
+
status: "passed",
|
|
194
|
+
summary: `source schema refresh: ${path.relative(config.productDir, state.cachePath)}`,
|
|
195
|
+
})
|
|
196
|
+
: null;
|
|
197
|
+
if (finished) options.reporter?.setupOperationFinished?.(finished);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const finished = operation
|
|
200
|
+
? options.setupRegistry.finish(operation, {
|
|
201
|
+
status: "failed",
|
|
202
|
+
summary: `source schema refresh: ${path.relative(config.productDir, state.cachePath)}`,
|
|
203
|
+
error: error?.message || error,
|
|
204
|
+
})
|
|
205
|
+
: null;
|
|
206
|
+
if (finished) options.reporter?.setupOperationFinished?.(finished);
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function writeSourceSchemaCacheFromUrl(config, sourceUrl, cachePath, options = {}) {
|
|
212
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
213
|
+
const tempDir = fs.mkdtempSync(path.join(path.dirname(cachePath), ".source-schema-"));
|
|
214
|
+
const tempPath = path.join(tempDir, path.basename(cachePath));
|
|
215
|
+
try {
|
|
216
|
+
await dumpPostgresSchemaToFile(config, tempPath, sourceUrl, options);
|
|
217
|
+
fs.renameSync(tempPath, cachePath);
|
|
218
|
+
} finally {
|
|
219
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readSourceSchemaCacheText(cachePath) {
|
|
224
|
+
return sanitizeSnapshotText(fs.readFileSync(cachePath, "utf8"));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function shouldRefreshSourceSchema(state) {
|
|
228
|
+
if (!state.cacheExists) return true;
|
|
229
|
+
if (state.sourceSchema.refresh?.mode === "ttl") {
|
|
230
|
+
const meta = readJson(state.metadataPath);
|
|
231
|
+
const refreshedAt = meta?.refreshedAt ? Date.parse(meta.refreshedAt) : 0;
|
|
232
|
+
if (!Number.isFinite(refreshedAt) || refreshedAt <= 0) return true;
|
|
233
|
+
return Date.now() - refreshedAt >= state.sourceSchema.refresh.ttlSeconds * 1000;
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function writeSourceSchemaMetadata(metadataPath, metadata) {
|
|
239
|
+
fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
|
|
240
|
+
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function readJson(filePath) {
|
|
244
|
+
if (!filePath || !fs.existsSync(filePath)) return null;
|
|
245
|
+
try {
|
|
246
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function writeSchemaMismatchDiagnostics(config, sourceCachePath, sourceSchema, localSchema) {
|
|
253
|
+
const dir = path.join(config.productDir, ".testkit", "results", "schema");
|
|
254
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
255
|
+
const prefix = `${sanitizePathSegment(config.runtimeLabel || config.name)}__${sanitizePathSegment(config.name)}`;
|
|
256
|
+
const sourcePath = path.join(dir, `${prefix}__source-schema.sql`);
|
|
257
|
+
const localPath = path.join(dir, `${prefix}__local-replay-schema.sql`);
|
|
258
|
+
const diffPath = path.join(dir, `${prefix}__schema.diff`);
|
|
259
|
+
fs.writeFileSync(sourcePath, sourceSchema);
|
|
260
|
+
fs.writeFileSync(localPath, localSchema);
|
|
261
|
+
fs.writeFileSync(diffPath, await buildUnifiedDiff(sourcePath, localPath));
|
|
262
|
+
return {
|
|
263
|
+
sourceCachePath,
|
|
264
|
+
sourcePath,
|
|
265
|
+
localPath,
|
|
266
|
+
diffPath,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function buildUnifiedDiff(sourcePath, localPath) {
|
|
271
|
+
const result = await execa("diff", ["-u", sourcePath, localPath], {
|
|
272
|
+
reject: false,
|
|
273
|
+
stdout: "pipe",
|
|
274
|
+
stderr: "pipe",
|
|
275
|
+
}).catch((error) => ({ exitCode: 2, stdout: "", stderr: error.message }));
|
|
276
|
+
if (result.exitCode === 0 || result.exitCode === 1) return result.stdout || "";
|
|
277
|
+
return `Could not generate diff: ${result.stderr || "diff failed"}\n`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function resolveUnavailableMode(value, env) {
|
|
281
|
+
if (value === "fail" || value === "warn-cache") return value;
|
|
282
|
+
return env.CI === "true" ? "fail" : "warn-cache";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function serviceSourceSchemaEnvName(serviceName) {
|
|
286
|
+
return `TESTKIT_${String(serviceName).toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_SCHEMA_SOURCE_DATABASE_URL`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function sanitizePathSegment(value) {
|
|
290
|
+
return String(value)
|
|
291
|
+
.trim()
|
|
292
|
+
.toLowerCase()
|
|
293
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
294
|
+
.replace(/^-+|-+$/g, "") || "schema";
|
|
295
|
+
}
|