@saptools/cf-hana 0.5.0 → 0.5.1

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/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { homedir } from "os";
6
6
  import { join } from "path";
7
7
 
8
8
  // src/config.ts
9
- var CLI_VERSION = "0.5.0";
9
+ var CLI_VERSION = "0.5.1";
10
10
  var ENV_PREFIX = "CF_HANA";
11
11
  var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
12
12
  var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
@@ -438,7 +438,7 @@ function findTopLevelChar(sql, target, startIndex, endIndex) {
438
438
  }
439
439
 
440
440
  // src/statements.ts
441
- var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH"]);
441
+ var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT"]);
442
442
  var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
443
443
  var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
444
444
  function firstKeyword(sql) {
@@ -478,8 +478,165 @@ function firstKeyword(sql) {
478
478
  }
479
479
  return sql.slice(index, keywordEnd).toUpperCase();
480
480
  }
481
- function classifyStatement(sql) {
482
- const keyword = firstKeyword(sql);
481
+ function skipQuotedText2(sql, start) {
482
+ const quote = sql[start];
483
+ let index = start + 1;
484
+ while (index < sql.length) {
485
+ if (sql[index] === quote) {
486
+ if (sql[index + 1] === quote) {
487
+ index += 2;
488
+ continue;
489
+ }
490
+ index += 1;
491
+ break;
492
+ }
493
+ index += 1;
494
+ }
495
+ return index;
496
+ }
497
+ function skipLineComment2(sql, start) {
498
+ let index = start + 2;
499
+ while (index < sql.length && sql[index] !== "\n") {
500
+ index += 1;
501
+ }
502
+ return index;
503
+ }
504
+ function skipBlockComment2(sql, start) {
505
+ let index = start + 2;
506
+ while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
507
+ index += 1;
508
+ }
509
+ return Math.min(index + 2, sql.length);
510
+ }
511
+ function maskIgnoredSqlText(sql) {
512
+ let masked = "";
513
+ let index = 0;
514
+ while (index < sql.length) {
515
+ const char = sql[index];
516
+ if (char === "'" || char === '"') {
517
+ const end = skipQuotedText2(sql, index);
518
+ masked += " ".repeat(end - index);
519
+ index = end;
520
+ continue;
521
+ }
522
+ if (char === "-" && sql[index + 1] === "-") {
523
+ const end = skipLineComment2(sql, index);
524
+ masked += " ".repeat(end - index);
525
+ index = end;
526
+ continue;
527
+ }
528
+ if (char === "/" && sql[index + 1] === "*") {
529
+ const end = skipBlockComment2(sql, index);
530
+ masked += " ".repeat(end - index);
531
+ index = end;
532
+ continue;
533
+ }
534
+ masked += char ?? "";
535
+ index += 1;
536
+ }
537
+ return masked;
538
+ }
539
+ function topLevelKeywordIndex(sql, keyword) {
540
+ const masked = maskIgnoredSqlText(sql);
541
+ let depth = 0;
542
+ for (let index = 0; index < masked.length; index += 1) {
543
+ const char = masked[index];
544
+ if (char === "(") {
545
+ depth += 1;
546
+ continue;
547
+ }
548
+ if (char === ")") {
549
+ depth = Math.max(0, depth - 1);
550
+ continue;
551
+ }
552
+ if (depth === 0 && masked.slice(index, index + keyword.length).toUpperCase() === keyword && !/[A-Za-z0-9_$#]/.test(masked.charAt(index - 1)) && !/[A-Za-z0-9_$#]/.test(masked.charAt(index + keyword.length))) {
553
+ return index;
554
+ }
555
+ }
556
+ return void 0;
557
+ }
558
+ function hasTopLevelKeyword(sql, keyword) {
559
+ return topLevelKeywordIndex(sql, keyword) !== void 0;
560
+ }
561
+ function isIdentifierChar2(char) {
562
+ return /[A-Za-z0-9_$#]/.test(char);
563
+ }
564
+ function skipWhitespace(masked, start) {
565
+ let index = start;
566
+ while (index < masked.length && /\s/.test(masked.charAt(index))) {
567
+ index += 1;
568
+ }
569
+ return index;
570
+ }
571
+ function matchingCloseParenIndex(masked, openIndex) {
572
+ let depth = 0;
573
+ for (let index = openIndex; index < masked.length; index += 1) {
574
+ const char = masked.charAt(index);
575
+ if (char === "(") {
576
+ depth += 1;
577
+ } else if (char === ")") {
578
+ depth -= 1;
579
+ if (depth === 0) {
580
+ return index;
581
+ }
582
+ }
583
+ }
584
+ return void 0;
585
+ }
586
+ function isAsKeywordAt(masked, index) {
587
+ return masked.slice(index, index + 2).toUpperCase() === "AS" && !isIdentifierChar2(masked.charAt(index - 1)) && !isIdentifierChar2(masked.charAt(index + 2));
588
+ }
589
+ function cteListEndIndex(masked, afterWith) {
590
+ let index = afterWith;
591
+ for (; ; ) {
592
+ index = skipWhitespace(masked, index);
593
+ while (index < masked.length && isIdentifierChar2(masked.charAt(index))) {
594
+ index += 1;
595
+ }
596
+ index = skipWhitespace(masked, index);
597
+ if (masked.charAt(index) === "(") {
598
+ const columnListEnd = matchingCloseParenIndex(masked, index);
599
+ if (columnListEnd === void 0) {
600
+ return void 0;
601
+ }
602
+ index = skipWhitespace(masked, columnListEnd + 1);
603
+ }
604
+ if (!isAsKeywordAt(masked, index)) {
605
+ return void 0;
606
+ }
607
+ index = skipWhitespace(masked, index + 2);
608
+ if (masked.charAt(index) !== "(") {
609
+ return void 0;
610
+ }
611
+ const closeIndex = matchingCloseParenIndex(masked, index);
612
+ if (closeIndex === void 0) {
613
+ return void 0;
614
+ }
615
+ index = skipWhitespace(masked, closeIndex + 1);
616
+ if (masked.charAt(index) === ",") {
617
+ index += 1;
618
+ continue;
619
+ }
620
+ return index;
621
+ }
622
+ }
623
+ function resolveWithStatement(sql) {
624
+ const withIndex = topLevelKeywordIndex(sql, "WITH");
625
+ if (withIndex === void 0) {
626
+ return void 0;
627
+ }
628
+ const masked = maskIgnoredSqlText(sql);
629
+ const trailingIndex = cteListEndIndex(masked, withIndex + "WITH".length);
630
+ if (trailingIndex === void 0) {
631
+ return void 0;
632
+ }
633
+ let keywordEnd = trailingIndex;
634
+ while (keywordEnd < masked.length && isIdentifierChar2(masked.charAt(keywordEnd))) {
635
+ keywordEnd += 1;
636
+ }
637
+ return { keyword: masked.slice(trailingIndex, keywordEnd).toUpperCase(), index: trailingIndex };
638
+ }
639
+ function classifyByKeyword(keyword) {
483
640
  if (SELECT_KEYWORDS.has(keyword)) {
484
641
  return "select";
485
642
  }
@@ -491,6 +648,14 @@ function classifyStatement(sql) {
491
648
  }
492
649
  return "unknown";
493
650
  }
651
+ function classifyStatement(sql) {
652
+ const keyword = firstKeyword(sql);
653
+ if (keyword === "WITH") {
654
+ const resolved = resolveWithStatement(sql);
655
+ return resolved === void 0 ? "unknown" : classifyByKeyword(resolved.keyword);
656
+ }
657
+ return classifyByKeyword(keyword);
658
+ }
494
659
  function quoteIdentifier(identifier) {
495
660
  if (identifier.length === 0) {
496
661
  throw new QueryError("A SQL identifier must not be empty");
@@ -654,11 +819,11 @@ function mergeTargetReference(statementSql, target, usingStart) {
654
819
  return target.reference;
655
820
  }
656
821
  if (/^PARTITION\b/i.test(suffix)) {
657
- const open = suffix.indexOf("(");
658
- if (open === -1) {
822
+ const open2 = suffix.indexOf("(");
823
+ if (open2 === -1) {
659
824
  return void 0;
660
825
  }
661
- const close = findTopLevelChar(suffix, ")", open + 1, suffix.length);
826
+ const close = findTopLevelChar(suffix, ")", open2 + 1, suffix.length);
662
827
  if (close === void 0) {
663
828
  return void 0;
664
829
  }
@@ -841,13 +1006,19 @@ function dispatchWriteBackupPlan(keyword, statementSql, params) {
841
1006
  }
842
1007
  }
843
1008
  function buildWriteBackupPlan(sql, params = []) {
844
- const statementSql = trimStatementSql(sql);
845
- const keyword = firstKeyword(statementSql);
1009
+ const trimmed = trimStatementSql(sql);
1010
+ const leading = firstKeyword(trimmed);
1011
+ const withResolved = leading === "WITH" ? resolveWithStatement(trimmed) : void 0;
1012
+ const keyword = withResolved?.keyword ?? leading;
846
1013
  if (!isWriteKeyword(keyword)) {
847
1014
  return void 0;
848
1015
  }
849
- assertParamArity(statementSql, params);
850
- return dispatchWriteBackupPlan(keyword, statementSql, params);
1016
+ const startIndex = withResolved?.index ?? 0;
1017
+ const statementSql = trimmed.slice(startIndex);
1018
+ const slicedParams = params.slice(countPlaceholders(trimmed.slice(0, startIndex)));
1019
+ assertParamArity(statementSql, slicedParams);
1020
+ const plan = dispatchWriteBackupPlan(keyword, statementSql, slicedParams);
1021
+ return plan === void 0 ? void 0 : { ...plan, statementSql: trimmed };
851
1022
  }
852
1023
 
853
1024
  // src/backup.ts
@@ -1176,9 +1347,9 @@ function buildEnv(ctx, overrides = {}) {
1176
1347
  env["CF_HOME"] = ctx.cfHome;
1177
1348
  return env;
1178
1349
  }
1179
- async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
1350
+ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS, maxAttempts = CF_RETRY_ATTEMPTS) {
1180
1351
  let lastErr;
1181
- for (let attempt = 0; attempt < CF_RETRY_ATTEMPTS; attempt++) {
1352
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1182
1353
  try {
1183
1354
  const { stdout } = await execFileAsync(bin, args, {
1184
1355
  env,
@@ -1197,18 +1368,24 @@ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
1197
1368
  if (isEnoent || !isTimeout && !isNetworkFlake) {
1198
1369
  throw err;
1199
1370
  }
1200
- if (attempt < CF_RETRY_ATTEMPTS - 1) {
1371
+ if (attempt < maxAttempts - 1) {
1201
1372
  await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
1202
1373
  }
1203
1374
  }
1204
1375
  }
1205
1376
  throw lastErr;
1206
1377
  }
1207
- async function runCf(args, ctx, overrides = {}) {
1378
+ async function runCf(args, ctx, overrides = {}, execOptions = {}) {
1208
1379
  const { bin, argsPrefix } = resolveCfBin();
1209
1380
  const env = buildEnv(ctx, overrides);
1210
1381
  try {
1211
- return await execWithRetries(bin, [...argsPrefix, ...args], env);
1382
+ return await execWithRetries(
1383
+ bin,
1384
+ [...argsPrefix, ...args],
1385
+ env,
1386
+ execOptions.timeoutMs,
1387
+ execOptions.maxAttempts
1388
+ );
1212
1389
  } catch (lastErr) {
1213
1390
  const e = lastErr;
1214
1391
  const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
@@ -1238,15 +1415,26 @@ async function cfEnvDirect(appName) {
1238
1415
  delete env["SAP_PASSWORD"];
1239
1416
  return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
1240
1417
  }
1241
- async function cfApps(ctx) {
1242
- return await runCf(["apps"], ctx);
1418
+ async function cfApps(ctx, timeoutMs) {
1419
+ return await runCf(
1420
+ ["apps"],
1421
+ ctx,
1422
+ {},
1423
+ timeoutMs === void 0 ? {} : { timeoutMs, maxAttempts: 1 }
1424
+ );
1243
1425
  }
1244
- async function cfAppsDirect() {
1426
+ async function cfAppsDirect(timeoutMs) {
1245
1427
  const { bin, argsPrefix } = resolveCfBin();
1246
1428
  const env = { ...process.env };
1247
1429
  delete env["SAP_EMAIL"];
1248
1430
  delete env["SAP_PASSWORD"];
1249
- return await execWithRetries(bin, [...argsPrefix, "apps"], env);
1431
+ return await execWithRetries(
1432
+ bin,
1433
+ [...argsPrefix, "apps"],
1434
+ env,
1435
+ timeoutMs,
1436
+ timeoutMs === void 0 ? void 0 : 1
1437
+ );
1250
1438
  }
1251
1439
  async function readCurrentCfTarget() {
1252
1440
  const { bin, argsPrefix } = resolveCfBin();
@@ -2153,6 +2341,13 @@ async function connectHdb(params) {
2153
2341
  password: params.password,
2154
2342
  ca: params.certificate,
2155
2343
  useTLS: true,
2344
+ // HANA Cloud can reactively redirect a connection mid-auth to an
2345
+ // internal per-node hostname (pod-locality routing). That target is
2346
+ // often unreachable outside SAP's own network, and for a tunneled
2347
+ // connection it would silently abandon the SSH forward for a fresh,
2348
+ // untunneled socket. The original bound host is always a real, working
2349
+ // endpoint, so disabling the redirect is safe on the direct path too.
2350
+ disableCloudRedirect: true,
2156
2351
  ...params.servername === void 0 ? {} : { servername: params.servername }
2157
2352
  });
2158
2353
  await openClient(client, params.connectTimeoutMs);
@@ -2262,83 +2457,6 @@ async function appendSqlHistory(input, options = {}) {
2262
2457
  // src/safety.ts
2263
2458
  var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
2264
2459
  var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
2265
- function skipQuotedText2(sql, start) {
2266
- const quote = sql[start];
2267
- let index = start + 1;
2268
- while (index < sql.length) {
2269
- if (sql[index] === quote) {
2270
- if (sql[index + 1] === quote) {
2271
- index += 2;
2272
- continue;
2273
- }
2274
- index += 1;
2275
- break;
2276
- }
2277
- index += 1;
2278
- }
2279
- return index;
2280
- }
2281
- function skipLineComment2(sql, start) {
2282
- let index = start + 2;
2283
- while (index < sql.length && sql[index] !== "\n") {
2284
- index += 1;
2285
- }
2286
- return index;
2287
- }
2288
- function skipBlockComment2(sql, start) {
2289
- let index = start + 2;
2290
- while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
2291
- index += 1;
2292
- }
2293
- return Math.min(index + 2, sql.length);
2294
- }
2295
- function maskIgnoredSqlText(sql) {
2296
- let masked = "";
2297
- let index = 0;
2298
- while (index < sql.length) {
2299
- const char = sql[index];
2300
- if (char === "'" || char === '"') {
2301
- const end = skipQuotedText2(sql, index);
2302
- masked += " ".repeat(end - index);
2303
- index = end;
2304
- continue;
2305
- }
2306
- if (char === "-" && sql[index + 1] === "-") {
2307
- const end = skipLineComment2(sql, index);
2308
- masked += " ".repeat(end - index);
2309
- index = end;
2310
- continue;
2311
- }
2312
- if (char === "/" && sql[index + 1] === "*") {
2313
- const end = skipBlockComment2(sql, index);
2314
- masked += " ".repeat(end - index);
2315
- index = end;
2316
- continue;
2317
- }
2318
- masked += char ?? "";
2319
- index += 1;
2320
- }
2321
- return masked;
2322
- }
2323
- function hasTopLevelKeyword(sql, keyword) {
2324
- const masked = maskIgnoredSqlText(sql);
2325
- let depth = 0;
2326
- for (let index = 0; index < masked.length; index += 1) {
2327
- const char = masked[index];
2328
- if (char === "(") {
2329
- depth += 1;
2330
- continue;
2331
- }
2332
- if (char === ")") {
2333
- depth = Math.max(0, depth - 1);
2334
- continue;
2335
- }
2336
- if (depth === 0 && masked.slice(index, index + keyword.length).toUpperCase() === keyword && !/[A-Za-z0-9_$#]/.test(masked.charAt(index - 1)) && !/[A-Za-z0-9_$#]/.test(masked.charAt(index + keyword.length))) {
2337
- return true;
2338
- }
2339
- }
2340
- return false;
2341
- }
2342
2460
  function isUnconditionalMergeDelete(sql) {
2343
2461
  return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
2344
2462
  }
@@ -2381,17 +2499,23 @@ function appendLimit(sql, limit) {
2381
2499
  }
2382
2500
  function inspectStatement(sql) {
2383
2501
  const kind = classifyStatement(sql);
2384
- const keyword = firstKeyword(sql);
2502
+ const leading = firstKeyword(sql);
2503
+ const withResolved = leading === "WITH" ? resolveWithStatement(sql) : void 0;
2504
+ const keyword = withResolved?.keyword ?? leading;
2505
+ const tail = withResolved === void 0 ? sql : sql.slice(withResolved.index);
2385
2506
  if (kind === "ddl") {
2386
2507
  return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
2387
2508
  }
2388
2509
  if (kind === "dml") {
2389
- const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(sql, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(sql) || keyword === "REPLACE" && isMalformedReplace(sql);
2510
+ const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(tail, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(tail) || keyword === "REPLACE" && isMalformedReplace(tail);
2390
2511
  return {
2391
2512
  kind,
2392
2513
  destructive
2393
2514
  };
2394
2515
  }
2516
+ if (kind === "unknown") {
2517
+ return { kind, destructive: keyword === "CALL" };
2518
+ }
2395
2519
  return { kind, destructive: false };
2396
2520
  }
2397
2521
  function evaluateGuard(sql, config) {
@@ -2436,13 +2560,13 @@ function applyAutoLimit(sql, limit) {
2436
2560
 
2437
2561
  // src/tunnel/cache.ts
2438
2562
  import { createHash } from "crypto";
2439
- import { mkdir as mkdir3, readdir as readdir2, readFile, rename, rm as rm4, writeFile as writeFile2 } from "fs/promises";
2563
+ import { mkdir as mkdir3, readdir as readdir2, readFile as readFile2, rename, rm as rm4, writeFile as writeFile2 } from "fs/promises";
2440
2564
  import { homedir as homedir3 } from "os";
2441
2565
  import { join as join5 } from "path";
2442
2566
 
2443
2567
  // src/tunnel/process.ts
2444
2568
  import { spawn } from "child_process";
2445
- import { mkdtemp as mkdtemp2, rm as rm3 } from "fs/promises";
2569
+ import { mkdtemp as mkdtemp2, open, readFile, rm as rm3 } from "fs/promises";
2446
2570
  import { connect as netConnect, createServer } from "net";
2447
2571
  import { tmpdir as tmpdir2 } from "os";
2448
2572
  import { join as join4 } from "path";
@@ -2549,8 +2673,8 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
2549
2673
  child.on("exit", onAbort);
2550
2674
  child.on("error", onAbort);
2551
2675
  timers.poll = setInterval(() => {
2552
- void probePort(localPort).then((open) => {
2553
- if (open && child.pid !== void 0) {
2676
+ void probePort(localPort).then((open2) => {
2677
+ if (open2 && child.pid !== void 0) {
2554
2678
  finish({ localPort, pid: child.pid });
2555
2679
  }
2556
2680
  });
@@ -2558,6 +2682,36 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
2558
2682
  timers.deadline = setTimeout(finish, boundMs);
2559
2683
  });
2560
2684
  }
2685
+ var STDERR_TAIL_MAX_CHARS = 4096;
2686
+ async function openStderrCapture() {
2687
+ try {
2688
+ const dir = await mkdtemp2(join4(tmpdir2(), "saptools-cf-hana-tunnel-log-"));
2689
+ const path = join4(dir, "stderr.log");
2690
+ const handle = await open(path, "w");
2691
+ return {
2692
+ path,
2693
+ fd: handle.fd,
2694
+ close: async () => {
2695
+ await handle.close().catch(() => {
2696
+ });
2697
+ await rm3(dir, { recursive: true, force: true });
2698
+ }
2699
+ };
2700
+ } catch {
2701
+ return void 0;
2702
+ }
2703
+ }
2704
+ async function readStderrTail(path) {
2705
+ try {
2706
+ const content = (await readFile(path, "utf8")).trim().replace(/\s+/g, " ");
2707
+ if (content.length === 0) {
2708
+ return void 0;
2709
+ }
2710
+ return content.length > STDERR_TAIL_MAX_CHARS ? content.slice(-STDERR_TAIL_MAX_CHARS) : content;
2711
+ } catch {
2712
+ return void 0;
2713
+ }
2714
+ }
2561
2715
  async function spawnTunnel(params, deps = {}) {
2562
2716
  assertPositiveSafeInteger("tunnel keepalive seconds", params.keepaliveSeconds);
2563
2717
  const remainingMs = params.deadline - Date.now();
@@ -2582,8 +2736,24 @@ async function spawnTunnel(params, deps = {}) {
2582
2736
  `sleep ${String(params.keepaliveSeconds)}`
2583
2737
  ];
2584
2738
  const env = params.cfHome === void 0 ? { ...process.env } : { ...process.env, CF_HOME: params.cfHome };
2585
- const child = spawnProcess(bin, args, { detached: true, stdio: "ignore", env });
2586
- return await raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess);
2739
+ const openCapture = deps.openStderrCapture ?? openStderrCapture;
2740
+ const capture = await openCapture();
2741
+ const stdio = capture === void 0 ? "ignore" : ["ignore", "ignore", capture.fd];
2742
+ const child = spawnProcess(bin, args, { detached: true, stdio, env });
2743
+ const result = await raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess);
2744
+ if (capture !== void 0) {
2745
+ if (result === void 0) {
2746
+ try {
2747
+ const tail = await readStderrTail(capture.path);
2748
+ if (tail !== void 0) {
2749
+ deps.onFailureDiagnostic?.(tail);
2750
+ }
2751
+ } catch {
2752
+ }
2753
+ }
2754
+ await capture.close();
2755
+ }
2756
+ return result;
2587
2757
  }
2588
2758
 
2589
2759
  // src/tunnel/cache.ts
@@ -2628,7 +2798,7 @@ function isTunnelCacheRecord(value) {
2628
2798
  }
2629
2799
  async function parseTunnelCacheFile(path) {
2630
2800
  try {
2631
- const raw = JSON.parse(await readFile(path, "utf8"));
2801
+ const raw = JSON.parse(await readFile2(path, "utf8"));
2632
2802
  return isTunnelCacheRecord(raw) ? raw : void 0;
2633
2803
  } catch {
2634
2804
  return void 0;
@@ -2846,6 +3016,9 @@ function isConnectivityFailure(error) {
2846
3016
  }
2847
3017
  return hasOpenConnectionCause(error.cause, 0);
2848
3018
  }
3019
+ function isUnattributedQueryFailure(error) {
3020
+ return error instanceof QueryError && error.databaseCode === void 0 && error.sqlState === void 0;
3021
+ }
2849
3022
 
2850
3023
  // src/tunnel/fallback.ts
2851
3024
  var EXPIRY_SAFETY_MARGIN_MS = 3e4;
@@ -2875,30 +3048,40 @@ async function discardQuietly(work) {
2875
3048
  await work.catch(() => {
2876
3049
  });
2877
3050
  }
3051
+ function shouldDiscardTunnel(error) {
3052
+ return isConnectivityFailure(error) || isUnattributedQueryFailure(error);
3053
+ }
2878
3054
  async function tryReadyRecord(record, driver, config, directParams, cacheOptions) {
2879
3055
  try {
2880
3056
  const connection = await driver.connect(tunneledParams(directParams, record.localPort));
2881
3057
  config.onTunnelStatus?.(`connected via SSH tunnel through ${record.app}`);
2882
3058
  return connection;
2883
3059
  } catch (error) {
2884
- if (isConnectivityFailure(error)) {
3060
+ if (shouldDiscardTunnel(error)) {
2885
3061
  await discardQuietly(evictTunnelCache(config.host, cacheOptions));
2886
3062
  return void 0;
2887
3063
  }
2888
3064
  throw error;
2889
3065
  }
2890
3066
  }
2891
- async function discoverAppsStdout(ctx) {
3067
+ function knownCandidates(targetAppName, hintApp) {
3068
+ return hintApp === void 0 || hintApp === targetAppName ? [targetAppName] : [targetAppName, hintApp];
3069
+ }
3070
+ async function discoverAppsStdout(ctx, timeoutMs) {
2892
3071
  try {
2893
- return ctx === void 0 ? await cfAppsDirect() : await cfApps(ctx);
3072
+ return ctx === void 0 ? await cfAppsDirect(timeoutMs) : await cfApps(ctx, timeoutMs);
2894
3073
  } catch {
2895
3074
  return;
2896
3075
  }
2897
3076
  }
2898
- async function buildCandidates(config, ctx, hintApp) {
2899
- const stdout = await discoverAppsStdout(ctx);
3077
+ async function discoverExtraCandidates(config, ctx, alreadyTried, deadline) {
3078
+ const remainingMs = deadline - Date.now();
3079
+ if (remainingMs <= 0) {
3080
+ return [];
3081
+ }
3082
+ const stdout = await discoverAppsStdout(ctx, remainingMs);
2900
3083
  const base = buildCandidateList(config.appName, stdout, DEFAULT_TUNNEL_MAX_CANDIDATES);
2901
- return hintApp === void 0 || base.includes(hintApp) ? base : [hintApp, ...base];
3084
+ return base.filter((app) => !alreadyTried.includes(app));
2902
3085
  }
2903
3086
  async function finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions) {
2904
3087
  const readyRecord = {
@@ -2914,7 +3097,7 @@ async function finalizeCandidateConnection(app, spawned, driver, config, directP
2914
3097
  config.onTunnelStatus?.(`connected via SSH tunnel through ${app}`);
2915
3098
  return connection;
2916
3099
  } catch (error) {
2917
- if (isConnectivityFailure(error)) {
3100
+ if (shouldDiscardTunnel(error)) {
2918
3101
  await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
2919
3102
  killTunnelProcess(spawned.pid);
2920
3103
  return void 0;
@@ -2931,7 +3114,8 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
2931
3114
  return await tryReadyRecord(claim.record, driver, config, directParams, cacheOptions);
2932
3115
  }
2933
3116
  if (claim.outcome === "wait") {
2934
- const ready = await waitForEstablishment(config.host, deadline, void 0, cacheOptions);
3117
+ const waitDeadline = Math.min(deadline, Date.now() + DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS);
3118
+ const ready = await waitForEstablishment(config.host, waitDeadline, void 0, cacheOptions);
2935
3119
  return ready === void 0 ? void 0 : await tryReadyRecord(ready, driver, config, directParams, cacheOptions);
2936
3120
  }
2937
3121
  const spawned = await spawnTunnel(
@@ -2944,7 +3128,12 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
2944
3128
  deadline,
2945
3129
  candidateTimeoutMs: DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS
2946
3130
  },
2947
- overrides.process ?? {}
3131
+ {
3132
+ ...overrides.process,
3133
+ onFailureDiagnostic: (message) => {
3134
+ config.onTunnelStatus?.(`tunnel via ${app} failed: ${message}`);
3135
+ }
3136
+ }
2948
3137
  );
2949
3138
  if (spawned === void 0) {
2950
3139
  await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
@@ -2954,7 +3143,11 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
2954
3143
  }
2955
3144
  async function tryCachedTunnel(driver, config, directParams, cacheOptions) {
2956
3145
  if (config.refreshTunnel) {
3146
+ const superseded = await readTunnelCacheEntry(config.host, cacheOptions);
2957
3147
  await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3148
+ if (superseded?.status === "ready") {
3149
+ (cacheOptions.killProcess ?? killTunnelProcess)(superseded.pid);
3150
+ }
2958
3151
  return { connection: void 0, hintApp: void 0 };
2959
3152
  }
2960
3153
  const entry = await readTunnelCacheEntry(config.host, cacheOptions);
@@ -2972,8 +3165,12 @@ async function tryDirectConnect(driver, config, directParams) {
2972
3165
  if (config.tunnelMode !== "auto") {
2973
3166
  return { connection: void 0, directError: void 0 };
2974
3167
  }
3168
+ const boundedDirectParams = {
3169
+ ...directParams,
3170
+ connectTimeoutMs: Math.min(directParams.connectTimeoutMs, DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS)
3171
+ };
2975
3172
  try {
2976
- return { connection: await driver.connect(directParams), directError: void 0 };
3173
+ return { connection: await driver.connect(boundedDirectParams), directError: void 0 };
2977
3174
  } catch (error) {
2978
3175
  if (!isConnectivityFailure(error)) {
2979
3176
  throw error;
@@ -2990,18 +3187,47 @@ function buildExhaustionError(host, directError, candidates) {
2990
3187
  `Could not establish an SSH tunnel to ${host} through any candidate app (tried: ${candidates.length > 0 ? candidates.join(", ") : "none"})`
2991
3188
  );
2992
3189
  }
2993
- async function runCandidateLoop(ctx, driver, config, directParams, deadline, hintApp, overrides) {
2994
- const candidatesTried = await buildCandidates(config, ctx, hintApp);
2995
- for (const app of candidatesTried) {
3190
+ async function tryCandidateList(apps, ctx, driver, config, directParams, deadline, overrides, tried) {
3191
+ for (const app of apps) {
2996
3192
  if (Date.now() >= deadline) {
2997
- break;
3193
+ return void 0;
2998
3194
  }
3195
+ tried.push(app);
2999
3196
  const connection = await tryCandidate(app, ctx, driver, config, directParams, deadline, overrides);
3000
3197
  if (connection !== void 0) {
3001
- return { connection, candidatesTried };
3198
+ return connection;
3002
3199
  }
3003
3200
  }
3004
- return { connection: void 0, candidatesTried };
3201
+ return void 0;
3202
+ }
3203
+ async function runCandidateLoop(ctx, driver, config, directParams, deadline, hintApp, overrides) {
3204
+ const tried = [];
3205
+ const known = knownCandidates(config.appName, hintApp);
3206
+ const knownConnection = await tryCandidateList(
3207
+ known,
3208
+ ctx,
3209
+ driver,
3210
+ config,
3211
+ directParams,
3212
+ deadline,
3213
+ overrides,
3214
+ tried
3215
+ );
3216
+ if (knownConnection !== void 0) {
3217
+ return { connection: knownConnection, candidatesTried: tried };
3218
+ }
3219
+ const discovered = await discoverExtraCandidates(config, ctx, tried, deadline);
3220
+ const discoveredConnection = await tryCandidateList(
3221
+ discovered,
3222
+ ctx,
3223
+ driver,
3224
+ config,
3225
+ directParams,
3226
+ deadline,
3227
+ overrides,
3228
+ tried
3229
+ );
3230
+ return { connection: discoveredConnection, candidatesTried: tried };
3005
3231
  }
3006
3232
  async function connectWithTunnelFallback(driver, config, overrides = {}) {
3007
3233
  const directParams = directParamsOf(config);