@saptools/cf-hana 0.4.1 → 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.4.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;
@@ -15,6 +15,27 @@ var DEFAULT_POOL_IDLE_MS = 6e4;
15
15
  var DEFAULT_AUTO_LIMIT = 100;
16
16
  var DEFAULT_MAX_RESULT_STORE_BYTES = 256 * 1024 * 1024;
17
17
  var MAX_RESULT_STORE_BYTES = resolveMaxResultStoreBytes();
18
+ function resolvePositiveIntEnv(suffix, defaultValue) {
19
+ const raw = readEnv(envName(suffix));
20
+ if (raw === void 0) {
21
+ return defaultValue;
22
+ }
23
+ const parsed = Number(raw);
24
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : defaultValue;
25
+ }
26
+ var DEFAULT_TUNNEL_FALLBACK_BUDGET_MS = resolvePositiveIntEnv(
27
+ "TUNNEL_FALLBACK_BUDGET_MS",
28
+ 25e3
29
+ );
30
+ var DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS = resolvePositiveIntEnv(
31
+ "TUNNEL_CANDIDATE_TIMEOUT_MS",
32
+ 15e3
33
+ );
34
+ var DEFAULT_TUNNEL_MAX_CANDIDATES = resolvePositiveIntEnv("TUNNEL_MAX_CANDIDATES", 3);
35
+ var DEFAULT_TUNNEL_KEEPALIVE_SECONDS = resolvePositiveIntEnv(
36
+ "TUNNEL_KEEPALIVE_SECONDS",
37
+ 1200
38
+ );
18
39
  function resolveMaxResultStoreBytes() {
19
40
  if (readEnv(envName("DRIVER")) !== "fake") {
20
41
  return DEFAULT_MAX_RESULT_STORE_BYTES;
@@ -417,7 +438,7 @@ function findTopLevelChar(sql, target, startIndex, endIndex) {
417
438
  }
418
439
 
419
440
  // src/statements.ts
420
- var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH"]);
441
+ var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT"]);
421
442
  var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
422
443
  var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
423
444
  function firstKeyword(sql) {
@@ -457,8 +478,165 @@ function firstKeyword(sql) {
457
478
  }
458
479
  return sql.slice(index, keywordEnd).toUpperCase();
459
480
  }
460
- function classifyStatement(sql) {
461
- 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) {
462
640
  if (SELECT_KEYWORDS.has(keyword)) {
463
641
  return "select";
464
642
  }
@@ -470,6 +648,14 @@ function classifyStatement(sql) {
470
648
  }
471
649
  return "unknown";
472
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
+ }
473
659
  function quoteIdentifier(identifier) {
474
660
  if (identifier.length === 0) {
475
661
  throw new QueryError("A SQL identifier must not be empty");
@@ -633,11 +819,11 @@ function mergeTargetReference(statementSql, target, usingStart) {
633
819
  return target.reference;
634
820
  }
635
821
  if (/^PARTITION\b/i.test(suffix)) {
636
- const open = suffix.indexOf("(");
637
- if (open === -1) {
822
+ const open2 = suffix.indexOf("(");
823
+ if (open2 === -1) {
638
824
  return void 0;
639
825
  }
640
- const close = findTopLevelChar(suffix, ")", open + 1, suffix.length);
826
+ const close = findTopLevelChar(suffix, ")", open2 + 1, suffix.length);
641
827
  if (close === void 0) {
642
828
  return void 0;
643
829
  }
@@ -820,13 +1006,19 @@ function dispatchWriteBackupPlan(keyword, statementSql, params) {
820
1006
  }
821
1007
  }
822
1008
  function buildWriteBackupPlan(sql, params = []) {
823
- const statementSql = trimStatementSql(sql);
824
- 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;
825
1013
  if (!isWriteKeyword(keyword)) {
826
1014
  return void 0;
827
1015
  }
828
- assertParamArity(statementSql, params);
829
- 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 };
830
1022
  }
831
1023
 
832
1024
  // src/backup.ts
@@ -1155,9 +1347,9 @@ function buildEnv(ctx, overrides = {}) {
1155
1347
  env["CF_HOME"] = ctx.cfHome;
1156
1348
  return env;
1157
1349
  }
1158
- 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) {
1159
1351
  let lastErr;
1160
- for (let attempt = 0; attempt < CF_RETRY_ATTEMPTS; attempt++) {
1352
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1161
1353
  try {
1162
1354
  const { stdout } = await execFileAsync(bin, args, {
1163
1355
  env,
@@ -1176,18 +1368,24 @@ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
1176
1368
  if (isEnoent || !isTimeout && !isNetworkFlake) {
1177
1369
  throw err;
1178
1370
  }
1179
- if (attempt < CF_RETRY_ATTEMPTS - 1) {
1371
+ if (attempt < maxAttempts - 1) {
1180
1372
  await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
1181
1373
  }
1182
1374
  }
1183
1375
  }
1184
1376
  throw lastErr;
1185
1377
  }
1186
- async function runCf(args, ctx, overrides = {}) {
1378
+ async function runCf(args, ctx, overrides = {}, execOptions = {}) {
1187
1379
  const { bin, argsPrefix } = resolveCfBin();
1188
1380
  const env = buildEnv(ctx, overrides);
1189
1381
  try {
1190
- 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
+ );
1191
1389
  } catch (lastErr) {
1192
1390
  const e = lastErr;
1193
1391
  const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
@@ -1217,6 +1415,27 @@ async function cfEnvDirect(appName) {
1217
1415
  delete env["SAP_PASSWORD"];
1218
1416
  return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
1219
1417
  }
1418
+ async function cfApps(ctx, timeoutMs) {
1419
+ return await runCf(
1420
+ ["apps"],
1421
+ ctx,
1422
+ {},
1423
+ timeoutMs === void 0 ? {} : { timeoutMs, maxAttempts: 1 }
1424
+ );
1425
+ }
1426
+ async function cfAppsDirect(timeoutMs) {
1427
+ const { bin, argsPrefix } = resolveCfBin();
1428
+ const env = { ...process.env };
1429
+ delete env["SAP_EMAIL"];
1430
+ delete env["SAP_PASSWORD"];
1431
+ return await execWithRetries(
1432
+ bin,
1433
+ [...argsPrefix, "apps"],
1434
+ env,
1435
+ timeoutMs,
1436
+ timeoutMs === void 0 ? void 0 : 1
1437
+ );
1438
+ }
1220
1439
  async function readCurrentCfTarget() {
1221
1440
  const { bin, argsPrefix } = resolveCfBin();
1222
1441
  const env = { ...process.env };
@@ -1266,6 +1485,26 @@ function parseTargetFields(stdout) {
1266
1485
  }
1267
1486
  return map;
1268
1487
  }
1488
+ function parseCfAppsOutput(stdout) {
1489
+ const lines = stdout.split(/\r?\n/);
1490
+ const headerIndex = lines.findIndex((line) => /^\s*name\s+\S/i.test(line));
1491
+ if (headerIndex === -1) {
1492
+ return [];
1493
+ }
1494
+ const rows = [];
1495
+ for (const line of lines.slice(headerIndex + 1)) {
1496
+ const trimmed = line.trim();
1497
+ if (trimmed.length === 0) {
1498
+ continue;
1499
+ }
1500
+ const [name, state] = trimmed.split(/\s+/);
1501
+ if (name === void 0 || state === void 0) {
1502
+ continue;
1503
+ }
1504
+ rows.push({ name, state });
1505
+ }
1506
+ return rows;
1507
+ }
1269
1508
  function formatCurrentCfAppSelector(target, appName) {
1270
1509
  const name = appName.trim();
1271
1510
  if (!name) {
@@ -1577,7 +1816,10 @@ async function resolveAppBindings(rawSelector, options) {
1577
1816
  source: "live",
1578
1817
  selectorSource: target.selectorSource,
1579
1818
  regionConfirmed: target.regionConfirmed,
1580
- selectorCanBePinned: target.selectorCanBePinned
1819
+ selectorCanBePinned: target.selectorCanBePinned,
1820
+ apiEndpoint: target.apiEndpoint,
1821
+ orgName: target.orgName,
1822
+ spaceName: target.spaceName
1581
1823
  };
1582
1824
  }
1583
1825
  async function readExplicitBindings(target, options) {
@@ -1643,6 +1885,7 @@ function toConnectionTarget(binding, role) {
1643
1885
  // src/driver/fake.ts
1644
1886
  import { appendFile } from "fs/promises";
1645
1887
  var catalogFailureInjected = false;
1888
+ var connectFailureInjectedOnce = false;
1646
1889
  function catalogObjects() {
1647
1890
  return [
1648
1891
  { SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "EXISTING_TABLE", OBJECT_TYPE: "TABLE" },
@@ -1851,10 +2094,28 @@ var FakeConnection = class {
1851
2094
  return this.closed;
1852
2095
  }
1853
2096
  };
2097
+ function maybeFailConnect() {
2098
+ const mode = readEnv(envName("FAKE_FAIL_CONNECT"));
2099
+ if (mode !== "once" && mode !== "always") {
2100
+ return;
2101
+ }
2102
+ if (mode === "once" && connectFailureInjectedOnce) {
2103
+ return;
2104
+ }
2105
+ connectFailureInjectedOnce = true;
2106
+ const cause = Object.assign(
2107
+ new Error("Could not connect to any host: [ fake-host:443 - forced fixture failure ]"),
2108
+ { code: "EHDBOPENCONN" }
2109
+ );
2110
+ throw new CfHanaError("CONNECTION", `Failed to connect to HANA: ${cause.message}`, { cause });
2111
+ }
1854
2112
  function createFakeDriver() {
1855
2113
  return {
1856
2114
  name: "fake",
1857
- connect: (_params) => Promise.resolve(new FakeConnection())
2115
+ connect: (_params) => {
2116
+ maybeFailConnect();
2117
+ return Promise.resolve(new FakeConnection());
2118
+ }
1858
2119
  };
1859
2120
  }
1860
2121
 
@@ -2079,7 +2340,15 @@ async function connectHdb(params) {
2079
2340
  user: params.user,
2080
2341
  password: params.password,
2081
2342
  ca: params.certificate,
2082
- useTLS: true
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,
2351
+ ...params.servername === void 0 ? {} : { servername: params.servername }
2083
2352
  });
2084
2353
  await openClient(client, params.connectTimeoutMs);
2085
2354
  try {
@@ -2188,83 +2457,6 @@ async function appendSqlHistory(input, options = {}) {
2188
2457
  // src/safety.ts
2189
2458
  var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
2190
2459
  var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
2191
- function skipQuotedText2(sql, start) {
2192
- const quote = sql[start];
2193
- let index = start + 1;
2194
- while (index < sql.length) {
2195
- if (sql[index] === quote) {
2196
- if (sql[index + 1] === quote) {
2197
- index += 2;
2198
- continue;
2199
- }
2200
- index += 1;
2201
- break;
2202
- }
2203
- index += 1;
2204
- }
2205
- return index;
2206
- }
2207
- function skipLineComment2(sql, start) {
2208
- let index = start + 2;
2209
- while (index < sql.length && sql[index] !== "\n") {
2210
- index += 1;
2211
- }
2212
- return index;
2213
- }
2214
- function skipBlockComment2(sql, start) {
2215
- let index = start + 2;
2216
- while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
2217
- index += 1;
2218
- }
2219
- return Math.min(index + 2, sql.length);
2220
- }
2221
- function maskIgnoredSqlText(sql) {
2222
- let masked = "";
2223
- let index = 0;
2224
- while (index < sql.length) {
2225
- const char = sql[index];
2226
- if (char === "'" || char === '"') {
2227
- const end = skipQuotedText2(sql, index);
2228
- masked += " ".repeat(end - index);
2229
- index = end;
2230
- continue;
2231
- }
2232
- if (char === "-" && sql[index + 1] === "-") {
2233
- const end = skipLineComment2(sql, index);
2234
- masked += " ".repeat(end - index);
2235
- index = end;
2236
- continue;
2237
- }
2238
- if (char === "/" && sql[index + 1] === "*") {
2239
- const end = skipBlockComment2(sql, index);
2240
- masked += " ".repeat(end - index);
2241
- index = end;
2242
- continue;
2243
- }
2244
- masked += char ?? "";
2245
- index += 1;
2246
- }
2247
- return masked;
2248
- }
2249
- function hasTopLevelKeyword(sql, keyword) {
2250
- const masked = maskIgnoredSqlText(sql);
2251
- let depth = 0;
2252
- for (let index = 0; index < masked.length; index += 1) {
2253
- const char = masked[index];
2254
- if (char === "(") {
2255
- depth += 1;
2256
- continue;
2257
- }
2258
- if (char === ")") {
2259
- depth = Math.max(0, depth - 1);
2260
- continue;
2261
- }
2262
- 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))) {
2263
- return true;
2264
- }
2265
- }
2266
- return false;
2267
- }
2268
2460
  function isUnconditionalMergeDelete(sql) {
2269
2461
  return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
2270
2462
  }
@@ -2307,17 +2499,23 @@ function appendLimit(sql, limit) {
2307
2499
  }
2308
2500
  function inspectStatement(sql) {
2309
2501
  const kind = classifyStatement(sql);
2310
- 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);
2311
2506
  if (kind === "ddl") {
2312
2507
  return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
2313
2508
  }
2314
2509
  if (kind === "dml") {
2315
- 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);
2316
2511
  return {
2317
2512
  kind,
2318
2513
  destructive
2319
2514
  };
2320
2515
  }
2516
+ if (kind === "unknown") {
2517
+ return { kind, destructive: keyword === "CALL" };
2518
+ }
2321
2519
  return { kind, destructive: false };
2322
2520
  }
2323
2521
  function evaluateGuard(sql, config) {
@@ -2360,6 +2558,707 @@ function applyAutoLimit(sql, limit) {
2360
2558
  };
2361
2559
  }
2362
2560
 
2561
+ // src/tunnel/cache.ts
2562
+ import { createHash } from "crypto";
2563
+ import { mkdir as mkdir3, readdir as readdir2, readFile as readFile2, rename, rm as rm4, writeFile as writeFile2 } from "fs/promises";
2564
+ import { homedir as homedir3 } from "os";
2565
+ import { join as join5 } from "path";
2566
+
2567
+ // src/tunnel/process.ts
2568
+ import { spawn } from "child_process";
2569
+ import { mkdtemp as mkdtemp2, open, readFile, rm as rm3 } from "fs/promises";
2570
+ import { connect as netConnect, createServer } from "net";
2571
+ import { tmpdir as tmpdir2 } from "os";
2572
+ import { join as join4 } from "path";
2573
+ var PORT_POLL_INTERVAL_MS = 100;
2574
+ var DEFAULT_PORT_PROBE_TIMEOUT_MS = 500;
2575
+ function assertPositiveSafeInteger(label, value) {
2576
+ if (!Number.isSafeInteger(value) || value <= 0) {
2577
+ throw new CfHanaError("CONFIG", `${label} must be a positive integer`);
2578
+ }
2579
+ }
2580
+ async function allocateLocalPort() {
2581
+ return await new Promise((resolve, reject) => {
2582
+ const server = createServer();
2583
+ server.once("error", reject);
2584
+ server.listen(0, "127.0.0.1", () => {
2585
+ const address = server.address();
2586
+ server.close(() => {
2587
+ if (address === null || typeof address === "string") {
2588
+ reject(new CfHanaError("CONNECTION", "Could not allocate a local port for a tunnel"));
2589
+ return;
2590
+ }
2591
+ resolve(address.port);
2592
+ });
2593
+ });
2594
+ });
2595
+ }
2596
+ function probeLocalPort(port, timeoutMs = DEFAULT_PORT_PROBE_TIMEOUT_MS) {
2597
+ return new Promise((resolve) => {
2598
+ let settled = false;
2599
+ const socket = netConnect({ host: "127.0.0.1", port });
2600
+ const finish = (result) => {
2601
+ if (settled) {
2602
+ return;
2603
+ }
2604
+ settled = true;
2605
+ clearTimeout(timer);
2606
+ socket.destroy();
2607
+ resolve(result);
2608
+ };
2609
+ const timer = setTimeout(() => {
2610
+ finish(false);
2611
+ }, timeoutMs);
2612
+ timer.unref();
2613
+ socket.once("connect", () => {
2614
+ finish(true);
2615
+ });
2616
+ socket.once("error", () => {
2617
+ finish(false);
2618
+ });
2619
+ });
2620
+ }
2621
+ function killTunnelProcess(pid) {
2622
+ if (pid === void 0) {
2623
+ return;
2624
+ }
2625
+ try {
2626
+ process.kill(pid, "SIGTERM");
2627
+ } catch {
2628
+ }
2629
+ }
2630
+ async function withScopedCfSession(selectorSource, target, sapCredentials, work) {
2631
+ if (selectorSource === "ambient") {
2632
+ return await work();
2633
+ }
2634
+ if (sapCredentials === void 0) {
2635
+ throw new CredentialsNotFoundError(
2636
+ "SAP credentials are required to open a tunnel session for an explicit selector"
2637
+ );
2638
+ }
2639
+ const cfHome = await mkdtemp2(join4(tmpdir2(), "saptools-cf-hana-tunnel-"));
2640
+ try {
2641
+ const ctx = { cfHome };
2642
+ await cfApi(target.apiEndpoint, ctx);
2643
+ await cfAuth(sapCredentials.email, sapCredentials.password, ctx);
2644
+ await cfTargetSpace(target.orgName, target.spaceName, ctx);
2645
+ return await work(ctx);
2646
+ } finally {
2647
+ await rm3(cfHome, { recursive: true, force: true });
2648
+ }
2649
+ }
2650
+ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess) {
2651
+ return new Promise((resolve) => {
2652
+ let settled = false;
2653
+ const timers = {};
2654
+ const finish = (result) => {
2655
+ if (settled) {
2656
+ return;
2657
+ }
2658
+ settled = true;
2659
+ clearInterval(timers.poll);
2660
+ clearTimeout(timers.deadline);
2661
+ child.removeListener("exit", onAbort);
2662
+ child.removeListener("error", onAbort);
2663
+ if (result === void 0) {
2664
+ killProcess(child.pid);
2665
+ } else {
2666
+ child.unref();
2667
+ }
2668
+ resolve(result);
2669
+ };
2670
+ function onAbort() {
2671
+ finish();
2672
+ }
2673
+ child.on("exit", onAbort);
2674
+ child.on("error", onAbort);
2675
+ timers.poll = setInterval(() => {
2676
+ void probePort(localPort).then((open2) => {
2677
+ if (open2 && child.pid !== void 0) {
2678
+ finish({ localPort, pid: child.pid });
2679
+ }
2680
+ });
2681
+ }, PORT_POLL_INTERVAL_MS);
2682
+ timers.deadline = setTimeout(finish, boundMs);
2683
+ });
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
+ }
2715
+ async function spawnTunnel(params, deps = {}) {
2716
+ assertPositiveSafeInteger("tunnel keepalive seconds", params.keepaliveSeconds);
2717
+ const remainingMs = params.deadline - Date.now();
2718
+ if (remainingMs <= 0) {
2719
+ return void 0;
2720
+ }
2721
+ const boundMs = Math.min(remainingMs, params.candidateTimeoutMs);
2722
+ const spawnProcess = deps.spawnProcess ?? spawn;
2723
+ const probePort = deps.probePort ?? probeLocalPort;
2724
+ const allocatePort = deps.allocatePort ?? allocateLocalPort;
2725
+ const killProcess = deps.killProcess ?? killTunnelProcess;
2726
+ const localPort = await allocatePort();
2727
+ const { bin, argsPrefix } = resolveCfBin();
2728
+ const forward = `${String(localPort)}:${params.hanaHost}:${String(params.hanaPort)}`;
2729
+ const args = [
2730
+ ...argsPrefix,
2731
+ "ssh",
2732
+ params.app,
2733
+ "-L",
2734
+ forward,
2735
+ "-c",
2736
+ `sleep ${String(params.keepaliveSeconds)}`
2737
+ ];
2738
+ const env = params.cfHome === void 0 ? { ...process.env } : { ...process.env, CF_HOME: params.cfHome };
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;
2757
+ }
2758
+
2759
+ // src/tunnel/cache.ts
2760
+ var DEFAULT_ESTABLISHING_STALE_MS = 2 * DEFAULT_TUNNEL_FALLBACK_BUDGET_MS;
2761
+ var DEFAULT_POLL_INTERVAL_MS = 300;
2762
+ function isRecord2(value) {
2763
+ return typeof value === "object" && value !== null;
2764
+ }
2765
+ function defaultIsProcessAlive(pid) {
2766
+ try {
2767
+ process.kill(pid, 0);
2768
+ return true;
2769
+ } catch (error) {
2770
+ return isRecord2(error) && error["code"] !== "ESRCH";
2771
+ }
2772
+ }
2773
+ function sleep(ms) {
2774
+ return new Promise((resolve) => {
2775
+ setTimeout(resolve, ms);
2776
+ });
2777
+ }
2778
+ function tunnelCacheRoot(saptoolsRoot) {
2779
+ return join5(saptoolsRoot ?? join5(homedir3(), ".saptools"), "cf-hana", "tunnel");
2780
+ }
2781
+ function tunnelCacheKey(host) {
2782
+ return createHash("sha256").update(host).digest("hex");
2783
+ }
2784
+ function tunnelCachePath(host, saptoolsRoot) {
2785
+ return join5(tunnelCacheRoot(saptoolsRoot), `${tunnelCacheKey(host)}.json`);
2786
+ }
2787
+ function isOrgKey(value) {
2788
+ return isRecord2(value) && typeof value["apiEndpoint"] === "string" && typeof value["orgName"] === "string";
2789
+ }
2790
+ function hasEstablishingFields(value) {
2791
+ return value["status"] === "establishing" && typeof value["host"] === "string" && typeof value["startedAt"] === "string" && typeof value["ownerPid"] === "number" && isOrgKey(value["orgKey"]);
2792
+ }
2793
+ function hasReadyFields(value) {
2794
+ return value["status"] === "ready" && typeof value["host"] === "string" && typeof value["localPort"] === "number" && typeof value["pid"] === "number" && typeof value["app"] === "string" && typeof value["expiresAt"] === "string" && isOrgKey(value["orgKey"]);
2795
+ }
2796
+ function isTunnelCacheRecord(value) {
2797
+ return isRecord2(value) && value["version"] === 1 && (hasEstablishingFields(value) || hasReadyFields(value));
2798
+ }
2799
+ async function parseTunnelCacheFile(path) {
2800
+ try {
2801
+ const raw = JSON.parse(await readFile2(path, "utf8"));
2802
+ return isTunnelCacheRecord(raw) ? raw : void 0;
2803
+ } catch {
2804
+ return void 0;
2805
+ }
2806
+ }
2807
+ async function readTunnelCacheEntry(host, options = {}) {
2808
+ const entry = await parseTunnelCacheFile(tunnelCachePath(host, options.saptoolsRoot));
2809
+ return entry?.host === host ? entry : void 0;
2810
+ }
2811
+ function isExpired(entry, now) {
2812
+ const expiresAt = Date.parse(entry.expiresAt);
2813
+ return !Number.isFinite(expiresAt) || now.getTime() >= expiresAt;
2814
+ }
2815
+ async function isTunnelUsable(entry, options = {}) {
2816
+ const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
2817
+ if (!isAlive(entry.pid)) {
2818
+ return false;
2819
+ }
2820
+ if (isExpired(entry, options.now?.() ?? /* @__PURE__ */ new Date())) {
2821
+ return false;
2822
+ }
2823
+ const probePort = options.probePort ?? probeLocalPort;
2824
+ return await probePort(entry.localPort);
2825
+ }
2826
+ function isEstablishingStale(entry, options) {
2827
+ const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
2828
+ if (!isAlive(entry.ownerPid)) {
2829
+ return true;
2830
+ }
2831
+ const startedAt = Date.parse(entry.startedAt);
2832
+ if (!Number.isFinite(startedAt)) {
2833
+ return true;
2834
+ }
2835
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
2836
+ const staleAfterMs = options.staleAfterMs ?? DEFAULT_ESTABLISHING_STALE_MS;
2837
+ return now.getTime() - startedAt >= staleAfterMs;
2838
+ }
2839
+ async function writeEstablishingMarker(host, ownerPid, orgKey, options) {
2840
+ const root = tunnelCacheRoot(options.saptoolsRoot);
2841
+ await mkdir3(root, { recursive: true, mode: 448 });
2842
+ const record = {
2843
+ version: 1,
2844
+ status: "establishing",
2845
+ host,
2846
+ startedAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
2847
+ ownerPid,
2848
+ orgKey
2849
+ };
2850
+ try {
2851
+ await writeFile2(tunnelCachePath(host, options.saptoolsRoot), `${JSON.stringify(record)}
2852
+ `, {
2853
+ encoding: "utf8",
2854
+ mode: 384,
2855
+ flag: "wx"
2856
+ });
2857
+ return true;
2858
+ } catch (error) {
2859
+ if (isRecord2(error) && error["code"] === "EEXIST") {
2860
+ return false;
2861
+ }
2862
+ throw error;
2863
+ }
2864
+ }
2865
+ async function claimEstablishing(host, ownerPid, orgKey, options = {}) {
2866
+ if (await writeEstablishingMarker(host, ownerPid, orgKey, options)) {
2867
+ return { outcome: "claimed" };
2868
+ }
2869
+ const existing = await readTunnelCacheEntry(host, options);
2870
+ if (existing === void 0) {
2871
+ return await writeEstablishingMarker(host, ownerPid, orgKey, options) ? { outcome: "claimed" } : { outcome: "wait" };
2872
+ }
2873
+ if (existing.status === "ready") {
2874
+ return { outcome: "already-ready", record: existing };
2875
+ }
2876
+ if (!isEstablishingStale(existing, options)) {
2877
+ return { outcome: "wait" };
2878
+ }
2879
+ await rm4(tunnelCachePath(host, options.saptoolsRoot), { force: true });
2880
+ return await writeEstablishingMarker(host, ownerPid, orgKey, options) ? { outcome: "claimed" } : { outcome: "wait" };
2881
+ }
2882
+ async function waitForEstablishment(host, deadline, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, options = {}) {
2883
+ for (; ; ) {
2884
+ const entry = await readTunnelCacheEntry(host, options);
2885
+ if (entry === void 0) {
2886
+ return void 0;
2887
+ }
2888
+ if (entry.status === "ready") {
2889
+ return entry;
2890
+ }
2891
+ const remainingMs = deadline - Date.now();
2892
+ if (remainingMs <= 0) {
2893
+ return void 0;
2894
+ }
2895
+ await sleep(Math.min(pollIntervalMs, remainingMs));
2896
+ }
2897
+ }
2898
+ async function finalizeEstablishingReady(host, record, options = {}) {
2899
+ const root = tunnelCacheRoot(options.saptoolsRoot);
2900
+ const path = tunnelCachePath(host, options.saptoolsRoot);
2901
+ const tempPath = `${path}.tmp-${String(process.pid)}`;
2902
+ const stored = { version: 1, status: "ready", host, ...record };
2903
+ await mkdir3(root, { recursive: true, mode: 448 });
2904
+ await rm4(tempPath, { force: true });
2905
+ await writeFile2(tempPath, `${JSON.stringify(stored)}
2906
+ `, { encoding: "utf8", mode: 384 });
2907
+ await rename(tempPath, path);
2908
+ }
2909
+ async function finalizeEstablishingFailed(host, options = {}) {
2910
+ await rm4(tunnelCachePath(host, options.saptoolsRoot), { force: true });
2911
+ }
2912
+ async function evictTunnelCache(host, options = {}) {
2913
+ await rm4(tunnelCachePath(host, options.saptoolsRoot), { force: true });
2914
+ }
2915
+ function sameOrg(a, b) {
2916
+ return a.apiEndpoint === b.apiEndpoint && a.orgName === b.orgName;
2917
+ }
2918
+ async function reapOneFile(path, currentOrgKey, options) {
2919
+ const entry = await parseTunnelCacheFile(path);
2920
+ if (entry === void 0) {
2921
+ return;
2922
+ }
2923
+ const killProcess = options.killProcess ?? killTunnelProcess;
2924
+ if (entry.status === "establishing") {
2925
+ if (isEstablishingStale(entry, options)) {
2926
+ await rm4(path, { force: true });
2927
+ }
2928
+ return;
2929
+ }
2930
+ const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
2931
+ if (!isAlive(entry.pid)) {
2932
+ await rm4(path, { force: true });
2933
+ return;
2934
+ }
2935
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
2936
+ if (isExpired(entry, now) || !sameOrg(entry.orgKey, currentOrgKey)) {
2937
+ killProcess(entry.pid);
2938
+ await rm4(path, { force: true });
2939
+ }
2940
+ }
2941
+ async function reapStaleAndCrossOrgTunnels(currentOrgKey, options = {}) {
2942
+ const root = tunnelCacheRoot(options.saptoolsRoot);
2943
+ let files;
2944
+ try {
2945
+ files = await readdir2(root);
2946
+ } catch {
2947
+ return;
2948
+ }
2949
+ await Promise.all(
2950
+ files.filter((file) => file.endsWith(".json")).map(async (file) => {
2951
+ await reapOneFile(join5(root, file), currentOrgKey, options);
2952
+ })
2953
+ );
2954
+ }
2955
+
2956
+ // src/tunnel/candidates.ts
2957
+ var RUNNING_STATE = "started";
2958
+ var VALID_APP_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
2959
+ function isValidAppName(name) {
2960
+ return VALID_APP_NAME.test(name);
2961
+ }
2962
+ function discoveredAppNames(stdout, targetAppName) {
2963
+ if (stdout === void 0) {
2964
+ return [];
2965
+ }
2966
+ const seen = /* @__PURE__ */ new Set();
2967
+ const names = [];
2968
+ for (const row of parseCfAppsOutput(stdout)) {
2969
+ if (row.state !== RUNNING_STATE) {
2970
+ continue;
2971
+ }
2972
+ if (row.name === targetAppName || seen.has(row.name)) {
2973
+ continue;
2974
+ }
2975
+ if (!isValidAppName(row.name)) {
2976
+ continue;
2977
+ }
2978
+ seen.add(row.name);
2979
+ names.push(row.name);
2980
+ }
2981
+ return names;
2982
+ }
2983
+ function buildCandidateList(targetAppName, discoveredStdout, maxCandidates) {
2984
+ const discovered = discoveredAppNames(discoveredStdout, targetAppName);
2985
+ return [targetAppName, ...discovered.slice(0, maxCandidates)];
2986
+ }
2987
+
2988
+ // src/tunnel/classifier.ts
2989
+ var CONNECT_TIMEOUT_MESSAGE = "HANA connection timed out";
2990
+ var OPEN_CONNECTION_CODE = "EHDBOPENCONN";
2991
+ var MAX_CAUSE_DEPTH = 5;
2992
+ function causeCode(value) {
2993
+ return typeof value === "object" && value !== null ? value.code : void 0;
2994
+ }
2995
+ function causeOf(value) {
2996
+ return typeof value === "object" && value !== null ? value.cause : void 0;
2997
+ }
2998
+ function hasOpenConnectionCause(cause, depth) {
2999
+ if (depth > MAX_CAUSE_DEPTH) {
3000
+ return false;
3001
+ }
3002
+ if (causeCode(cause) === OPEN_CONNECTION_CODE) {
3003
+ return true;
3004
+ }
3005
+ return hasOpenConnectionCause(causeOf(cause), depth + 1);
3006
+ }
3007
+ function isConnectivityFailure(error) {
3008
+ if (!(error instanceof CfHanaError)) {
3009
+ return false;
3010
+ }
3011
+ if (error.code === "TIMEOUT") {
3012
+ return error.message.includes(CONNECT_TIMEOUT_MESSAGE);
3013
+ }
3014
+ if (error.code !== "CONNECTION") {
3015
+ return false;
3016
+ }
3017
+ return hasOpenConnectionCause(error.cause, 0);
3018
+ }
3019
+ function isUnattributedQueryFailure(error) {
3020
+ return error instanceof QueryError && error.databaseCode === void 0 && error.sqlState === void 0;
3021
+ }
3022
+
3023
+ // src/tunnel/fallback.ts
3024
+ var EXPIRY_SAFETY_MARGIN_MS = 3e4;
3025
+ function directParamsOf(config) {
3026
+ return {
3027
+ host: config.host,
3028
+ port: config.port,
3029
+ user: config.user,
3030
+ password: config.password,
3031
+ schema: config.schema,
3032
+ certificate: config.certificate,
3033
+ connectTimeoutMs: config.connectTimeoutMs
3034
+ };
3035
+ }
3036
+ function tunneledParams(directParams, localPort) {
3037
+ return { ...directParams, host: "127.0.0.1", port: localPort, servername: directParams.host };
3038
+ }
3039
+ function orgKeyOf(config) {
3040
+ return { apiEndpoint: config.apiEndpoint, orgName: config.orgName };
3041
+ }
3042
+ function tunnelExpiryIso() {
3043
+ return new Date(
3044
+ Date.now() + DEFAULT_TUNNEL_KEEPALIVE_SECONDS * 1e3 - EXPIRY_SAFETY_MARGIN_MS
3045
+ ).toISOString();
3046
+ }
3047
+ async function discardQuietly(work) {
3048
+ await work.catch(() => {
3049
+ });
3050
+ }
3051
+ function shouldDiscardTunnel(error) {
3052
+ return isConnectivityFailure(error) || isUnattributedQueryFailure(error);
3053
+ }
3054
+ async function tryReadyRecord(record, driver, config, directParams, cacheOptions) {
3055
+ try {
3056
+ const connection = await driver.connect(tunneledParams(directParams, record.localPort));
3057
+ config.onTunnelStatus?.(`connected via SSH tunnel through ${record.app}`);
3058
+ return connection;
3059
+ } catch (error) {
3060
+ if (shouldDiscardTunnel(error)) {
3061
+ await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3062
+ return void 0;
3063
+ }
3064
+ throw error;
3065
+ }
3066
+ }
3067
+ function knownCandidates(targetAppName, hintApp) {
3068
+ return hintApp === void 0 || hintApp === targetAppName ? [targetAppName] : [targetAppName, hintApp];
3069
+ }
3070
+ async function discoverAppsStdout(ctx, timeoutMs) {
3071
+ try {
3072
+ return ctx === void 0 ? await cfAppsDirect(timeoutMs) : await cfApps(ctx, timeoutMs);
3073
+ } catch {
3074
+ return;
3075
+ }
3076
+ }
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);
3083
+ const base = buildCandidateList(config.appName, stdout, DEFAULT_TUNNEL_MAX_CANDIDATES);
3084
+ return base.filter((app) => !alreadyTried.includes(app));
3085
+ }
3086
+ async function finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions) {
3087
+ const readyRecord = {
3088
+ localPort: spawned.localPort,
3089
+ pid: spawned.pid,
3090
+ app,
3091
+ orgKey: orgKeyOf(config),
3092
+ expiresAt: tunnelExpiryIso()
3093
+ };
3094
+ try {
3095
+ const connection = await driver.connect(tunneledParams(directParams, spawned.localPort));
3096
+ await discardQuietly(finalizeEstablishingReady(config.host, readyRecord, cacheOptions));
3097
+ config.onTunnelStatus?.(`connected via SSH tunnel through ${app}`);
3098
+ return connection;
3099
+ } catch (error) {
3100
+ if (shouldDiscardTunnel(error)) {
3101
+ await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
3102
+ killTunnelProcess(spawned.pid);
3103
+ return void 0;
3104
+ }
3105
+ await discardQuietly(finalizeEstablishingReady(config.host, readyRecord, cacheOptions));
3106
+ throw error;
3107
+ }
3108
+ }
3109
+ async function tryCandidate(app, ctx, driver, config, directParams, deadline, overrides) {
3110
+ const cacheOptions = overrides.cache ?? {};
3111
+ config.onTunnelStatus?.(`trying to reach ${config.host} via app ${app}...`);
3112
+ const claim = await claimEstablishing(config.host, process.pid, orgKeyOf(config), cacheOptions);
3113
+ if (claim.outcome === "already-ready") {
3114
+ return await tryReadyRecord(claim.record, driver, config, directParams, cacheOptions);
3115
+ }
3116
+ if (claim.outcome === "wait") {
3117
+ const waitDeadline = Math.min(deadline, Date.now() + DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS);
3118
+ const ready = await waitForEstablishment(config.host, waitDeadline, void 0, cacheOptions);
3119
+ return ready === void 0 ? void 0 : await tryReadyRecord(ready, driver, config, directParams, cacheOptions);
3120
+ }
3121
+ const spawned = await spawnTunnel(
3122
+ {
3123
+ cfHome: ctx?.cfHome,
3124
+ app,
3125
+ hanaHost: config.host,
3126
+ hanaPort: config.port,
3127
+ keepaliveSeconds: DEFAULT_TUNNEL_KEEPALIVE_SECONDS,
3128
+ deadline,
3129
+ candidateTimeoutMs: DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS
3130
+ },
3131
+ {
3132
+ ...overrides.process,
3133
+ onFailureDiagnostic: (message) => {
3134
+ config.onTunnelStatus?.(`tunnel via ${app} failed: ${message}`);
3135
+ }
3136
+ }
3137
+ );
3138
+ if (spawned === void 0) {
3139
+ await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
3140
+ return void 0;
3141
+ }
3142
+ return await finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions);
3143
+ }
3144
+ async function tryCachedTunnel(driver, config, directParams, cacheOptions) {
3145
+ if (config.refreshTunnel) {
3146
+ const superseded = await readTunnelCacheEntry(config.host, cacheOptions);
3147
+ await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3148
+ if (superseded?.status === "ready") {
3149
+ (cacheOptions.killProcess ?? killTunnelProcess)(superseded.pid);
3150
+ }
3151
+ return { connection: void 0, hintApp: void 0 };
3152
+ }
3153
+ const entry = await readTunnelCacheEntry(config.host, cacheOptions);
3154
+ if (entry?.status !== "ready") {
3155
+ return { connection: void 0, hintApp: void 0 };
3156
+ }
3157
+ if (!await isTunnelUsable(entry, cacheOptions)) {
3158
+ await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3159
+ return { connection: void 0, hintApp: entry.app };
3160
+ }
3161
+ const connection = await tryReadyRecord(entry, driver, config, directParams, cacheOptions);
3162
+ return { connection, hintApp: entry.app };
3163
+ }
3164
+ async function tryDirectConnect(driver, config, directParams) {
3165
+ if (config.tunnelMode !== "auto") {
3166
+ return { connection: void 0, directError: void 0 };
3167
+ }
3168
+ const boundedDirectParams = {
3169
+ ...directParams,
3170
+ connectTimeoutMs: Math.min(directParams.connectTimeoutMs, DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS)
3171
+ };
3172
+ try {
3173
+ return { connection: await driver.connect(boundedDirectParams), directError: void 0 };
3174
+ } catch (error) {
3175
+ if (!isConnectivityFailure(error)) {
3176
+ throw error;
3177
+ }
3178
+ return { connection: void 0, directError: error };
3179
+ }
3180
+ }
3181
+ function buildExhaustionError(host, directError, candidates) {
3182
+ if (directError !== void 0) {
3183
+ return directError;
3184
+ }
3185
+ return new CfHanaError(
3186
+ "CONNECTION",
3187
+ `Could not establish an SSH tunnel to ${host} through any candidate app (tried: ${candidates.length > 0 ? candidates.join(", ") : "none"})`
3188
+ );
3189
+ }
3190
+ async function tryCandidateList(apps, ctx, driver, config, directParams, deadline, overrides, tried) {
3191
+ for (const app of apps) {
3192
+ if (Date.now() >= deadline) {
3193
+ return void 0;
3194
+ }
3195
+ tried.push(app);
3196
+ const connection = await tryCandidate(app, ctx, driver, config, directParams, deadline, overrides);
3197
+ if (connection !== void 0) {
3198
+ return connection;
3199
+ }
3200
+ }
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 };
3231
+ }
3232
+ async function connectWithTunnelFallback(driver, config, overrides = {}) {
3233
+ const directParams = directParamsOf(config);
3234
+ const cacheOptions = overrides.cache ?? {};
3235
+ await discardQuietly(reapStaleAndCrossOrgTunnels(orgKeyOf(config), cacheOptions));
3236
+ const cached = await tryCachedTunnel(driver, config, directParams, cacheOptions);
3237
+ if (cached.connection !== void 0) {
3238
+ return cached.connection;
3239
+ }
3240
+ const direct = await tryDirectConnect(driver, config, directParams);
3241
+ if (direct.connection !== void 0) {
3242
+ return direct.connection;
3243
+ }
3244
+ const deadline = Date.now() + DEFAULT_TUNNEL_FALLBACK_BUDGET_MS;
3245
+ const target = {
3246
+ apiEndpoint: config.apiEndpoint,
3247
+ orgName: config.orgName,
3248
+ spaceName: config.spaceName
3249
+ };
3250
+ const loopResult = await withScopedCfSession(
3251
+ config.selectorSource,
3252
+ target,
3253
+ config.sapCredentials,
3254
+ (ctx) => runCandidateLoop(ctx, driver, config, directParams, deadline, cached.hintApp, overrides)
3255
+ );
3256
+ if (loopResult.connection !== void 0) {
3257
+ return loopResult.connection;
3258
+ }
3259
+ throw buildExhaustionError(config.host, direct.directError, loopResult.candidatesTried);
3260
+ }
3261
+
2363
3262
  // src/connection.ts
2364
3263
  function assertValidAutoLimit(value) {
2365
3264
  if (value !== false && (!Number.isSafeInteger(value) || value <= 0 || value >= Number.MAX_SAFE_INTEGER)) {
@@ -2453,15 +3352,7 @@ var Connection = class _Connection {
2453
3352
  driverConnection;
2454
3353
  config;
2455
3354
  static async open(driver, config) {
2456
- const driverConnection = await driver.connect({
2457
- host: config.host,
2458
- port: config.port,
2459
- user: config.user,
2460
- password: config.password,
2461
- schema: config.schema,
2462
- certificate: config.certificate,
2463
- connectTimeoutMs: config.connectTimeoutMs
2464
- });
3355
+ const driverConnection = await connectWithTunnelFallback(driver, config);
2465
3356
  return new _Connection(driverConnection, config);
2466
3357
  }
2467
3358
  async query(sql, params, options) {
@@ -2743,6 +3634,31 @@ function toPoolOptions(options) {
2743
3634
  }
2744
3635
  return options.pool ?? {};
2745
3636
  }
3637
+ function buildConnectionConfig(resolved, target, options) {
3638
+ const sapCredentials = readSapCredentials({ email: options.email, password: options.password });
3639
+ return {
3640
+ host: target.host,
3641
+ port: target.port,
3642
+ user: target.user,
3643
+ password: target.password,
3644
+ schema: target.schema,
3645
+ certificate: target.certificate,
3646
+ connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
3647
+ queryTimeoutMs: options.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
3648
+ readOnly: options.readOnly ?? false,
3649
+ allowDestructive: options.allowDestructive ?? false,
3650
+ autoLimit: options.autoLimit ?? DEFAULT_AUTO_LIMIT,
3651
+ appName: resolved.appName,
3652
+ orgName: resolved.orgName,
3653
+ spaceName: resolved.spaceName,
3654
+ apiEndpoint: resolved.apiEndpoint,
3655
+ selectorSource: resolved.selectorSource,
3656
+ tunnelMode: options.tunnel === true ? "always" : "auto",
3657
+ refreshTunnel: options.refreshTunnel ?? false,
3658
+ ...sapCredentials === void 0 ? {} : { sapCredentials },
3659
+ ...options.onTunnelStatus === void 0 ? {} : { onTunnelStatus: options.onTunnelStatus }
3660
+ };
3661
+ }
2746
3662
  function toSelectedBindingInfo(bindings, selected) {
2747
3663
  const availableBindingNames = bindings.flatMap(
2748
3664
  (binding) => binding.name === void 0 ? [] : [binding.name]
@@ -2770,19 +3686,7 @@ var HanaClient = class _HanaClient {
2770
3686
  const bindingInfo = toSelectedBindingInfo(resolved.bindings, binding);
2771
3687
  const target = toConnectionTarget(binding, role);
2772
3688
  const driver = createDriver();
2773
- const config = {
2774
- host: target.host,
2775
- port: target.port,
2776
- user: target.user,
2777
- password: target.password,
2778
- schema: target.schema,
2779
- certificate: target.certificate,
2780
- connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
2781
- queryTimeoutMs: options.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
2782
- readOnly: options.readOnly ?? false,
2783
- allowDestructive: options.allowDestructive ?? false,
2784
- autoLimit: options.autoLimit ?? DEFAULT_AUTO_LIMIT
2785
- };
3689
+ const config = buildConnectionConfig(resolved, target, options);
2786
3690
  const poolOptions = toPoolOptions(options);
2787
3691
  const info = {
2788
3692
  selector: resolved.selector,