@elench/testkit 0.1.118 → 0.1.120

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/lib/app/doctor.mjs +11 -113
  2. package/lib/cli/assistant/command-observer.mjs +1 -1
  3. package/lib/cli/assistant/state.mjs +2 -0
  4. package/lib/cli/commands/lint.mjs +41 -0
  5. package/lib/cli/entrypoint.mjs +1 -0
  6. package/lib/cli/operations/lint/operation.mjs +20 -0
  7. package/lib/cli/renderers/doctor/text.mjs +5 -0
  8. package/lib/cli/renderers/lint/text.mjs +20 -0
  9. package/lib/config/index.mjs +1 -0
  10. package/lib/config-api/database-steps.mjs +340 -0
  11. package/lib/config-api/index.d.ts +126 -3
  12. package/lib/config-api/index.mjs +176 -12
  13. package/lib/lint/index.mjs +689 -0
  14. package/lib/runner/template-steps.mjs +8 -0
  15. package/lib/runner/template.mjs +13 -0
  16. package/lib/runtime/index.d.ts +43 -0
  17. package/lib/runtime/index.mjs +24 -0
  18. package/lib/runtime-src/k6/http-assertions.js +82 -0
  19. package/lib/shared/configured-steps.mjs +16 -0
  20. package/lib/ui/index.d.ts +118 -0
  21. package/lib/ui/index.mjs +21 -0
  22. package/lib/ui/provisioning.mjs +283 -0
  23. package/lib/ui/sandbox.mjs +250 -0
  24. package/node_modules/@elench/next-analysis/package.json +1 -1
  25. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  26. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  27. package/node_modules/@elench/ts-analysis/package.json +1 -1
  28. package/node_modules/es-toolkit/CHANGELOG.md +801 -0
  29. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
  30. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
  31. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
  32. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
  33. package/node_modules/esprima/ChangeLog +235 -0
  34. package/package.json +5 -5
@@ -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,
@@ -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,41 @@
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
+ "testkit-boundary": Flags.boolean({
16
+ description: "Fail when tracked Testkit plumbing exists outside testkit.config.* or __testkit__/ paths",
17
+ default: false,
18
+ }),
19
+ };
20
+
21
+ async run() {
22
+ return withAssistantCommandResult("lint", async () => {
23
+ const { flags } = await this.parse(LintCommand);
24
+ const result = await executeLintOperation(flags);
25
+
26
+ if (!this.jsonEnabled()) {
27
+ for (const line of renderLintResult(result)) {
28
+ this.log(line);
29
+ }
30
+ }
31
+
32
+ if (!result.ok) {
33
+ const error = new Error("testkit lint failed");
34
+ error.result = result;
35
+ throw error;
36
+ }
37
+
38
+ return result;
39
+ });
40
+ }
41
+ }
@@ -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
  ]);
@@ -0,0 +1,20 @@
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
+ const cliLint = flags["testkit-boundary"]
9
+ ? { rules: { testkitBoundary: true } }
10
+ : {};
11
+ return runLint({
12
+ dir: productDir,
13
+ ...(config.lint || {}),
14
+ ...cliLint,
15
+ rules: {
16
+ ...(config.lint?.rules || {}),
17
+ ...(cliLint.rules || {}),
18
+ },
19
+ });
20
+ }
@@ -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
+ }
@@ -167,6 +167,7 @@ function normalizeServiceConfig({
167
167
  skip,
168
168
  runtime,
169
169
  browser,
170
+ ui: config.ui || null,
170
171
  fileMetadataByPath: serviceFileMetadata,
171
172
  local,
172
173
  },
@@ -0,0 +1,340 @@
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 verifyRows(context = {}) {
18
+ const args = context.args || {};
19
+ const checks = Array.isArray(args.checks) ? args.checks : [args];
20
+ const databaseUrl = requireDatabaseUrl(context);
21
+ const failures = [];
22
+
23
+ for (const [index, check] of checks.entries()) {
24
+ const label = `verifyRows.args.checks[${index}]`;
25
+ const table = requireIdentifier(check.table, `${label}.table`);
26
+ const where = normalizeOptionalSql(check.where);
27
+ const minRows = normalizeNonNegativeInteger(check.minRows ?? 1, `${label}.minRows`);
28
+ const severity = check.severity === "warn" ? "warn" : "error";
29
+ const query = `select count(*)::int from ${table}${where ? ` where ${where}` : ""}`;
30
+ const count = Number(await runScalar(databaseUrl, query, context));
31
+
32
+ if (!Number.isInteger(count) || count < minRows) {
33
+ const message = `Expected at least ${minRows} row(s) in ${table}, found ${count || 0}`;
34
+ if (severity === "warn") {
35
+ console.warn(message);
36
+ } else {
37
+ failures.push(message);
38
+ }
39
+ }
40
+ }
41
+
42
+ if (failures.length > 0) {
43
+ throw new Error(`Database row verification failed:\n${failures.map((failure) => `- ${failure}`).join("\n")}`);
44
+ }
45
+ }
46
+
47
+ export async function runSql(context = {}) {
48
+ const args = context.args || {};
49
+ const statements = normalizeStatementList(args.statements ?? args.statement, "runSql.args.statements");
50
+ const databaseUrl = requireDatabaseUrl(context);
51
+
52
+ for (const statement of statements) {
53
+ await runPsql(databaseUrl, statement, context);
54
+ }
55
+ }
56
+
57
+ export async function seedRows(context = {}) {
58
+ const args = context.args || {};
59
+ const table = requireIdentifier(args.table, "seedRows.args.table");
60
+ const rows = normalizeRowList(args.rows, "seedRows.args.rows");
61
+ const databaseUrl = requireDatabaseUrl(context);
62
+
63
+ if (args.truncate === true) {
64
+ await runPsql(databaseUrl, `truncate table ${table}`, context);
65
+ }
66
+
67
+ if (rows.length === 0) return;
68
+
69
+ const columns = normalizeSeedColumns(args.columns, rows);
70
+ const columnSql = columns.map((column) => requireIdentifier(column, `seedRows.args.columns.${column}`));
71
+ const valueGroups = rows.map((row) =>
72
+ `(${columns.map((column) => toSqlExpression(row[column], context)).join(", ")})`
73
+ );
74
+ await runPsql(
75
+ databaseUrl,
76
+ `insert into ${table} (${columnSql.join(", ")}) values ${valueGroups.join(", ")}`,
77
+ context
78
+ );
79
+ }
80
+
81
+ export async function materializePostgresBinding(context = {}) {
82
+ const args = context.args || {};
83
+ const table = requireIdentifier(args.table, "materializePostgresBinding.args.table");
84
+ const keyColumn = requireIdentifier(
85
+ args.keyColumn || "slug",
86
+ "materializePostgresBinding.args.keyColumn"
87
+ );
88
+ const key = normalizeRequiredValue(args.key, "materializePostgresBinding.args.key");
89
+ const values = normalizeObject(args.values, "materializePostgresBinding.args.values");
90
+ const databaseUrl = requireDatabaseUrl(context);
91
+
92
+ const columns = Object.keys(values);
93
+ if (columns.length === 0) {
94
+ throw new Error("materializePostgresBinding.args.values must contain at least one column");
95
+ }
96
+
97
+ const assignments = columns.map((column) => {
98
+ const identifier = requireIdentifier(column, `materializePostgresBinding.args.values.${column}`);
99
+ return `${identifier} = ${toSqlLiteral(values[column])}`;
100
+ });
101
+ const query =
102
+ `with updated as (update ${table} set ${assignments.join(", ")} ` +
103
+ `where ${keyColumn} = ${toSqlLiteral(key)} returning 1) select count(*)::int from updated`;
104
+ const result = await runPsql(databaseUrl, query, context);
105
+ const updated = Number(String(result.stdout || "").trim() || "0");
106
+ if (updated !== 1) {
107
+ throw new Error(`Expected to materialize 1 ${table} row for ${keyColumn}=${key}, updated ${updated || 0}`);
108
+ }
109
+ }
110
+
111
+ export async function updateRows(context = {}) {
112
+ const args = context.args || {};
113
+ const table = requireIdentifier(args.table, "updateRows.args.table");
114
+ const where = normalizeWhereObject(args.where, "updateRows.args.where");
115
+ const values = normalizeObject(args.values ?? args.set, "updateRows.args.values");
116
+ const databaseUrl = requireDatabaseUrl(context);
117
+ const expectRows = args.expectRows == null
118
+ ? null
119
+ : normalizeNonNegativeInteger(args.expectRows, "updateRows.args.expectRows");
120
+
121
+ const assignments = Object.keys(values).map((column) => {
122
+ const identifier = requireIdentifier(column, `updateRows.args.values.${column}`);
123
+ return `${identifier} = ${toSqlExpression(values[column], context)}`;
124
+ });
125
+ if (assignments.length === 0) {
126
+ throw new Error("updateRows.args.values must contain at least one column");
127
+ }
128
+
129
+ const query =
130
+ `with updated as (update ${table} set ${assignments.join(", ")} ` +
131
+ `where ${buildWhereClause(where, context)} returning 1) select count(*)::int from updated`;
132
+ const result = await runPsql(databaseUrl, query, context);
133
+ const updated = Number(String(result.stdout || "").trim() || "0");
134
+ if (expectRows !== null && updated !== expectRows) {
135
+ throw new Error(`Expected to update ${expectRows} ${table} row(s), updated ${updated || 0}`);
136
+ }
137
+ }
138
+
139
+ function requireDatabaseUrl(context) {
140
+ const databaseUrl = String(context.databaseUrl || context.env?.DATABASE_URL || "").trim();
141
+ if (!databaseUrl) {
142
+ throw new Error("Database template step requires DATABASE_URL");
143
+ }
144
+ return databaseUrl;
145
+ }
146
+
147
+ async function runScalar(databaseUrl, query, context) {
148
+ const result = await runPsql(databaseUrl, query, context);
149
+ return String(result.stdout || "").trim();
150
+ }
151
+
152
+ async function runPsql(databaseUrl, query, context) {
153
+ const result = await execa(
154
+ "psql",
155
+ [
156
+ databaseUrl,
157
+ "-v",
158
+ "ON_ERROR_STOP=1",
159
+ "-X",
160
+ "-q",
161
+ "-t",
162
+ "-A",
163
+ "-c",
164
+ query,
165
+ ],
166
+ {
167
+ cwd: context.cwd || context.productDir || process.cwd(),
168
+ env: context.env || process.env,
169
+ stdout: "pipe",
170
+ stderr: "pipe",
171
+ reject: false,
172
+ }
173
+ );
174
+ if (result.exitCode !== 0) {
175
+ throw new Error(result.stderr || result.shortMessage || `psql failed with exit code ${result.exitCode}`);
176
+ }
177
+ return result;
178
+ }
179
+
180
+ function normalizeStatementList(value, label) {
181
+ const values = Array.isArray(value) ? value : value == null ? [] : [value];
182
+ if (values.length === 0) {
183
+ throw new Error(`${label} must contain at least one SQL statement`);
184
+ }
185
+ return values.map((statement, index) => {
186
+ const normalized = String(statement || "").trim();
187
+ if (!normalized || /;\s*\S/.test(normalized)) {
188
+ throw new Error(`${label}[${index}] must be one SQL statement`);
189
+ }
190
+ return normalized;
191
+ });
192
+ }
193
+
194
+ function normalizeRowList(value, label) {
195
+ if (!Array.isArray(value)) {
196
+ throw new Error(`${label} must be an array`);
197
+ }
198
+ return value.map((row, index) => normalizeObject(row, `${label}[${index}]`));
199
+ }
200
+
201
+ function normalizeSeedColumns(value, rows) {
202
+ if (value != null) {
203
+ if (!Array.isArray(value) || value.length === 0) {
204
+ throw new Error("seedRows.args.columns must be a non-empty array");
205
+ }
206
+ return value.map((column) => String(column));
207
+ }
208
+ return [...new Set(rows.flatMap((row) => Object.keys(row)))].sort((left, right) => left.localeCompare(right));
209
+ }
210
+
211
+ function normalizeWhereObject(value, label) {
212
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
213
+ throw new Error(`${label} must be an object`);
214
+ }
215
+ const entries = Object.entries(value);
216
+ if (entries.length === 0) {
217
+ throw new Error(`${label} must contain at least one column`);
218
+ }
219
+ return value;
220
+ }
221
+
222
+ function buildWhereClause(where, context) {
223
+ return Object.entries(where)
224
+ .map(([column, value]) => `${requireIdentifier(column, `where.${column}`)} = ${toSqlExpression(value, context)}`)
225
+ .join(" and ");
226
+ }
227
+
228
+ function requireIdentifier(value, label) {
229
+ const normalized = String(value || "").trim();
230
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(normalized)) {
231
+ throw new Error(`${label} must be a SQL identifier or schema-qualified identifier`);
232
+ }
233
+ return normalized;
234
+ }
235
+
236
+ function normalizeOptionalSql(value) {
237
+ if (value == null || value === "") return "";
238
+ const normalized = String(value).trim();
239
+ if (!normalized || /;|--|\/\*/.test(normalized)) {
240
+ throw new Error("verifySeed.args.where must be a single SQL predicate without comments or semicolons");
241
+ }
242
+ return normalized;
243
+ }
244
+
245
+ function normalizeNonNegativeInteger(value, label) {
246
+ const normalized = Number(value);
247
+ if (!Number.isInteger(normalized) || normalized < 0) {
248
+ throw new Error(`${label} must be a non-negative integer`);
249
+ }
250
+ return normalized;
251
+ }
252
+
253
+ function normalizeObject(value, label) {
254
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
255
+ throw new Error(`${label} must be an object`);
256
+ }
257
+ return value;
258
+ }
259
+
260
+ function normalizeRequiredValue(value, label) {
261
+ if (value == null || String(value).length === 0) {
262
+ throw new Error(`${label} is required`);
263
+ }
264
+ return value;
265
+ }
266
+
267
+ function toSqlLiteral(value) {
268
+ if (value === null) return "null";
269
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
270
+ if (typeof value === "boolean") return value ? "true" : "false";
271
+ return `'${String(value).replaceAll("'", "''")}'`;
272
+ }
273
+
274
+ function toSqlExpression(value, context) {
275
+ if (!isValueDescriptor(value)) return toSqlLiteral(value);
276
+ switch (value.kind) {
277
+ case "now":
278
+ return "now()";
279
+ case "env":
280
+ return toSqlLiteral(readRequiredEnv(context.env || process.env, value.name));
281
+ case "json":
282
+ return `${toSqlLiteral(JSON.stringify(resolveJsonValue(value.value, context)))}::jsonb`;
283
+ case "postgres-connection-from-env":
284
+ return `${toSqlLiteral(JSON.stringify(buildPostgresConnectionFromEnv(context.env || process.env, value.prefix)))}::jsonb`;
285
+ default:
286
+ throw new Error(`Unsupported database value descriptor "${value.kind}"`);
287
+ }
288
+ }
289
+
290
+ function isValueDescriptor(value) {
291
+ return value && typeof value === "object" && !Array.isArray(value) && typeof value.kind === "string";
292
+ }
293
+
294
+ function resolveJsonValue(value, context) {
295
+ if (Array.isArray(value)) return value.map((entry) => resolveJsonValue(entry, context));
296
+ if (!value || typeof value !== "object") return value;
297
+ if (isValueDescriptor(value)) {
298
+ if (value.kind === "env") return readRequiredEnv(context.env || process.env, value.name);
299
+ if (value.kind === "postgres-connection-from-env") {
300
+ return buildPostgresConnectionFromEnv(context.env || process.env, value.prefix);
301
+ }
302
+ }
303
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, resolveJsonValue(entry, context)]));
304
+ }
305
+
306
+ function buildPostgresConnectionFromEnv(env, prefix) {
307
+ const normalizedPrefix = String(prefix || "").trim();
308
+ if (!normalizedPrefix) {
309
+ throw new Error("postgresConnectionFromEnv requires a non-empty prefix");
310
+ }
311
+ return {
312
+ host: readRequiredEnv(env, `${normalizedPrefix}_HOST`),
313
+ port: readRequiredPort(env, `${normalizedPrefix}_PORT`),
314
+ database: readRequiredEnv(env, `${normalizedPrefix}_NAME`),
315
+ username: readRequiredEnv(env, `${normalizedPrefix}_USER`),
316
+ password: readRequiredEnv(env, `${normalizedPrefix}_PASSWORD`),
317
+ ssl: parseBooleanEnv(env[`${normalizedPrefix}_SSL`]),
318
+ };
319
+ }
320
+
321
+ function readRequiredEnv(env, name) {
322
+ const value = env?.[name];
323
+ if (value == null || String(value).trim().length === 0) {
324
+ throw new Error(`${name} environment variable is required`);
325
+ }
326
+ return String(value).trim();
327
+ }
328
+
329
+ function readRequiredPort(env, name) {
330
+ const value = Number.parseInt(readRequiredEnv(env, name), 10);
331
+ if (!Number.isInteger(value) || value <= 0) {
332
+ throw new Error(`${name} must be a positive integer`);
333
+ }
334
+ return value;
335
+ }
336
+
337
+ function parseBooleanEnv(value) {
338
+ if (!value) return false;
339
+ return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
340
+ }