@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.
Files changed (34) hide show
  1. package/README.md +33 -8
  2. package/lib/cli/args.mjs +3 -3
  3. package/lib/cli/command-flags.mjs +4 -0
  4. package/lib/cli/commands/db/schema/refresh.mjs +21 -0
  5. package/lib/cli/commands/db/schema/verify.mjs +27 -0
  6. package/lib/cli/entrypoint.mjs +1 -0
  7. package/lib/cli/operations/db/schema/refresh/operation.mjs +56 -0
  8. package/lib/cli/operations/db/{snapshot/capture → schema/verify}/operation.mjs +6 -27
  9. package/lib/cli/operations/run/operation.mjs +1 -0
  10. package/lib/cli/renderers/db-schema/text.mjs +7 -0
  11. package/lib/config/database.mjs +64 -0
  12. package/lib/config-api/index.d.ts +16 -1
  13. package/lib/config-api/index.mjs +31 -16
  14. package/lib/database/fingerprint.mjs +2 -0
  15. package/lib/database/index.mjs +142 -104
  16. package/lib/database/schema-source.mjs +295 -0
  17. package/lib/database/template-steps.mjs +158 -38
  18. package/lib/runner/orchestrator.mjs +4 -3
  19. package/lib/runner/template-steps.mjs +12 -1
  20. package/lib/runner/template.mjs +16 -1
  21. package/node_modules/@elench/next-analysis/package.json +1 -1
  22. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  23. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  24. package/node_modules/@elench/ts-analysis/package.json +1 -1
  25. package/node_modules/es-toolkit/CHANGELOG.md +801 -0
  26. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
  27. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
  28. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
  29. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
  30. package/node_modules/esprima/ChangeLog +235 -0
  31. package/package.json +8 -5
  32. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +1 -1
  33. package/lib/cli/commands/db/snapshot/capture.mjs +0 -26
  34. package/lib/cli/renderers/db-snapshot-capture/text.mjs +0 -3
@@ -25,7 +25,17 @@ import {
25
25
  readStateValue as readStateValueModel,
26
26
  visitDirs as visitDirsModel,
27
27
  } from "./state.mjs";
28
- import { captureTemplateSnapshot, runTemplateStage } from "./template-steps.mjs";
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
- const templateFingerprint = await computeTemplateFingerprint(config);
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 ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, options);
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
- const existingFingerprint = readStateValue(path.join(cacheDir, "template_fingerprint"));
204
- const existingDbName = readStateValue(path.join(cacheDir, "template_database_name"));
205
- const desiredDbName = buildTemplateDatabaseName(serviceName, templateFingerprint);
206
-
207
- if (
208
- existingFingerprint === templateFingerprint &&
209
- existingDbName &&
210
- (await databaseExists(infra, existingDbName))
211
- ) {
212
- options.setupRegistry?.recordCached({
213
- config,
214
- stage: "template",
215
- kind: "database-template",
216
- summary: "template cache hit",
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
- writeLocalCacheState(cacheDir, infra, existingDbName, templateFingerprint);
219
- return;
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
- if (existingDbName && existingDbName !== desiredDbName) {
223
- await dropDatabaseIfExists(infra, existingDbName);
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
- const templateUrl = buildDatabaseUrl(infra, desiredDbName);
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 runTemplateStage(config, "migrate", templateUrl, {
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
- const finished = templateOperation
255
- ? options.setupRegistry.finish(templateOperation, {
256
- status: "passed",
257
- summary: "template rebuild",
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
- const finished = templateOperation
263
- ? options.setupRegistry.finish(templateOperation, {
264
- status: "failed",
265
- summary: "template rebuild",
266
- error: error?.message || error,
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
- writeLocalCacheState(cacheDir, infra, desiredDbName, templateFingerprint);
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
+ }