@elench/testkit 0.1.117 → 0.1.119
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/app/doctor.mjs +11 -113
- package/lib/cli/assistant/command-observer.mjs +1 -1
- package/lib/cli/assistant/context-pack.mjs +31 -11
- package/lib/cli/assistant/state.mjs +2 -0
- package/lib/cli/commands/lint.mjs +37 -0
- package/lib/cli/entrypoint.mjs +1 -0
- package/lib/cli/operations/db/schema/refresh/operation.mjs +4 -2
- package/lib/cli/operations/lint/operation.mjs +12 -0
- package/lib/cli/renderers/db-schema/text.mjs +3 -0
- package/lib/cli/renderers/doctor/text.mjs +5 -0
- package/lib/cli/renderers/lint/text.mjs +20 -0
- package/lib/config/database.mjs +9 -13
- package/lib/config-api/database-steps.mjs +132 -0
- package/lib/config-api/index.d.ts +37 -5
- package/lib/config-api/index.mjs +123 -12
- package/lib/database/fingerprint.mjs +2 -2
- package/lib/database/index.mjs +4 -4
- package/lib/database/schema-source.mjs +107 -14
- package/lib/lint/index.mjs +569 -0
- package/lib/repo/state.mjs +164 -0
- package/lib/runner/metadata.mjs +11 -24
- package/lib/runner/template-steps.mjs +8 -0
- package/lib/runner/template.mjs +0 -3
- package/lib/runtime/index.d.ts +43 -0
- package/lib/runtime/index.mjs +24 -0
- package/lib/runtime-src/k6/http-assertions.js +82 -0
- package/lib/shared/configured-steps.mjs +16 -0
- package/lib/ui/index.d.ts +46 -0
- package/lib/ui/index.mjs +11 -0
- package/lib/ui/sandbox.mjs +115 -0
- 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/package.json +1 -1
|
@@ -9,14 +9,13 @@ export interface DatabaseTemplateConfig {
|
|
|
9
9
|
|
|
10
10
|
export interface DatabaseTemplateOptions {
|
|
11
11
|
inputs?: string[];
|
|
12
|
-
migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[];
|
|
13
|
-
seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[];
|
|
14
|
-
verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[];
|
|
12
|
+
migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
13
|
+
seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
14
|
+
verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
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
|
}
|
|
@@ -27,6 +26,7 @@ export interface DatabaseSourceSchemaConfig extends DatabaseSourceSchemaOptions
|
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
export interface TemplateStepBaseConfig {
|
|
29
|
+
args?: unknown;
|
|
30
30
|
cwd?: string;
|
|
31
31
|
inputs?: string[];
|
|
32
32
|
}
|
|
@@ -51,6 +51,8 @@ export type TemplateLifecycleStepConfig =
|
|
|
51
51
|
| TemplateSqlFileStepConfig
|
|
52
52
|
| TemplateModuleStepConfig;
|
|
53
53
|
|
|
54
|
+
export interface TemplateLifecycleStepOptions extends TemplateStepBaseConfig {}
|
|
55
|
+
|
|
54
56
|
export interface TscBuildConfig {
|
|
55
57
|
kind: "tsc";
|
|
56
58
|
cwd?: string;
|
|
@@ -380,6 +382,8 @@ export interface NodeAppOptions extends Omit<ServiceConfig, "local" | "runtime"
|
|
|
380
382
|
}
|
|
381
383
|
|
|
382
384
|
export interface NextAppOptions extends Omit<ServiceConfig, "local" | "runtime" | "env"> {
|
|
385
|
+
api?: string | { baseUrl: string };
|
|
386
|
+
auth?: "disabled-clerk" | { kind: "disabled-clerk" };
|
|
383
387
|
baseUrl?: string;
|
|
384
388
|
build?: BuildConfig | null;
|
|
385
389
|
buildInputs?: string[];
|
|
@@ -414,6 +418,14 @@ export interface TestkitConfig {
|
|
|
414
418
|
};
|
|
415
419
|
}
|
|
416
420
|
|
|
421
|
+
export interface NodeNextPresetOptions {
|
|
422
|
+
fileTimeoutSeconds?: number;
|
|
423
|
+
install?: "require-host" | "download";
|
|
424
|
+
node?: string;
|
|
425
|
+
npm?: string;
|
|
426
|
+
workers?: number;
|
|
427
|
+
}
|
|
428
|
+
|
|
417
429
|
export declare function defineConfig<T extends TestkitConfig>(config: T): T;
|
|
418
430
|
export declare function defineFile<T extends TestkitFileMetadata>(metadata: T): T;
|
|
419
431
|
export declare const app: {
|
|
@@ -424,19 +436,39 @@ export declare const database: {
|
|
|
424
436
|
schema: {
|
|
425
437
|
fromEnv(envName: string, options?: DatabaseSourceSchemaOptions): DatabaseSourceSchemaConfig;
|
|
426
438
|
};
|
|
439
|
+
steps: {
|
|
440
|
+
materializePostgresBinding(options?: unknown): TemplateModuleStepConfig;
|
|
441
|
+
verifySeed(options?: unknown): TemplateModuleStepConfig;
|
|
442
|
+
};
|
|
427
443
|
postgres(
|
|
428
444
|
options?: Omit<LocalDatabaseConfig, "provider" | "template"> & {
|
|
445
|
+
inputs?: string[];
|
|
446
|
+
migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
447
|
+
seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
429
448
|
template?: DatabaseTemplateOptions;
|
|
449
|
+
verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
430
450
|
}
|
|
431
451
|
): LocalDatabaseConfig;
|
|
432
452
|
fixture(
|
|
433
453
|
options?: Omit<LocalDatabaseConfig, "provider" | "template"> & {
|
|
454
|
+
inputs?: string[];
|
|
455
|
+
migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
456
|
+
seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
434
457
|
template?: DatabaseTemplateOptions;
|
|
458
|
+
verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
435
459
|
discovery?: DiscoveryConfig;
|
|
436
460
|
envFiles?: string[];
|
|
437
461
|
}
|
|
438
462
|
): ServiceConfig;
|
|
439
463
|
};
|
|
464
|
+
export declare const step: {
|
|
465
|
+
command(run: string, options?: TemplateLifecycleStepOptions): TemplateCommandStepConfig;
|
|
466
|
+
module(target: string, options?: TemplateLifecycleStepOptions): TemplateModuleStepConfig;
|
|
467
|
+
sqlFile(path: string, options?: TemplateLifecycleStepOptions): TemplateSqlFileStepConfig;
|
|
468
|
+
};
|
|
469
|
+
export declare const presets: {
|
|
470
|
+
nodeNext(options?: NodeNextPresetOptions): Pick<TestkitConfig, "execution" | "toolchains">;
|
|
471
|
+
};
|
|
440
472
|
export declare const toolchain: {
|
|
441
473
|
node(options?: NodeToolchainConfig): NodeToolchainConfig;
|
|
442
474
|
};
|
package/lib/config-api/index.mjs
CHANGED
|
@@ -53,10 +53,47 @@ function buildDatabaseTemplateConfig(options = {}) {
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function commandStep(run, options = {}) {
|
|
57
|
+
return {
|
|
58
|
+
kind: "command",
|
|
59
|
+
run,
|
|
60
|
+
...copyStepOptions(options),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sqlFileStep(filePath, options = {}) {
|
|
65
|
+
return {
|
|
66
|
+
kind: "sql-file",
|
|
67
|
+
path: filePath,
|
|
68
|
+
...copyStepOptions(options),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function moduleStep(target, options = {}) {
|
|
73
|
+
return {
|
|
74
|
+
kind: "module",
|
|
75
|
+
target,
|
|
76
|
+
...copyStepOptions(options),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function copyStepOptions(options = {}) {
|
|
81
|
+
return {
|
|
82
|
+
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
83
|
+
...(options.inputs !== undefined ? { inputs: [...options.inputs] } : {}),
|
|
84
|
+
...(options.args !== undefined ? { args: options.args } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
56
88
|
function sourceSchemaFromEnv(envName, options = {}) {
|
|
57
89
|
if (typeof envName !== "string" || envName.trim().length === 0) {
|
|
58
90
|
throw new Error("database.schema.fromEnv(...) requires a non-empty env var name");
|
|
59
91
|
}
|
|
92
|
+
if (Object.prototype.hasOwnProperty.call(options, "cachePath")) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"database.schema.fromEnv(...) no longer accepts cachePath. Testkit now manages commit-scoped source schema caches automatically."
|
|
95
|
+
);
|
|
96
|
+
}
|
|
60
97
|
return {
|
|
61
98
|
kind: "env",
|
|
62
99
|
env: envName.trim(),
|
|
@@ -64,15 +101,30 @@ function sourceSchemaFromEnv(envName, options = {}) {
|
|
|
64
101
|
};
|
|
65
102
|
}
|
|
66
103
|
|
|
104
|
+
function databaseStepTarget(exportName) {
|
|
105
|
+
return `@elench/testkit/config/database-steps#${exportName}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function verifySeedStep(options = {}) {
|
|
109
|
+
return moduleStep(databaseStepTarget("verifySeed"), { args: options });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function materializePostgresBindingStep(options = {}) {
|
|
113
|
+
return moduleStep(databaseStepTarget("materializePostgresBinding"), { args: options });
|
|
114
|
+
}
|
|
115
|
+
|
|
67
116
|
function postgresFixture(options = {}) {
|
|
68
|
-
const { discovery, envFiles, template, ...databaseOptions } = options;
|
|
69
|
-
for (const legacyKey of ["
|
|
117
|
+
const { discovery, envFiles, template, inputs, migrate, seed, verify, ...databaseOptions } = options;
|
|
118
|
+
for (const legacyKey of ["schema"]) {
|
|
70
119
|
if (Object.prototype.hasOwnProperty.call(options, legacyKey)) {
|
|
71
120
|
throw new Error(
|
|
72
|
-
`database.fixture(...) no longer accepts
|
|
121
|
+
`database.fixture(...) no longer accepts "${legacyKey}". Configure database.sourceSchema instead.`
|
|
73
122
|
);
|
|
74
123
|
}
|
|
75
124
|
}
|
|
125
|
+
const resolvedTemplate = template || hasTemplateLifecycle(options)
|
|
126
|
+
? buildDatabaseTemplateConfig({ inputs, migrate, seed, verify, ...(template || {}) })
|
|
127
|
+
: undefined;
|
|
76
128
|
return {
|
|
77
129
|
discovery: discovery || {
|
|
78
130
|
roots: [".testkit-fixture"],
|
|
@@ -80,16 +132,22 @@ function postgresFixture(options = {}) {
|
|
|
80
132
|
envFiles,
|
|
81
133
|
local: false,
|
|
82
134
|
database: postgresDatabase(
|
|
83
|
-
|
|
135
|
+
resolvedTemplate
|
|
84
136
|
? {
|
|
85
137
|
...databaseOptions,
|
|
86
|
-
template:
|
|
138
|
+
template: resolvedTemplate,
|
|
87
139
|
}
|
|
88
140
|
: databaseOptions
|
|
89
141
|
),
|
|
90
142
|
};
|
|
91
143
|
}
|
|
92
144
|
|
|
145
|
+
function hasTemplateLifecycle(options = {}) {
|
|
146
|
+
return ["inputs", "migrate", "seed", "verify"].some((key) =>
|
|
147
|
+
Object.prototype.hasOwnProperty.call(options, key)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
93
151
|
function nodeToolchain(options = {}) {
|
|
94
152
|
return {
|
|
95
153
|
kind: "node",
|
|
@@ -190,6 +248,8 @@ function nodeApp(options = {}) {
|
|
|
190
248
|
|
|
191
249
|
function nextApp(options = {}) {
|
|
192
250
|
const {
|
|
251
|
+
api,
|
|
252
|
+
auth: authMode,
|
|
193
253
|
baseUrl: explicitBaseUrl,
|
|
194
254
|
browser,
|
|
195
255
|
build: explicitBuild,
|
|
@@ -209,7 +269,10 @@ function nextApp(options = {}) {
|
|
|
209
269
|
} = options;
|
|
210
270
|
|
|
211
271
|
const normalizedPort = requiredNumber(port, "app.next port");
|
|
212
|
-
const normalizedEnv =
|
|
272
|
+
const normalizedEnv = {
|
|
273
|
+
...normalizeNextPresetEnv({ api, authMode }),
|
|
274
|
+
...normalizePresetEnv(env),
|
|
275
|
+
};
|
|
213
276
|
const baseUrl = explicitBaseUrl || "http://127.0.0.1:{port}";
|
|
214
277
|
const build =
|
|
215
278
|
explicitBuild === undefined
|
|
@@ -261,12 +324,16 @@ export const database = {
|
|
|
261
324
|
schema: {
|
|
262
325
|
fromEnv: sourceSchemaFromEnv,
|
|
263
326
|
},
|
|
327
|
+
steps: {
|
|
328
|
+
materializePostgresBinding: materializePostgresBindingStep,
|
|
329
|
+
verifySeed: verifySeedStep,
|
|
330
|
+
},
|
|
264
331
|
postgres(options = {}) {
|
|
265
|
-
const { template, sourceSchema, ...databaseOptions } = options;
|
|
266
|
-
for (const legacyKey of ["
|
|
332
|
+
const { template, sourceSchema, inputs, migrate, seed, verify, ...databaseOptions } = options;
|
|
333
|
+
for (const legacyKey of ["schema"]) {
|
|
267
334
|
if (Object.prototype.hasOwnProperty.call(options, legacyKey)) {
|
|
268
335
|
throw new Error(
|
|
269
|
-
`database.postgres(...) no longer accepts
|
|
336
|
+
`database.postgres(...) no longer accepts "${legacyKey}". Use database.postgres({ sourceSchema: database.schema.fromEnv(...) }) instead.`
|
|
270
337
|
);
|
|
271
338
|
}
|
|
272
339
|
}
|
|
@@ -276,11 +343,14 @@ export const database = {
|
|
|
276
343
|
...databaseOptions,
|
|
277
344
|
sourceSchema,
|
|
278
345
|
};
|
|
346
|
+
const resolvedTemplate = template || hasTemplateLifecycle(options)
|
|
347
|
+
? buildDatabaseTemplateConfig({ inputs, migrate, seed, verify, ...(template || {}) })
|
|
348
|
+
: undefined;
|
|
279
349
|
return postgresDatabase(
|
|
280
|
-
|
|
350
|
+
resolvedTemplate
|
|
281
351
|
? {
|
|
282
352
|
...normalizedDatabaseOptions,
|
|
283
|
-
template:
|
|
353
|
+
template: resolvedTemplate,
|
|
284
354
|
}
|
|
285
355
|
: normalizedDatabaseOptions
|
|
286
356
|
);
|
|
@@ -288,6 +358,30 @@ export const database = {
|
|
|
288
358
|
fixture: postgresFixture,
|
|
289
359
|
};
|
|
290
360
|
|
|
361
|
+
export const step = {
|
|
362
|
+
command: commandStep,
|
|
363
|
+
module: moduleStep,
|
|
364
|
+
sqlFile: sqlFileStep,
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
export const presets = {
|
|
368
|
+
nodeNext(options = {}) {
|
|
369
|
+
return {
|
|
370
|
+
execution: {
|
|
371
|
+
workers: options.workers ?? 1,
|
|
372
|
+
fileTimeoutSeconds: options.fileTimeoutSeconds ?? 120,
|
|
373
|
+
},
|
|
374
|
+
toolchains: {
|
|
375
|
+
node: nodeToolchain({
|
|
376
|
+
install: options.install ?? "download",
|
|
377
|
+
...(options.node ? { node: options.node } : {}),
|
|
378
|
+
...(options.npm ? { npm: options.npm } : {}),
|
|
379
|
+
}),
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
|
|
291
385
|
export const toolchain = {
|
|
292
386
|
node: nodeToolchain,
|
|
293
387
|
};
|
|
@@ -378,6 +472,22 @@ function normalizeDatabaseEnvToken(value, label, sanitize = true) {
|
|
|
378
472
|
return normalized;
|
|
379
473
|
}
|
|
380
474
|
|
|
475
|
+
function normalizeNextPresetEnv({ api, authMode } = {}) {
|
|
476
|
+
const env = {};
|
|
477
|
+
if (api) {
|
|
478
|
+
const baseUrlToken = typeof api === "string" ? api : api.baseUrl;
|
|
479
|
+
if (baseUrlToken) {
|
|
480
|
+
env.NEXT_PUBLIC_API_URL = baseUrlToken;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (authMode === "disabled-clerk" || authMode?.kind === "disabled-clerk") {
|
|
484
|
+
env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = "testkit_disabled";
|
|
485
|
+
env.CLERK_SECRET_KEY = "testkit_disabled";
|
|
486
|
+
env.CLERK_WEBHOOK_SECRET = "testkit_disabled";
|
|
487
|
+
}
|
|
488
|
+
return env;
|
|
489
|
+
}
|
|
490
|
+
|
|
381
491
|
function resolveNodeAppStart(build, entry) {
|
|
382
492
|
if (build?.kind === "tsc") {
|
|
383
493
|
const compiled = compiledEntryFromSource(entry || build.entry || "src/index.ts", build.outDir || "dist");
|
|
@@ -388,7 +498,8 @@ function resolveNodeAppStart(build, entry) {
|
|
|
388
498
|
|
|
389
499
|
function normalizeTemplateStepList(value) {
|
|
390
500
|
if (value == null) return [];
|
|
391
|
-
|
|
501
|
+
const values = Array.isArray(value) ? value : [value];
|
|
502
|
+
return values.map((entry) => typeof entry === "string" ? commandStep(entry) : entry);
|
|
392
503
|
}
|
|
393
504
|
|
|
394
505
|
function compiledEntryFromSource(entry, outDir) {
|
|
@@ -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 {
|