@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.
Files changed (37) hide show
  1. package/README.md +27 -12
  2. package/lib/app/doctor.mjs +11 -113
  3. package/lib/cli/assistant/command-observer.mjs +1 -1
  4. package/lib/cli/assistant/context-pack.mjs +31 -11
  5. package/lib/cli/assistant/state.mjs +2 -0
  6. package/lib/cli/commands/lint.mjs +37 -0
  7. package/lib/cli/entrypoint.mjs +1 -0
  8. package/lib/cli/operations/db/schema/refresh/operation.mjs +4 -2
  9. package/lib/cli/operations/lint/operation.mjs +12 -0
  10. package/lib/cli/renderers/db-schema/text.mjs +3 -0
  11. package/lib/cli/renderers/doctor/text.mjs +5 -0
  12. package/lib/cli/renderers/lint/text.mjs +20 -0
  13. package/lib/config/database.mjs +9 -13
  14. package/lib/config-api/database-steps.mjs +132 -0
  15. package/lib/config-api/index.d.ts +37 -5
  16. package/lib/config-api/index.mjs +123 -12
  17. package/lib/database/fingerprint.mjs +2 -2
  18. package/lib/database/index.mjs +4 -4
  19. package/lib/database/schema-source.mjs +107 -14
  20. package/lib/lint/index.mjs +569 -0
  21. package/lib/repo/state.mjs +164 -0
  22. package/lib/runner/metadata.mjs +11 -24
  23. package/lib/runner/template-steps.mjs +8 -0
  24. package/lib/runner/template.mjs +0 -3
  25. package/lib/runtime/index.d.ts +43 -0
  26. package/lib/runtime/index.mjs +24 -0
  27. package/lib/runtime-src/k6/http-assertions.js +82 -0
  28. package/lib/shared/configured-steps.mjs +16 -0
  29. package/lib/ui/index.d.ts +46 -0
  30. package/lib/ui/index.mjs +11 -0
  31. package/lib/ui/sandbox.mjs +115 -0
  32. package/node_modules/@elench/next-analysis/package.json +1 -1
  33. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  34. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  35. package/node_modules/@elench/ts-analysis/package.json +1 -1
  36. package/package.json +6 -5
  37. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +1 -1
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` refreshes
329
- `.testkit/db/<service>/source-schema.sql` from the source database when needed,
330
- applies that cached schema to the local template DB, runs local template setup,
331
- and verifies that the replayed local schema still matches the source dump. If
332
- local replay differs, Testkit refreshes from the source once and retries. If it
333
- still differs, the run fails with schema diagnostics under
334
- `.testkit/results/schema`.
328
+ source database as the schema source of truth. A normal `testkit run` resolves a
329
+ commit-aware source schema cache under
330
+ `.testkit/db/<service>/source-schemas/`, applies that cached schema to the local
331
+ template DB, runs local template setup, and verifies that the replayed local
332
+ schema still matches the source dump. If local replay differs, Testkit refreshes
333
+ from the source once for the current cache key and retries. If it still differs,
334
+ the run fails with schema diagnostics under `.testkit/results/schema`.
335
+
336
+ Source schema cache keys are derived automatically from repo state:
337
+
338
+ - clean git worktrees use `commits/<sha>`
339
+ - dirty git worktrees use `dirty/<sha>-<fingerprint>`
340
+ - non-git directories use `nogit/<fingerprint>`
341
+
342
+ Branch names and worktree paths are recorded as metadata but do not affect clean
343
+ commit cache keys, so branch renames and clean worktrees at the same commit
344
+ reuse the same source schema. Dirty worktrees are isolated by content
345
+ fingerprint so local experiments cannot overwrite a clean commit baseline.
335
346
 
336
347
  Template setup executes in three explicit phases:
337
348
 
@@ -349,11 +360,12 @@ exiting cannot be refreshed at the midpoint.
349
360
  Source schema refreshes are intentionally single-connection and pooler-safe.
350
361
  If a Neon pooled source URL is configured, Testkit rewrites it to the matching
351
362
  direct Neon endpoint before running `pg_dump` and records the original/resolved
352
- host classifications in `.testkit/db/<service>/source-schema.meta.json`. Unknown
353
- PgBouncer/pooler URLs fail closed; configure a direct source URL for those
354
- providers. Concurrent refreshes for the same service are serialized with a
355
- cache-local lock so multiple Testkit processes do not stampede the source
356
- database.
363
+ host classifications beside the resolved cache entry. Unknown PgBouncer/pooler
364
+ URLs fail closed; configure a direct source URL for those providers. Concurrent
365
+ refreshes for the same service and cache key are serialized with a cache-local
366
+ lock so multiple Testkit processes do not stampede the source database. Testkit
367
+ also maintains `.testkit/db/<service>/source-schemas/index.json` and prunes old
368
+ inactive cache entries automatically.
357
369
 
358
370
  For most repos, prefer declarative step objects directly inside
359
371
  `database.postgres({ template: ... })` and `runtime.prepare.steps`.
@@ -695,6 +707,8 @@ services that define `database: database.postgres(...)`.
695
707
  - runtime databases are cloned from templates when binding is `per-runtime`
696
708
  - shared databases are reused when binding is `shared`
697
709
  - source schema caches are refreshed only from the configured source database
710
+ - clean commits, dirty worktrees, and non-git directories get separate source
711
+ schema cache entries automatically
698
712
  - template fingerprints are derived automatically from env files, source schema
699
713
  cache, migrate/seed config, and repo contents
700
714
 
@@ -711,6 +725,7 @@ npm test
711
725
  npm run test:unit
712
726
  npm run test:integration
713
727
  npm run test:system
728
+ npm run test:live:github
714
729
  npm run test:live:neon
715
730
  npm run test:database-version:compat
716
731
  ```
@@ -1,16 +1,14 @@
1
- import fs from "fs";
2
1
  import path from "path";
3
- import ts from "typescript";
4
2
  import { discoverTests } from "../discovery/index.mjs";
5
3
  import { loadConfigContext } from "../config/index.mjs";
6
4
  import { runTestkitTypecheck } from "./typecheck.mjs";
7
- import { findConfigFile } from "../config/config-loader.mjs";
5
+ import { runLint } from "../lint/index.mjs";
8
6
 
9
7
  export async function runDoctor(options = {}) {
10
8
  const checks = [];
11
9
  const productDir = options.dir ? path.resolve(process.cwd(), options.dir) : process.cwd();
12
10
 
13
- await loadConfigContext({ dir: productDir });
11
+ const context = await loadConfigContext({ dir: productDir });
14
12
  checks.push({
15
13
  code: "config-load",
16
14
  level: "pass",
@@ -29,26 +27,18 @@ export async function runDoctor(options = {}) {
29
27
  details: discoveryErrors,
30
28
  });
31
29
 
32
- const playwrightViolations = findPlaywrightRuntimeImportViolations(productDir);
33
- checks.push({
34
- code: "ui-runtime-imports",
35
- level: playwrightViolations.length === 0 ? "pass" : "fail",
36
- message:
37
- playwrightViolations.length === 0
38
- ? "No runtime @playwright/test imports found in testkit UI suites"
39
- : `Found ${playwrightViolations.length} UI runtime import violation(s); import from @elench/testkit/ui instead`,
40
- details: playwrightViolations,
30
+ const lint = await runLint({
31
+ dir: productDir,
32
+ ...(context.config?.lint || {}),
41
33
  });
42
-
43
- const configImportViolations = findConfigImportViolations(productDir);
44
34
  checks.push({
45
- code: "config-import-hygiene",
46
- level: configImportViolations.length === 0 ? "pass" : "fail",
35
+ code: "lint",
36
+ level: lint.ok ? "pass" : "fail",
47
37
  message:
48
- configImportViolations.length === 0
49
- ? "Repo config does not import __testkit__ helper modules"
50
- : `Found ${configImportViolations.length} repo config import violation(s)`,
51
- details: configImportViolations,
38
+ lint.ok
39
+ ? `No Testkit lint violations across ${lint.summary.testkitFiles} suite file(s)`
40
+ : `Found ${lint.summary.violations} Testkit lint violation(s)`,
41
+ details: lint.violations,
52
42
  });
53
43
 
54
44
  const hasBrowserOrNextWork = discovery.files.some((entry) => entry.type === "ui");
@@ -88,95 +78,3 @@ export async function runDoctor(options = {}) {
88
78
  checks,
89
79
  };
90
80
  }
91
-
92
- function findPlaywrightRuntimeImportViolations(productDir) {
93
- const violations = [];
94
- for (const absolutePath of collectFiles(productDir)) {
95
- const sourceText = fs.readFileSync(absolutePath, "utf8");
96
- const sourceFile = ts.createSourceFile(absolutePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
97
-
98
- for (const statement of sourceFile.statements) {
99
- if (!ts.isImportDeclaration(statement)) continue;
100
- if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
101
- if (statement.moduleSpecifier.text !== "@playwright/test") continue;
102
- const clause = statement.importClause;
103
- if (clause?.isTypeOnly) continue;
104
- if (!clause) {
105
- violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
106
- continue;
107
- }
108
- if (clause.name) {
109
- violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
110
- continue;
111
- }
112
- if (!clause.namedBindings) {
113
- violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
114
- continue;
115
- }
116
- if (ts.isNamespaceImport(clause.namedBindings)) {
117
- violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
118
- continue;
119
- }
120
- if (clause.namedBindings.elements.some((entry) => !entry.isTypeOnly)) {
121
- violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
122
- }
123
- }
124
- }
125
- return violations;
126
- }
127
-
128
- function findConfigImportViolations(productDir) {
129
- const configFile = findConfigFile(productDir);
130
- if (!configFile || !fs.existsSync(configFile)) return [];
131
-
132
- const sourceText = fs.readFileSync(configFile, "utf8");
133
- const sourceFile = ts.createSourceFile(configFile, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
134
- const violations = [];
135
-
136
- for (const statement of sourceFile.statements) {
137
- if (!ts.isImportDeclaration(statement)) continue;
138
- if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
139
- const specifier = statement.moduleSpecifier.text;
140
- if (!isRepoLocalConfigImportViolation(specifier)) continue;
141
- const position = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile));
142
- violations.push({
143
- file: path.relative(productDir, configFile).split(path.sep).join("/"),
144
- line: position.line + 1,
145
- specifier,
146
- snippet: statement.getText(sourceFile),
147
- });
148
- }
149
-
150
- return violations;
151
- }
152
-
153
- function collectFiles(rootDir, out = []) {
154
- if (!fs.existsSync(rootDir)) return out;
155
- for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
156
- const absolutePath = path.join(rootDir, entry.name);
157
- if (entry.isDirectory()) {
158
- if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".testkit") continue;
159
- collectFiles(absolutePath, out);
160
- continue;
161
- }
162
- if (entry.isFile() && (entry.name.endsWith(".ui.testkit.ts") || entry.name.endsWith(".ui.testkit.ts"))) {
163
- out.push(absolutePath);
164
- }
165
- }
166
- return out.sort((left, right) => left.localeCompare(right));
167
- }
168
-
169
- function isRepoLocalConfigImportViolation(specifier) {
170
- if (typeof specifier !== "string") return false;
171
- if (!specifier.startsWith(".") && !specifier.startsWith("/")) return false;
172
- return specifier.includes("__testkit__");
173
- }
174
-
175
- function relativeViolation(productDir, absolutePath, sourceFile, statement) {
176
- const position = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile));
177
- return {
178
- file: path.relative(productDir, absolutePath).split(path.sep).join("/"),
179
- line: position.line + 1,
180
- snippet: statement.getText(sourceFile),
181
- };
182
- }
@@ -3,7 +3,7 @@ import path from "path";
3
3
  import { isAssistantRunCommand } from "./command-classifier.mjs";
4
4
 
5
5
  const POLL_INTERVAL_MS = 150;
6
- const OBSERVED_KINDS = new Set(["run", "discover", "status", "doctor", "typecheck"]);
6
+ const OBSERVED_KINDS = new Set(["run", "discover", "status", "doctor", "lint", "typecheck"]);
7
7
 
8
8
  export function createAssistantCommandObserver({
9
9
  productDir,
@@ -1,6 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { fileURLToPath, pathToFileURL } from "url";
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 resolveClassifierUrl() {
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
- import { spawnSync } from "child_process";
175
- import fs from "fs";
176
- import path from "path";
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
 
@@ -10,6 +10,7 @@ import { createRunState } from "../state/run/state.mjs";
10
10
  import { buildContextSelection } from "../../results/context.mjs";
11
11
  import { renderDiscoverResult } from "../renderers/discover/text.mjs";
12
12
  import { renderDoctorResult } from "../renderers/doctor/text.mjs";
13
+ import { renderLintResult } from "../renderers/lint/text.mjs";
13
14
  import { renderStatusResult } from "../renderers/status/text.mjs";
14
15
  import { renderTypecheckResult } from "../renderers/typecheck/text.mjs";
15
16
  import { isProviderInstalled } from "./providers/index.mjs";
@@ -1092,6 +1093,7 @@ function renderObservedCommandResult(command) {
1092
1093
  return normalizeRenderedLines((result.results || []).flatMap((entry) => renderStatusResult(entry)));
1093
1094
  }
1094
1095
  if (command.kind === "doctor") return normalizeRenderedLines(renderDoctorResult(result));
1096
+ if (command.kind === "lint") return normalizeRenderedLines(renderLintResult(result));
1095
1097
  if (command.kind === "typecheck") return normalizeRenderedLines(renderTypecheckResult(result));
1096
1098
  return [];
1097
1099
  }
@@ -0,0 +1,37 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import { executeLintOperation } from "../operations/lint/operation.mjs";
3
+ import { renderLintResult } from "../renderers/lint/text.mjs";
4
+ import { withAssistantCommandResult } from "../assistant/command-results.mjs";
5
+
6
+ export default class LintCommand extends Command {
7
+ static summary = "Run built-in Testkit repository hygiene checks";
8
+
9
+ static enableJsonFlag = true;
10
+
11
+ static flags = {
12
+ dir: Flags.string({
13
+ description: "Product directory",
14
+ }),
15
+ };
16
+
17
+ async run() {
18
+ return withAssistantCommandResult("lint", async () => {
19
+ const { flags } = await this.parse(LintCommand);
20
+ const result = await executeLintOperation(flags);
21
+
22
+ if (!this.jsonEnabled()) {
23
+ for (const line of renderLintResult(result)) {
24
+ this.log(line);
25
+ }
26
+ }
27
+
28
+ if (!result.ok) {
29
+ const error = new Error("testkit lint failed");
30
+ error.result = result;
31
+ throw error;
32
+ }
33
+
34
+ return result;
35
+ });
36
+ }
37
+ }
@@ -13,6 +13,7 @@ export function normalizeCliArgs(argv) {
13
13
  "discover",
14
14
  "typecheck",
15
15
  "doctor",
16
+ "lint",
16
17
  "browser",
17
18
  "db",
18
19
  ]);
@@ -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, getSourceSchemaCachePath } from "../../../../../database/schema-source.mjs";
7
+ import { forceRefreshSourceSchemaCache } from "../../../../../database/schema-source.mjs";
8
8
  import { createRunReporter } from "../../../../renderers/run/text-reporter.mjs";
9
9
  import { createRunLogRegistry } from "../../../../../runner/logs.mjs";
10
10
  import { createSetupOperationRegistry } from "../../../../../runner/setup-operations.mjs";
@@ -40,13 +40,15 @@ export async function executeDatabaseSchemaRefreshOperation(options = {}) {
40
40
  logRegistry,
41
41
  setupRegistry,
42
42
  });
43
- const outputPath = getSourceSchemaCachePath(resolvedTarget);
43
+ const outputPath = state.cachePath;
44
44
  return {
45
45
  ok: true,
46
46
  productDir,
47
47
  service: target.name,
48
48
  outputPath,
49
49
  outputLabel: path.relative(productDir, outputPath) || path.basename(outputPath),
50
+ cacheKey: state.cacheKey,
51
+ cacheKind: state.cacheKind,
50
52
  envName: state.envName || null,
51
53
  sourceUrl: state.refreshInfo?.metadata?.sourceUrl || null,
52
54
  reusedExistingRefresh: Boolean(state.refreshInfo?.reusedExistingRefresh),
@@ -0,0 +1,12 @@
1
+ import { runLint } from "../../../lint/index.mjs";
2
+ import { loadTestkitConfig } from "../../../config/config-loader.mjs";
3
+ import { resolveProductDir } from "../../../config/paths.mjs";
4
+
5
+ export async function executeLintOperation(flags = {}) {
6
+ const productDir = resolveProductDir(process.cwd(), flags.dir);
7
+ const { config } = await loadTestkitConfig(productDir);
8
+ return runLint({
9
+ dir: productDir,
10
+ ...(config.lint || {}),
11
+ });
12
+ }
@@ -1,5 +1,8 @@
1
1
  export function renderDatabaseSchemaRefreshResult(result) {
2
2
  const lines = [`Refreshed ${result.outputLabel}`];
3
+ if (result.cacheKey) {
4
+ lines.push(`Cache key ${result.cacheKey}`);
5
+ }
3
6
  if (result.sourceUrl?.rewritten && result.sourceUrl.originalClassification === "neon-pooler") {
4
7
  lines.push("Source schema URL uses Neon pooler; Testkit used the direct endpoint for pg_dump.");
5
8
  }
@@ -2,6 +2,11 @@ export function renderDoctorResult(result) {
2
2
  const lines = [`testkit doctor ${result.ok ? "passed" : "failed"} for ${result.productDir}`];
3
3
  for (const check of result.checks || []) {
4
4
  lines.push(`${check.level.toUpperCase()} ${check.code} ${check.message}`);
5
+ for (const detail of (check.details || []).slice(0, 5)) {
6
+ if (detail?.ruleId) {
7
+ lines.push(` ${detail.ruleId} ${detail.file}:${detail.line} ${detail.message}`);
8
+ }
9
+ }
5
10
  }
6
11
  return lines;
7
12
  }
@@ -0,0 +1,20 @@
1
+ export function renderLintResult(result) {
2
+ const lines = [
3
+ `testkit lint ${result.ok ? "passed" : "failed"} for ${result.productDir}`,
4
+ `Checked ${result.summary.files} source file(s), ${result.summary.testkitFiles} testkit suite file(s).`,
5
+ ];
6
+
7
+ if (result.violations.length === 0) {
8
+ return lines;
9
+ }
10
+
11
+ lines.push("");
12
+ for (const violation of result.violations) {
13
+ const location = violation.line ? `${violation.file}:${violation.line}` : violation.file;
14
+ lines.push(`${location} ${violation.ruleId}: ${violation.message}`);
15
+ if (violation.snippet) {
16
+ lines.push(` ${violation.snippet}`);
17
+ }
18
+ }
19
+ return lines;
20
+ }
@@ -66,8 +66,7 @@ function normalizeSourceSchemaConfig(value, serviceName) {
66
66
  if (value === undefined) {
67
67
  return {
68
68
  kind: "auto",
69
- cachePath: null,
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 === "always") return { mode: "always" };
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
- }
@@ -0,0 +1,132 @@
1
+ import { execa } from "execa";
2
+
3
+ export async function verifySeed(context = {}) {
4
+ const args = context.args || {};
5
+ const table = requireIdentifier(args.table, "verifySeed.args.table");
6
+ const where = normalizeOptionalSql(args.where);
7
+ const minRows = normalizeNonNegativeInteger(args.minRows ?? 1, "verifySeed.args.minRows");
8
+ const databaseUrl = requireDatabaseUrl(context);
9
+ const query = `select count(*)::int from ${table}${where ? ` where ${where}` : ""}`;
10
+ const count = Number(await runScalar(databaseUrl, query, context));
11
+
12
+ if (!Number.isInteger(count) || count < minRows) {
13
+ throw new Error(`Expected at least ${minRows} row(s) in ${table}, found ${count || 0}`);
14
+ }
15
+ }
16
+
17
+ export async function materializePostgresBinding(context = {}) {
18
+ const args = context.args || {};
19
+ const table = requireIdentifier(args.table, "materializePostgresBinding.args.table");
20
+ const keyColumn = requireIdentifier(
21
+ args.keyColumn || "slug",
22
+ "materializePostgresBinding.args.keyColumn"
23
+ );
24
+ const key = normalizeRequiredValue(args.key, "materializePostgresBinding.args.key");
25
+ const values = normalizeObject(args.values, "materializePostgresBinding.args.values");
26
+ const databaseUrl = requireDatabaseUrl(context);
27
+
28
+ const columns = Object.keys(values);
29
+ if (columns.length === 0) {
30
+ throw new Error("materializePostgresBinding.args.values must contain at least one column");
31
+ }
32
+
33
+ const assignments = columns.map((column) => {
34
+ const identifier = requireIdentifier(column, `materializePostgresBinding.args.values.${column}`);
35
+ return `${identifier} = ${toSqlLiteral(values[column])}`;
36
+ });
37
+ const query =
38
+ `with updated as (update ${table} set ${assignments.join(", ")} ` +
39
+ `where ${keyColumn} = ${toSqlLiteral(key)} returning 1) select count(*)::int from updated`;
40
+ const result = await runPsql(databaseUrl, query, context);
41
+ const updated = Number(String(result.stdout || "").trim() || "0");
42
+ if (updated !== 1) {
43
+ throw new Error(`Expected to materialize 1 ${table} row for ${keyColumn}=${key}, updated ${updated || 0}`);
44
+ }
45
+ }
46
+
47
+ function requireDatabaseUrl(context) {
48
+ const databaseUrl = String(context.databaseUrl || context.env?.DATABASE_URL || "").trim();
49
+ if (!databaseUrl) {
50
+ throw new Error("Database template step requires DATABASE_URL");
51
+ }
52
+ return databaseUrl;
53
+ }
54
+
55
+ async function runScalar(databaseUrl, query, context) {
56
+ const result = await runPsql(databaseUrl, query, context);
57
+ return String(result.stdout || "").trim();
58
+ }
59
+
60
+ async function runPsql(databaseUrl, query, context) {
61
+ const result = await execa(
62
+ "psql",
63
+ [
64
+ databaseUrl,
65
+ "-v",
66
+ "ON_ERROR_STOP=1",
67
+ "-X",
68
+ "-q",
69
+ "-t",
70
+ "-A",
71
+ "-c",
72
+ query,
73
+ ],
74
+ {
75
+ cwd: context.cwd || context.productDir || process.cwd(),
76
+ env: context.env || process.env,
77
+ stdout: "pipe",
78
+ stderr: "pipe",
79
+ reject: false,
80
+ }
81
+ );
82
+ if (result.exitCode !== 0) {
83
+ throw new Error(result.stderr || result.shortMessage || `psql failed with exit code ${result.exitCode}`);
84
+ }
85
+ return result;
86
+ }
87
+
88
+ function requireIdentifier(value, label) {
89
+ const normalized = String(value || "").trim();
90
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(normalized)) {
91
+ throw new Error(`${label} must be a SQL identifier or schema-qualified identifier`);
92
+ }
93
+ return normalized;
94
+ }
95
+
96
+ function normalizeOptionalSql(value) {
97
+ if (value == null || value === "") return "";
98
+ const normalized = String(value).trim();
99
+ if (!normalized || /;|--|\/\*/.test(normalized)) {
100
+ throw new Error("verifySeed.args.where must be a single SQL predicate without comments or semicolons");
101
+ }
102
+ return normalized;
103
+ }
104
+
105
+ function normalizeNonNegativeInteger(value, label) {
106
+ const normalized = Number(value);
107
+ if (!Number.isInteger(normalized) || normalized < 0) {
108
+ throw new Error(`${label} must be a non-negative integer`);
109
+ }
110
+ return normalized;
111
+ }
112
+
113
+ function normalizeObject(value, label) {
114
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
115
+ throw new Error(`${label} must be an object`);
116
+ }
117
+ return value;
118
+ }
119
+
120
+ function normalizeRequiredValue(value, label) {
121
+ if (value == null || String(value).length === 0) {
122
+ throw new Error(`${label} is required`);
123
+ }
124
+ return value;
125
+ }
126
+
127
+ function toSqlLiteral(value) {
128
+ if (value === null) return "null";
129
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
130
+ if (typeof value === "boolean") return value ? "true" : "false";
131
+ return `'${String(value).replaceAll("'", "''")}'`;
132
+ }