@elench/testkit 0.1.116 → 0.1.117

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 CHANGED
@@ -346,6 +346,15 @@ is never written into the baseline. Keep schema-changing setup in its own step
346
346
  where possible; a single command that changes schema and then fails before
347
347
  exiting cannot be refreshed at the midpoint.
348
348
 
349
+ Source schema refreshes are intentionally single-connection and pooler-safe.
350
+ If a Neon pooled source URL is configured, Testkit rewrites it to the matching
351
+ 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.
357
+
349
358
  For most repos, prefer declarative step objects directly inside
350
359
  `database.postgres({ template: ... })` and `runtime.prepare.steps`.
351
360
  The supported shapes are:
@@ -48,6 +48,8 @@ export async function executeDatabaseSchemaRefreshOperation(options = {}) {
48
48
  outputPath,
49
49
  outputLabel: path.relative(productDir, outputPath) || path.basename(outputPath),
50
50
  envName: state.envName || null,
51
+ sourceUrl: state.refreshInfo?.metadata?.sourceUrl || null,
52
+ reusedExistingRefresh: Boolean(state.refreshInfo?.reusedExistingRefresh),
51
53
  };
52
54
  } finally {
53
55
  logRegistry.closeAll();
@@ -1,5 +1,12 @@
1
1
  export function renderDatabaseSchemaRefreshResult(result) {
2
- return [`Refreshed ${result.outputLabel}`];
2
+ const lines = [`Refreshed ${result.outputLabel}`];
3
+ if (result.sourceUrl?.rewritten && result.sourceUrl.originalClassification === "neon-pooler") {
4
+ lines.push("Source schema URL uses Neon pooler; Testkit used the direct endpoint for pg_dump.");
5
+ }
6
+ if (result.reusedExistingRefresh) {
7
+ lines.push("Another Testkit process refreshed this source schema cache first; reused that result.");
8
+ }
9
+ return lines;
3
10
  }
4
11
 
5
12
  export function renderDatabaseSchemaVerifyResult(result) {
@@ -5,6 +5,8 @@ import { execa } from "execa";
5
5
  import { resolveServiceCwd } from "../config/paths.mjs";
6
6
  import { buildExecutionEnv } from "../runner/template.mjs";
7
7
  import { dumpPostgresSchemaToFile, captureTemplateSnapshotText, runTemplateStep, sanitizeSnapshotText } from "./template-steps.mjs";
8
+ import { getSourceSchemaRefreshLockPath, withSourceSchemaRefreshLock } from "./source-refresh-lock.mjs";
9
+ import { resolveSourceSchemaDumpUrl } from "./source-url.mjs";
8
10
 
9
11
  const SOURCE_SCHEMA_DIR = path.join(".testkit", "db");
10
12
  const SOURCE_SCHEMA_FILE = "source-schema.sql";
@@ -57,8 +59,8 @@ export async function prepareSourceSchemaCache(config, options = {}) {
57
59
 
58
60
  if (state.sourceUrl) {
59
61
  if (shouldRefreshSourceSchema(state)) {
60
- await refreshSourceSchemaCache(config, state, options);
61
- return { ...state, refreshed: true, cacheExists: true };
62
+ const refreshInfo = await refreshSourceSchemaCache(config, state, options);
63
+ return { ...state, refreshed: true, cacheExists: true, refreshInfo };
62
64
  }
63
65
  options.setupRegistry?.recordCached({
64
66
  config,
@@ -93,8 +95,8 @@ export async function forceRefreshSourceSchemaCache(config, previousState, optio
93
95
  const envLabel = previousState?.envName || previousState?.sourceSchema?.env || "source schema env";
94
96
  throw new Error(`Cannot refresh source schema for service "${config.name}" because ${envLabel} is unavailable.`);
95
97
  }
96
- await refreshSourceSchemaCache(config, state, options);
97
- return { ...state, refreshed: true, cacheExists: true };
98
+ const refreshInfo = await refreshSourceSchemaCache(config, state, options);
99
+ return { ...state, refreshed: true, cacheExists: true, refreshInfo };
98
100
  }
99
101
 
100
102
  export async function applySourceSchemaCache(config, databaseUrl, state, options = {}) {
@@ -171,6 +173,7 @@ export function appendSourceSchemaCacheToHash(hash, config) {
171
173
  }
172
174
 
173
175
  async function refreshSourceSchemaCache(config, state, options = {}) {
176
+ let refreshInfo = null;
174
177
  const operation = options.setupRegistry?.start({
175
178
  config,
176
179
  stage: "source-schema:refresh",
@@ -178,16 +181,31 @@ async function refreshSourceSchemaCache(config, state, options = {}) {
178
181
  summary: `source schema refresh: ${path.relative(config.productDir, state.cachePath)}`,
179
182
  });
180
183
  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
- });
184
+ refreshInfo = await withSourceSchemaRefreshLock(
185
+ getSourceSchemaRefreshLockPath(state.cachePath),
186
+ async ({ waited, requestedAt }) => {
187
+ const cachedAfterWaiting = readRecentSourceSchemaMetadata(state, requestedAt);
188
+ if (cachedAfterWaiting) {
189
+ return {
190
+ reusedExistingRefresh: true,
191
+ waitedForLock: waited,
192
+ metadata: cachedAfterWaiting,
193
+ };
194
+ }
195
+ const result = await writeSourceSchemaCacheFromUrl(config, state.sourceUrl, state.cachePath, {
196
+ reporter: options.reporter || null,
197
+ logRecord: operation?._logRecord || null,
198
+ env: options.env || process.env,
199
+ });
200
+ const metadata = buildSourceSchemaMetadata(config, state, result);
201
+ writeSourceSchemaMetadata(state.metadataPath, metadata);
202
+ return {
203
+ reusedExistingRefresh: false,
204
+ waitedForLock: waited,
205
+ metadata,
206
+ };
207
+ }
208
+ );
191
209
  const finished = operation
192
210
  ? options.setupRegistry.finish(operation, {
193
211
  status: "passed",
@@ -195,6 +213,7 @@ async function refreshSourceSchemaCache(config, state, options = {}) {
195
213
  })
196
214
  : null;
197
215
  if (finished) options.reporter?.setupOperationFinished?.(finished);
216
+ return refreshInfo;
198
217
  } catch (error) {
199
218
  const finished = operation
200
219
  ? options.setupRegistry.finish(operation, {
@@ -209,17 +228,52 @@ async function refreshSourceSchemaCache(config, state, options = {}) {
209
228
  }
210
229
 
211
230
  async function writeSourceSchemaCacheFromUrl(config, sourceUrl, cachePath, options = {}) {
231
+ const resolution = resolveSourceSchemaDumpUrl(sourceUrl);
212
232
  fs.mkdirSync(path.dirname(cachePath), { recursive: true });
213
233
  const tempDir = fs.mkdtempSync(path.join(path.dirname(cachePath), ".source-schema-"));
214
234
  const tempPath = path.join(tempDir, path.basename(cachePath));
215
235
  try {
216
- await dumpPostgresSchemaToFile(config, tempPath, sourceUrl, options);
236
+ await dumpPostgresSchemaToFile(config, tempPath, resolution.dumpUrl, {
237
+ ...options,
238
+ pgApplicationName: sourceSchemaPgApplicationName(config.name),
239
+ });
217
240
  fs.renameSync(tempPath, cachePath);
241
+ return {
242
+ resolution,
243
+ pgDump: {
244
+ args: ["--schema-only", "--no-owner", "--no-privileges"],
245
+ applicationName: sourceSchemaPgApplicationName(config.name),
246
+ },
247
+ };
218
248
  } finally {
219
249
  fs.rmSync(tempDir, { recursive: true, force: true });
220
250
  }
221
251
  }
222
252
 
253
+ function buildSourceSchemaMetadata(config, state, result) {
254
+ return {
255
+ refreshedAt: new Date().toISOString(),
256
+ serviceName: config.name,
257
+ envName: state.envName,
258
+ cachePath: path.relative(config.productDir, state.cachePath),
259
+ sourceUrl: result.resolution.metadata,
260
+ pgDump: result.pgDump,
261
+ };
262
+ }
263
+
264
+ function readRecentSourceSchemaMetadata(state, requestedAt) {
265
+ if (!fs.existsSync(state.cachePath)) return null;
266
+ const metadata = readJson(state.metadataPath);
267
+ const refreshedAt = metadata?.refreshedAt ? Date.parse(metadata.refreshedAt) : 0;
268
+ if (!Number.isFinite(refreshedAt) || refreshedAt < requestedAt) return null;
269
+ return metadata;
270
+ }
271
+
272
+ function sourceSchemaPgApplicationName(serviceName) {
273
+ const suffix = String(serviceName).replace(/[^a-zA-Z0-9._:-]+/g, "-").slice(0, 24) || "service";
274
+ return `testkit:schema-source:${suffix}`;
275
+ }
276
+
223
277
  function readSourceSchemaCacheText(cachePath) {
224
278
  return sanitizeSnapshotText(fs.readFileSync(cachePath, "utf8"));
225
279
  }
@@ -0,0 +1,69 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ const DEFAULT_STALE_MS = 10 * 60 * 1000;
5
+ const DEFAULT_TIMEOUT_MS = 120 * 1000;
6
+ const DEFAULT_POLL_MS = 100;
7
+
8
+ export async function withSourceSchemaRefreshLock(lockPath, task, options = {}) {
9
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
10
+ const staleMs = options.staleMs || DEFAULT_STALE_MS;
11
+ const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
12
+ const pollMs = options.pollMs || DEFAULT_POLL_MS;
13
+ const startedAt = Date.now();
14
+
15
+ let waited = false;
16
+ while (true) {
17
+ try {
18
+ const fd = fs.openSync(lockPath, "wx");
19
+ try {
20
+ fs.writeFileSync(
21
+ fd,
22
+ `${JSON.stringify(
23
+ {
24
+ pid: process.pid,
25
+ createdAt: new Date().toISOString(),
26
+ },
27
+ null,
28
+ 2
29
+ )}\n`
30
+ );
31
+ } finally {
32
+ fs.closeSync(fd);
33
+ }
34
+
35
+ try {
36
+ return await task({ waited, requestedAt: startedAt });
37
+ } finally {
38
+ fs.rmSync(lockPath, { force: true });
39
+ }
40
+ } catch (error) {
41
+ if (error?.code !== "EEXIST") throw error;
42
+ waited = true;
43
+ removeStaleLock(lockPath, staleMs);
44
+ if (Date.now() - startedAt > timeoutMs) {
45
+ throw new Error(`Timed out waiting for source schema refresh lock: ${lockPath}`);
46
+ }
47
+ await sleep(pollMs);
48
+ }
49
+ }
50
+ }
51
+
52
+ export function getSourceSchemaRefreshLockPath(cachePath) {
53
+ return path.join(path.dirname(cachePath), "source-schema.refresh.lock");
54
+ }
55
+
56
+ function removeStaleLock(lockPath, staleMs) {
57
+ try {
58
+ const stat = fs.statSync(lockPath);
59
+ if (Date.now() - stat.mtimeMs > staleMs) {
60
+ fs.rmSync(lockPath, { force: true });
61
+ }
62
+ } catch (error) {
63
+ if (error?.code !== "ENOENT") throw error;
64
+ }
65
+ }
66
+
67
+ function sleep(ms) {
68
+ return new Promise((resolve) => setTimeout(resolve, ms));
69
+ }
@@ -0,0 +1,110 @@
1
+ const NEON_HOST_SUFFIX = ".neon.tech";
2
+ const NEON_POOLER_MARKER = "-pooler.";
3
+ const UNSUPPORTED_POOLER_PATTERN = /(^|[-.])(pgbouncer|pooler)([-.]|$)/i;
4
+
5
+ export function resolveSourceSchemaDumpUrl(databaseUrl) {
6
+ const original = parseDatabaseUrl(databaseUrl);
7
+ const originalClassification = classifyDatabaseHost(original.hostname);
8
+
9
+ if (originalClassification.kind === "neon-pooler") {
10
+ const resolved = new URL(original.url.href);
11
+ resolved.hostname = original.hostname.replace(NEON_POOLER_MARKER, ".");
12
+ return buildResolution({
13
+ original,
14
+ resolved,
15
+ originalClassification,
16
+ resolvedClassification: classifyDatabaseHost(resolved.hostname),
17
+ rewritten: true,
18
+ notice: "Source schema URL uses Neon pooler; Testkit is using the direct endpoint for pg_dump.",
19
+ });
20
+ }
21
+
22
+ if (originalClassification.kind === "unsupported-pooler") {
23
+ throw new Error(
24
+ [
25
+ "Refusing to run pg_dump through a pooled source database URL.",
26
+ `Host: ${original.hostname}`,
27
+ "Configure a direct source database URL.",
28
+ ].join("\n")
29
+ );
30
+ }
31
+
32
+ return buildResolution({
33
+ original,
34
+ resolved: original.url,
35
+ originalClassification,
36
+ resolvedClassification: originalClassification,
37
+ rewritten: false,
38
+ notice: null,
39
+ });
40
+ }
41
+
42
+ export function classifyDatabaseHost(hostname) {
43
+ const host = String(hostname || "").toLowerCase();
44
+ if (host.endsWith(NEON_HOST_SUFFIX) && host.includes(NEON_POOLER_MARKER)) {
45
+ return { kind: "neon-pooler", host };
46
+ }
47
+ if (host.endsWith(NEON_HOST_SUFFIX)) {
48
+ return { kind: "neon-direct", host };
49
+ }
50
+ if (UNSUPPORTED_POOLER_PATTERN.test(host)) {
51
+ return { kind: "unsupported-pooler", host };
52
+ }
53
+ return { kind: "unknown-direct", host };
54
+ }
55
+
56
+ export function redactDatabaseUrl(databaseUrl) {
57
+ try {
58
+ const parsed = new URL(databaseUrl);
59
+ if (parsed.username) parsed.username = "REDACTED";
60
+ if (parsed.password) parsed.password = "REDACTED";
61
+ return parsed.toString();
62
+ } catch {
63
+ return "[invalid database URL]";
64
+ }
65
+ }
66
+
67
+ export function assertPgDumpArgsAreSourceSafe(args) {
68
+ for (let index = 0; index < args.length; index += 1) {
69
+ const arg = String(args[index]);
70
+ if (arg === "-j" || arg === "--jobs" || arg.startsWith("--jobs=")) {
71
+ throw new Error("Source schema pg_dump must not use parallel jobs.");
72
+ }
73
+ }
74
+ }
75
+
76
+ function parseDatabaseUrl(databaseUrl) {
77
+ try {
78
+ const url = new URL(databaseUrl);
79
+ if (!url.hostname) throw new Error("missing host");
80
+ return {
81
+ url,
82
+ hostname: url.hostname.toLowerCase(),
83
+ };
84
+ } catch (error) {
85
+ throw new Error(`Invalid source database URL: ${error?.message || error}`);
86
+ }
87
+ }
88
+
89
+ function buildResolution({
90
+ original,
91
+ resolved,
92
+ originalClassification,
93
+ resolvedClassification,
94
+ rewritten,
95
+ notice,
96
+ }) {
97
+ return {
98
+ originalUrl: original.url.href,
99
+ dumpUrl: resolved.href,
100
+ rewritten,
101
+ notice,
102
+ metadata: {
103
+ originalHost: original.hostname,
104
+ originalClassification: originalClassification.kind,
105
+ resolvedHost: resolved.hostname.toLowerCase(),
106
+ resolvedClassification: resolvedClassification.kind,
107
+ rewritten,
108
+ },
109
+ };
110
+ }
@@ -8,6 +8,7 @@ import {
8
8
  runConfiguredSteps,
9
9
  } from "../runner/template-steps.mjs";
10
10
  import { captureOutput } from "../runner/processes.mjs";
11
+ import { assertPgDumpArgsAreSourceSafe } from "./source-url.mjs";
11
12
 
12
13
  export async function runTemplateStage(config, stageName, databaseUrl, options = {}) {
13
14
  const steps = config.testkit.database?.template?.[stageName] || [];
@@ -69,14 +70,18 @@ export async function captureTemplateSnapshotText(config, databaseUrl, options =
69
70
 
70
71
  export async function dumpPostgresSchemaToFile(config, outputPath, databaseUrl, options = {}) {
71
72
  const env = {
72
- ...buildTemplateExecutionEnv(config, {}, process.env),
73
- ...buildPostgresConnectionEnv(databaseUrl),
73
+ ...buildTemplateExecutionEnv(config, {}, options.env || process.env),
74
+ ...buildPostgresConnectionEnv(databaseUrl, {
75
+ applicationName: options.pgApplicationName,
76
+ }),
74
77
  };
75
- const result = await runPgDumpCommand(config, "pg_dump", pgDumpArgs(), env, options);
78
+ const args = pgDumpArgs();
79
+ assertPgDumpArgsAreSourceSafe(args);
80
+ const result = await runPgDumpCommand(config, "pg_dump", args, env, options);
76
81
  if (result.exitCode !== 0 && isPgDumpServerVersionMismatch(result)) {
77
82
  const serverMajor = parsePgDumpServerMajor(result);
78
83
  if (serverMajor) {
79
- const fallback = await runDockerizedPgDump(config, serverMajor, env, options);
84
+ const fallback = await runDockerizedPgDump(config, serverMajor, env, args, options);
80
85
  if (fallback.exitCode === 0) {
81
86
  fs.writeFileSync(outputPath, fallback.stdout);
82
87
  sanitizeSnapshotFile(outputPath);
@@ -92,7 +97,7 @@ export async function dumpPostgresSchemaToFile(config, outputPath, databaseUrl,
92
97
  sanitizeSnapshotFile(outputPath);
93
98
  }
94
99
 
95
- function pgDumpArgs() {
100
+ export function pgDumpArgs() {
96
101
  return [
97
102
  "--schema-only",
98
103
  "--no-owner",
@@ -100,7 +105,7 @@ function pgDumpArgs() {
100
105
  ];
101
106
  }
102
107
 
103
- async function runDockerizedPgDump(config, serverMajor, env, options) {
108
+ async function runDockerizedPgDump(config, serverMajor, env, pgDumpCommandArgs, options) {
104
109
  const image = `${process.env.TESTKIT_PG_DUMP_IMAGE_PREFIX || "postgres"}:${serverMajor}`;
105
110
  return runPgDumpCommand(
106
111
  config,
@@ -122,9 +127,11 @@ async function runDockerizedPgDump(config, serverMajor, env, options) {
122
127
  "PGPASSWORD",
123
128
  "-e",
124
129
  "PGSSLMODE",
130
+ "-e",
131
+ "PGAPPNAME",
125
132
  image,
126
133
  "pg_dump",
127
- ...pgDumpArgs(),
134
+ ...pgDumpCommandArgs,
128
135
  ],
129
136
  env,
130
137
  options
@@ -169,7 +176,7 @@ function parsePgDumpServerMajor(result) {
169
176
  return match ? Number(match[1]) : null;
170
177
  }
171
178
 
172
- function buildPostgresConnectionEnv(databaseUrl) {
179
+ function buildPostgresConnectionEnv(databaseUrl, options = {}) {
173
180
  const parsed = new URL(databaseUrl);
174
181
  return compactObject({
175
182
  PGHOST: parsed.hostname,
@@ -178,6 +185,7 @@ function buildPostgresConnectionEnv(databaseUrl) {
178
185
  PGUSER: decodeURIComponent(parsed.username || ""),
179
186
  PGPASSWORD: decodeURIComponent(parsed.password || ""),
180
187
  PGSSLMODE: parsed.searchParams.get("sslmode") || undefined,
188
+ PGAPPNAME: options.applicationName || undefined,
181
189
  });
182
190
  }
183
191
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/next-analysis",
3
- "version": "0.1.116",
3
+ "version": "0.1.117",
4
4
  "description": "SWC-backed Next.js source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-bridge",
3
- "version": "0.1.116",
3
+ "version": "0.1.117",
4
4
  "description": "Browser bridge helpers for testkit",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc -p tsconfig.json --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@elench/testkit-protocol": "0.1.116"
25
+ "@elench/testkit-protocol": "0.1.117"
26
26
  },
27
27
  "private": false
28
28
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.116",
3
+ "version": "0.1.117",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/ts-analysis",
3
- "version": "0.1.116",
3
+ "version": "0.1.117",
4
4
  "description": "TypeScript compiler-backed source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.116",
3
+ "version": "0.1.117",
4
4
  "description": "Assistant-first CLI for running, inspecting, and debugging local testkit suites",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -94,10 +94,10 @@
94
94
  },
95
95
  "dependencies": {
96
96
  "@babel/code-frame": "^7.29.0",
97
- "@elench/next-analysis": "0.1.116",
98
- "@elench/testkit-bridge": "0.1.116",
99
- "@elench/testkit-protocol": "0.1.116",
100
- "@elench/ts-analysis": "0.1.116",
97
+ "@elench/next-analysis": "0.1.117",
98
+ "@elench/testkit-bridge": "0.1.117",
99
+ "@elench/testkit-protocol": "0.1.117",
100
+ "@elench/ts-analysis": "0.1.117",
101
101
  "@oclif/core": "^4.10.6",
102
102
  "@playwright/test": "^1.52.0",
103
103
  "esbuild": "^0.25.11",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.115",
3
+ "version": "0.1.116",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",