@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/CHANGELOG.md +46 -0
- package/README.md +51 -3
- package/dist/cli.js +350 -124
- package/dist/cli.js.map +1 -1
- package/dist/index.js +348 -122
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Command } from "commander";
|
|
|
5
5
|
|
|
6
6
|
// src/config.ts
|
|
7
7
|
var CLI_NAME = "cf-hana";
|
|
8
|
-
var CLI_VERSION = "0.5.
|
|
8
|
+
var CLI_VERSION = "0.5.1";
|
|
9
9
|
var ENV_PREFIX = "CF_HANA";
|
|
10
10
|
var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
|
|
11
11
|
var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
|
|
@@ -1073,7 +1073,7 @@ function findTopLevelChar(sql, target, startIndex, endIndex) {
|
|
|
1073
1073
|
}
|
|
1074
1074
|
|
|
1075
1075
|
// src/statements.ts
|
|
1076
|
-
var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT"
|
|
1076
|
+
var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT"]);
|
|
1077
1077
|
var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
|
|
1078
1078
|
var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
|
|
1079
1079
|
function firstKeyword(sql) {
|
|
@@ -1113,8 +1113,165 @@ function firstKeyword(sql) {
|
|
|
1113
1113
|
}
|
|
1114
1114
|
return sql.slice(index, keywordEnd).toUpperCase();
|
|
1115
1115
|
}
|
|
1116
|
-
function
|
|
1117
|
-
const
|
|
1116
|
+
function skipQuotedText2(sql, start) {
|
|
1117
|
+
const quote = sql[start];
|
|
1118
|
+
let index = start + 1;
|
|
1119
|
+
while (index < sql.length) {
|
|
1120
|
+
if (sql[index] === quote) {
|
|
1121
|
+
if (sql[index + 1] === quote) {
|
|
1122
|
+
index += 2;
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
index += 1;
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
index += 1;
|
|
1129
|
+
}
|
|
1130
|
+
return index;
|
|
1131
|
+
}
|
|
1132
|
+
function skipLineComment3(sql, start) {
|
|
1133
|
+
let index = start + 2;
|
|
1134
|
+
while (index < sql.length && sql[index] !== "\n") {
|
|
1135
|
+
index += 1;
|
|
1136
|
+
}
|
|
1137
|
+
return index;
|
|
1138
|
+
}
|
|
1139
|
+
function skipBlockComment3(sql, start) {
|
|
1140
|
+
let index = start + 2;
|
|
1141
|
+
while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
1142
|
+
index += 1;
|
|
1143
|
+
}
|
|
1144
|
+
return Math.min(index + 2, sql.length);
|
|
1145
|
+
}
|
|
1146
|
+
function maskIgnoredSqlText(sql) {
|
|
1147
|
+
let masked = "";
|
|
1148
|
+
let index = 0;
|
|
1149
|
+
while (index < sql.length) {
|
|
1150
|
+
const char = sql[index];
|
|
1151
|
+
if (char === "'" || char === '"') {
|
|
1152
|
+
const end = skipQuotedText2(sql, index);
|
|
1153
|
+
masked += " ".repeat(end - index);
|
|
1154
|
+
index = end;
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
1158
|
+
const end = skipLineComment3(sql, index);
|
|
1159
|
+
masked += " ".repeat(end - index);
|
|
1160
|
+
index = end;
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
1164
|
+
const end = skipBlockComment3(sql, index);
|
|
1165
|
+
masked += " ".repeat(end - index);
|
|
1166
|
+
index = end;
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
masked += char ?? "";
|
|
1170
|
+
index += 1;
|
|
1171
|
+
}
|
|
1172
|
+
return masked;
|
|
1173
|
+
}
|
|
1174
|
+
function topLevelKeywordIndex(sql, keyword) {
|
|
1175
|
+
const masked = maskIgnoredSqlText(sql);
|
|
1176
|
+
let depth = 0;
|
|
1177
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
1178
|
+
const char = masked[index];
|
|
1179
|
+
if (char === "(") {
|
|
1180
|
+
depth += 1;
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
if (char === ")") {
|
|
1184
|
+
depth = Math.max(0, depth - 1);
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
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))) {
|
|
1188
|
+
return index;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return void 0;
|
|
1192
|
+
}
|
|
1193
|
+
function hasTopLevelKeyword(sql, keyword) {
|
|
1194
|
+
return topLevelKeywordIndex(sql, keyword) !== void 0;
|
|
1195
|
+
}
|
|
1196
|
+
function isIdentifierChar2(char) {
|
|
1197
|
+
return /[A-Za-z0-9_$#]/.test(char);
|
|
1198
|
+
}
|
|
1199
|
+
function skipWhitespace(masked, start) {
|
|
1200
|
+
let index = start;
|
|
1201
|
+
while (index < masked.length && /\s/.test(masked.charAt(index))) {
|
|
1202
|
+
index += 1;
|
|
1203
|
+
}
|
|
1204
|
+
return index;
|
|
1205
|
+
}
|
|
1206
|
+
function matchingCloseParenIndex(masked, openIndex) {
|
|
1207
|
+
let depth = 0;
|
|
1208
|
+
for (let index = openIndex; index < masked.length; index += 1) {
|
|
1209
|
+
const char = masked.charAt(index);
|
|
1210
|
+
if (char === "(") {
|
|
1211
|
+
depth += 1;
|
|
1212
|
+
} else if (char === ")") {
|
|
1213
|
+
depth -= 1;
|
|
1214
|
+
if (depth === 0) {
|
|
1215
|
+
return index;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return void 0;
|
|
1220
|
+
}
|
|
1221
|
+
function isAsKeywordAt(masked, index) {
|
|
1222
|
+
return masked.slice(index, index + 2).toUpperCase() === "AS" && !isIdentifierChar2(masked.charAt(index - 1)) && !isIdentifierChar2(masked.charAt(index + 2));
|
|
1223
|
+
}
|
|
1224
|
+
function cteListEndIndex(masked, afterWith) {
|
|
1225
|
+
let index = afterWith;
|
|
1226
|
+
for (; ; ) {
|
|
1227
|
+
index = skipWhitespace(masked, index);
|
|
1228
|
+
while (index < masked.length && isIdentifierChar2(masked.charAt(index))) {
|
|
1229
|
+
index += 1;
|
|
1230
|
+
}
|
|
1231
|
+
index = skipWhitespace(masked, index);
|
|
1232
|
+
if (masked.charAt(index) === "(") {
|
|
1233
|
+
const columnListEnd = matchingCloseParenIndex(masked, index);
|
|
1234
|
+
if (columnListEnd === void 0) {
|
|
1235
|
+
return void 0;
|
|
1236
|
+
}
|
|
1237
|
+
index = skipWhitespace(masked, columnListEnd + 1);
|
|
1238
|
+
}
|
|
1239
|
+
if (!isAsKeywordAt(masked, index)) {
|
|
1240
|
+
return void 0;
|
|
1241
|
+
}
|
|
1242
|
+
index = skipWhitespace(masked, index + 2);
|
|
1243
|
+
if (masked.charAt(index) !== "(") {
|
|
1244
|
+
return void 0;
|
|
1245
|
+
}
|
|
1246
|
+
const closeIndex = matchingCloseParenIndex(masked, index);
|
|
1247
|
+
if (closeIndex === void 0) {
|
|
1248
|
+
return void 0;
|
|
1249
|
+
}
|
|
1250
|
+
index = skipWhitespace(masked, closeIndex + 1);
|
|
1251
|
+
if (masked.charAt(index) === ",") {
|
|
1252
|
+
index += 1;
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
return index;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
function resolveWithStatement(sql) {
|
|
1259
|
+
const withIndex = topLevelKeywordIndex(sql, "WITH");
|
|
1260
|
+
if (withIndex === void 0) {
|
|
1261
|
+
return void 0;
|
|
1262
|
+
}
|
|
1263
|
+
const masked = maskIgnoredSqlText(sql);
|
|
1264
|
+
const trailingIndex = cteListEndIndex(masked, withIndex + "WITH".length);
|
|
1265
|
+
if (trailingIndex === void 0) {
|
|
1266
|
+
return void 0;
|
|
1267
|
+
}
|
|
1268
|
+
let keywordEnd = trailingIndex;
|
|
1269
|
+
while (keywordEnd < masked.length && isIdentifierChar2(masked.charAt(keywordEnd))) {
|
|
1270
|
+
keywordEnd += 1;
|
|
1271
|
+
}
|
|
1272
|
+
return { keyword: masked.slice(trailingIndex, keywordEnd).toUpperCase(), index: trailingIndex };
|
|
1273
|
+
}
|
|
1274
|
+
function classifyByKeyword(keyword) {
|
|
1118
1275
|
if (SELECT_KEYWORDS.has(keyword)) {
|
|
1119
1276
|
return "select";
|
|
1120
1277
|
}
|
|
@@ -1126,6 +1283,14 @@ function classifyStatement(sql) {
|
|
|
1126
1283
|
}
|
|
1127
1284
|
return "unknown";
|
|
1128
1285
|
}
|
|
1286
|
+
function classifyStatement(sql) {
|
|
1287
|
+
const keyword = firstKeyword(sql);
|
|
1288
|
+
if (keyword === "WITH") {
|
|
1289
|
+
const resolved = resolveWithStatement(sql);
|
|
1290
|
+
return resolved === void 0 ? "unknown" : classifyByKeyword(resolved.keyword);
|
|
1291
|
+
}
|
|
1292
|
+
return classifyByKeyword(keyword);
|
|
1293
|
+
}
|
|
1129
1294
|
function quoteIdentifier(identifier) {
|
|
1130
1295
|
if (identifier.length === 0) {
|
|
1131
1296
|
throw new QueryError("A SQL identifier must not be empty");
|
|
@@ -1289,11 +1454,11 @@ function mergeTargetReference(statementSql, target, usingStart) {
|
|
|
1289
1454
|
return target.reference;
|
|
1290
1455
|
}
|
|
1291
1456
|
if (/^PARTITION\b/i.test(suffix)) {
|
|
1292
|
-
const
|
|
1293
|
-
if (
|
|
1457
|
+
const open2 = suffix.indexOf("(");
|
|
1458
|
+
if (open2 === -1) {
|
|
1294
1459
|
return void 0;
|
|
1295
1460
|
}
|
|
1296
|
-
const close = findTopLevelChar(suffix, ")",
|
|
1461
|
+
const close = findTopLevelChar(suffix, ")", open2 + 1, suffix.length);
|
|
1297
1462
|
if (close === void 0) {
|
|
1298
1463
|
return void 0;
|
|
1299
1464
|
}
|
|
@@ -1476,13 +1641,19 @@ function dispatchWriteBackupPlan(keyword, statementSql, params) {
|
|
|
1476
1641
|
}
|
|
1477
1642
|
}
|
|
1478
1643
|
function buildWriteBackupPlan(sql, params = []) {
|
|
1479
|
-
const
|
|
1480
|
-
const
|
|
1644
|
+
const trimmed = trimStatementSql(sql);
|
|
1645
|
+
const leading = firstKeyword(trimmed);
|
|
1646
|
+
const withResolved = leading === "WITH" ? resolveWithStatement(trimmed) : void 0;
|
|
1647
|
+
const keyword = withResolved?.keyword ?? leading;
|
|
1481
1648
|
if (!isWriteKeyword(keyword)) {
|
|
1482
1649
|
return void 0;
|
|
1483
1650
|
}
|
|
1484
|
-
|
|
1485
|
-
|
|
1651
|
+
const startIndex = withResolved?.index ?? 0;
|
|
1652
|
+
const statementSql = trimmed.slice(startIndex);
|
|
1653
|
+
const slicedParams = params.slice(countPlaceholders(trimmed.slice(0, startIndex)));
|
|
1654
|
+
assertParamArity(statementSql, slicedParams);
|
|
1655
|
+
const plan = dispatchWriteBackupPlan(keyword, statementSql, slicedParams);
|
|
1656
|
+
return plan === void 0 ? void 0 : { ...plan, statementSql: trimmed };
|
|
1486
1657
|
}
|
|
1487
1658
|
|
|
1488
1659
|
// src/backup.ts
|
|
@@ -1811,9 +1982,9 @@ function buildEnv(ctx, overrides = {}) {
|
|
|
1811
1982
|
env["CF_HOME"] = ctx.cfHome;
|
|
1812
1983
|
return env;
|
|
1813
1984
|
}
|
|
1814
|
-
async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
1985
|
+
async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS, maxAttempts = CF_RETRY_ATTEMPTS) {
|
|
1815
1986
|
let lastErr;
|
|
1816
|
-
for (let attempt = 0; attempt <
|
|
1987
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1817
1988
|
try {
|
|
1818
1989
|
const { stdout } = await execFileAsync(bin, args, {
|
|
1819
1990
|
env,
|
|
@@ -1832,18 +2003,24 @@ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
1832
2003
|
if (isEnoent || !isTimeout && !isNetworkFlake) {
|
|
1833
2004
|
throw err;
|
|
1834
2005
|
}
|
|
1835
|
-
if (attempt <
|
|
2006
|
+
if (attempt < maxAttempts - 1) {
|
|
1836
2007
|
await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
|
|
1837
2008
|
}
|
|
1838
2009
|
}
|
|
1839
2010
|
}
|
|
1840
2011
|
throw lastErr;
|
|
1841
2012
|
}
|
|
1842
|
-
async function runCf(args, ctx, overrides = {}) {
|
|
2013
|
+
async function runCf(args, ctx, overrides = {}, execOptions = {}) {
|
|
1843
2014
|
const { bin, argsPrefix } = resolveCfBin();
|
|
1844
2015
|
const env = buildEnv(ctx, overrides);
|
|
1845
2016
|
try {
|
|
1846
|
-
return await execWithRetries(
|
|
2017
|
+
return await execWithRetries(
|
|
2018
|
+
bin,
|
|
2019
|
+
[...argsPrefix, ...args],
|
|
2020
|
+
env,
|
|
2021
|
+
execOptions.timeoutMs,
|
|
2022
|
+
execOptions.maxAttempts
|
|
2023
|
+
);
|
|
1847
2024
|
} catch (lastErr) {
|
|
1848
2025
|
const e = lastErr;
|
|
1849
2026
|
const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
|
|
@@ -1873,15 +2050,26 @@ async function cfEnvDirect(appName) {
|
|
|
1873
2050
|
delete env["SAP_PASSWORD"];
|
|
1874
2051
|
return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
|
|
1875
2052
|
}
|
|
1876
|
-
async function cfApps(ctx) {
|
|
1877
|
-
return await runCf(
|
|
2053
|
+
async function cfApps(ctx, timeoutMs) {
|
|
2054
|
+
return await runCf(
|
|
2055
|
+
["apps"],
|
|
2056
|
+
ctx,
|
|
2057
|
+
{},
|
|
2058
|
+
timeoutMs === void 0 ? {} : { timeoutMs, maxAttempts: 1 }
|
|
2059
|
+
);
|
|
1878
2060
|
}
|
|
1879
|
-
async function cfAppsDirect() {
|
|
2061
|
+
async function cfAppsDirect(timeoutMs) {
|
|
1880
2062
|
const { bin, argsPrefix } = resolveCfBin();
|
|
1881
2063
|
const env = { ...process.env };
|
|
1882
2064
|
delete env["SAP_EMAIL"];
|
|
1883
2065
|
delete env["SAP_PASSWORD"];
|
|
1884
|
-
return await execWithRetries(
|
|
2066
|
+
return await execWithRetries(
|
|
2067
|
+
bin,
|
|
2068
|
+
[...argsPrefix, "apps"],
|
|
2069
|
+
env,
|
|
2070
|
+
timeoutMs,
|
|
2071
|
+
timeoutMs === void 0 ? void 0 : 1
|
|
2072
|
+
);
|
|
1885
2073
|
}
|
|
1886
2074
|
async function readCurrentCfTarget() {
|
|
1887
2075
|
const { bin, argsPrefix } = resolveCfBin();
|
|
@@ -2788,6 +2976,13 @@ async function connectHdb(params) {
|
|
|
2788
2976
|
password: params.password,
|
|
2789
2977
|
ca: params.certificate,
|
|
2790
2978
|
useTLS: true,
|
|
2979
|
+
// HANA Cloud can reactively redirect a connection mid-auth to an
|
|
2980
|
+
// internal per-node hostname (pod-locality routing). That target is
|
|
2981
|
+
// often unreachable outside SAP's own network, and for a tunneled
|
|
2982
|
+
// connection it would silently abandon the SSH forward for a fresh,
|
|
2983
|
+
// untunneled socket. The original bound host is always a real, working
|
|
2984
|
+
// endpoint, so disabling the redirect is safe on the direct path too.
|
|
2985
|
+
disableCloudRedirect: true,
|
|
2791
2986
|
...params.servername === void 0 ? {} : { servername: params.servername }
|
|
2792
2987
|
});
|
|
2793
2988
|
await openClient(client, params.connectTimeoutMs);
|
|
@@ -2897,83 +3092,6 @@ async function appendSqlHistory(input, options = {}) {
|
|
|
2897
3092
|
// src/safety.ts
|
|
2898
3093
|
var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
|
|
2899
3094
|
var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
|
|
2900
|
-
function skipQuotedText2(sql, start) {
|
|
2901
|
-
const quote = sql[start];
|
|
2902
|
-
let index = start + 1;
|
|
2903
|
-
while (index < sql.length) {
|
|
2904
|
-
if (sql[index] === quote) {
|
|
2905
|
-
if (sql[index + 1] === quote) {
|
|
2906
|
-
index += 2;
|
|
2907
|
-
continue;
|
|
2908
|
-
}
|
|
2909
|
-
index += 1;
|
|
2910
|
-
break;
|
|
2911
|
-
}
|
|
2912
|
-
index += 1;
|
|
2913
|
-
}
|
|
2914
|
-
return index;
|
|
2915
|
-
}
|
|
2916
|
-
function skipLineComment3(sql, start) {
|
|
2917
|
-
let index = start + 2;
|
|
2918
|
-
while (index < sql.length && sql[index] !== "\n") {
|
|
2919
|
-
index += 1;
|
|
2920
|
-
}
|
|
2921
|
-
return index;
|
|
2922
|
-
}
|
|
2923
|
-
function skipBlockComment3(sql, start) {
|
|
2924
|
-
let index = start + 2;
|
|
2925
|
-
while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
2926
|
-
index += 1;
|
|
2927
|
-
}
|
|
2928
|
-
return Math.min(index + 2, sql.length);
|
|
2929
|
-
}
|
|
2930
|
-
function maskIgnoredSqlText(sql) {
|
|
2931
|
-
let masked = "";
|
|
2932
|
-
let index = 0;
|
|
2933
|
-
while (index < sql.length) {
|
|
2934
|
-
const char = sql[index];
|
|
2935
|
-
if (char === "'" || char === '"') {
|
|
2936
|
-
const end = skipQuotedText2(sql, index);
|
|
2937
|
-
masked += " ".repeat(end - index);
|
|
2938
|
-
index = end;
|
|
2939
|
-
continue;
|
|
2940
|
-
}
|
|
2941
|
-
if (char === "-" && sql[index + 1] === "-") {
|
|
2942
|
-
const end = skipLineComment3(sql, index);
|
|
2943
|
-
masked += " ".repeat(end - index);
|
|
2944
|
-
index = end;
|
|
2945
|
-
continue;
|
|
2946
|
-
}
|
|
2947
|
-
if (char === "/" && sql[index + 1] === "*") {
|
|
2948
|
-
const end = skipBlockComment3(sql, index);
|
|
2949
|
-
masked += " ".repeat(end - index);
|
|
2950
|
-
index = end;
|
|
2951
|
-
continue;
|
|
2952
|
-
}
|
|
2953
|
-
masked += char ?? "";
|
|
2954
|
-
index += 1;
|
|
2955
|
-
}
|
|
2956
|
-
return masked;
|
|
2957
|
-
}
|
|
2958
|
-
function hasTopLevelKeyword(sql, keyword) {
|
|
2959
|
-
const masked = maskIgnoredSqlText(sql);
|
|
2960
|
-
let depth = 0;
|
|
2961
|
-
for (let index = 0; index < masked.length; index += 1) {
|
|
2962
|
-
const char = masked[index];
|
|
2963
|
-
if (char === "(") {
|
|
2964
|
-
depth += 1;
|
|
2965
|
-
continue;
|
|
2966
|
-
}
|
|
2967
|
-
if (char === ")") {
|
|
2968
|
-
depth = Math.max(0, depth - 1);
|
|
2969
|
-
continue;
|
|
2970
|
-
}
|
|
2971
|
-
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))) {
|
|
2972
|
-
return true;
|
|
2973
|
-
}
|
|
2974
|
-
}
|
|
2975
|
-
return false;
|
|
2976
|
-
}
|
|
2977
3095
|
function isUnconditionalMergeDelete(sql) {
|
|
2978
3096
|
return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
|
|
2979
3097
|
}
|
|
@@ -3016,17 +3134,23 @@ function appendLimit(sql, limit) {
|
|
|
3016
3134
|
}
|
|
3017
3135
|
function inspectStatement(sql) {
|
|
3018
3136
|
const kind = classifyStatement(sql);
|
|
3019
|
-
const
|
|
3137
|
+
const leading = firstKeyword(sql);
|
|
3138
|
+
const withResolved = leading === "WITH" ? resolveWithStatement(sql) : void 0;
|
|
3139
|
+
const keyword = withResolved?.keyword ?? leading;
|
|
3140
|
+
const tail = withResolved === void 0 ? sql : sql.slice(withResolved.index);
|
|
3020
3141
|
if (kind === "ddl") {
|
|
3021
3142
|
return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
|
|
3022
3143
|
}
|
|
3023
3144
|
if (kind === "dml") {
|
|
3024
|
-
const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(
|
|
3145
|
+
const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(tail, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(tail) || keyword === "REPLACE" && isMalformedReplace(tail);
|
|
3025
3146
|
return {
|
|
3026
3147
|
kind,
|
|
3027
3148
|
destructive
|
|
3028
3149
|
};
|
|
3029
3150
|
}
|
|
3151
|
+
if (kind === "unknown") {
|
|
3152
|
+
return { kind, destructive: keyword === "CALL" };
|
|
3153
|
+
}
|
|
3030
3154
|
return { kind, destructive: false };
|
|
3031
3155
|
}
|
|
3032
3156
|
function evaluateGuard(sql, config) {
|
|
@@ -3071,13 +3195,13 @@ function applyAutoLimit(sql, limit) {
|
|
|
3071
3195
|
|
|
3072
3196
|
// src/tunnel/cache.ts
|
|
3073
3197
|
import { createHash as createHash2 } from "crypto";
|
|
3074
|
-
import { mkdir as mkdir4, readdir as readdir2, readFile as
|
|
3198
|
+
import { mkdir as mkdir4, readdir as readdir2, readFile as readFile3, rename as rename2, rm as rm5, writeFile as writeFile3 } from "fs/promises";
|
|
3075
3199
|
import { homedir as homedir4 } from "os";
|
|
3076
3200
|
import { join as join6 } from "path";
|
|
3077
3201
|
|
|
3078
3202
|
// src/tunnel/process.ts
|
|
3079
3203
|
import { spawn } from "child_process";
|
|
3080
|
-
import { mkdtemp as mkdtemp2, rm as rm4 } from "fs/promises";
|
|
3204
|
+
import { mkdtemp as mkdtemp2, open, readFile as readFile2, rm as rm4 } from "fs/promises";
|
|
3081
3205
|
import { connect as netConnect, createServer } from "net";
|
|
3082
3206
|
import { tmpdir as tmpdir2 } from "os";
|
|
3083
3207
|
import { join as join5 } from "path";
|
|
@@ -3184,8 +3308,8 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
|
|
|
3184
3308
|
child.on("exit", onAbort);
|
|
3185
3309
|
child.on("error", onAbort);
|
|
3186
3310
|
timers.poll = setInterval(() => {
|
|
3187
|
-
void probePort(localPort).then((
|
|
3188
|
-
if (
|
|
3311
|
+
void probePort(localPort).then((open2) => {
|
|
3312
|
+
if (open2 && child.pid !== void 0) {
|
|
3189
3313
|
finish({ localPort, pid: child.pid });
|
|
3190
3314
|
}
|
|
3191
3315
|
});
|
|
@@ -3193,6 +3317,36 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
|
|
|
3193
3317
|
timers.deadline = setTimeout(finish, boundMs);
|
|
3194
3318
|
});
|
|
3195
3319
|
}
|
|
3320
|
+
var STDERR_TAIL_MAX_CHARS = 4096;
|
|
3321
|
+
async function openStderrCapture() {
|
|
3322
|
+
try {
|
|
3323
|
+
const dir = await mkdtemp2(join5(tmpdir2(), "saptools-cf-hana-tunnel-log-"));
|
|
3324
|
+
const path = join5(dir, "stderr.log");
|
|
3325
|
+
const handle = await open(path, "w");
|
|
3326
|
+
return {
|
|
3327
|
+
path,
|
|
3328
|
+
fd: handle.fd,
|
|
3329
|
+
close: async () => {
|
|
3330
|
+
await handle.close().catch(() => {
|
|
3331
|
+
});
|
|
3332
|
+
await rm4(dir, { recursive: true, force: true });
|
|
3333
|
+
}
|
|
3334
|
+
};
|
|
3335
|
+
} catch {
|
|
3336
|
+
return void 0;
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
async function readStderrTail(path) {
|
|
3340
|
+
try {
|
|
3341
|
+
const content = (await readFile2(path, "utf8")).trim().replace(/\s+/g, " ");
|
|
3342
|
+
if (content.length === 0) {
|
|
3343
|
+
return void 0;
|
|
3344
|
+
}
|
|
3345
|
+
return content.length > STDERR_TAIL_MAX_CHARS ? content.slice(-STDERR_TAIL_MAX_CHARS) : content;
|
|
3346
|
+
} catch {
|
|
3347
|
+
return void 0;
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3196
3350
|
async function spawnTunnel(params, deps = {}) {
|
|
3197
3351
|
assertPositiveSafeInteger("tunnel keepalive seconds", params.keepaliveSeconds);
|
|
3198
3352
|
const remainingMs = params.deadline - Date.now();
|
|
@@ -3217,8 +3371,24 @@ async function spawnTunnel(params, deps = {}) {
|
|
|
3217
3371
|
`sleep ${String(params.keepaliveSeconds)}`
|
|
3218
3372
|
];
|
|
3219
3373
|
const env = params.cfHome === void 0 ? { ...process.env } : { ...process.env, CF_HOME: params.cfHome };
|
|
3220
|
-
const
|
|
3221
|
-
|
|
3374
|
+
const openCapture = deps.openStderrCapture ?? openStderrCapture;
|
|
3375
|
+
const capture = await openCapture();
|
|
3376
|
+
const stdio = capture === void 0 ? "ignore" : ["ignore", "ignore", capture.fd];
|
|
3377
|
+
const child = spawnProcess(bin, args, { detached: true, stdio, env });
|
|
3378
|
+
const result = await raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess);
|
|
3379
|
+
if (capture !== void 0) {
|
|
3380
|
+
if (result === void 0) {
|
|
3381
|
+
try {
|
|
3382
|
+
const tail = await readStderrTail(capture.path);
|
|
3383
|
+
if (tail !== void 0) {
|
|
3384
|
+
deps.onFailureDiagnostic?.(tail);
|
|
3385
|
+
}
|
|
3386
|
+
} catch {
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
await capture.close();
|
|
3390
|
+
}
|
|
3391
|
+
return result;
|
|
3222
3392
|
}
|
|
3223
3393
|
|
|
3224
3394
|
// src/tunnel/cache.ts
|
|
@@ -3263,7 +3433,7 @@ function isTunnelCacheRecord(value) {
|
|
|
3263
3433
|
}
|
|
3264
3434
|
async function parseTunnelCacheFile(path) {
|
|
3265
3435
|
try {
|
|
3266
|
-
const raw = JSON.parse(await
|
|
3436
|
+
const raw = JSON.parse(await readFile3(path, "utf8"));
|
|
3267
3437
|
return isTunnelCacheRecord(raw) ? raw : void 0;
|
|
3268
3438
|
} catch {
|
|
3269
3439
|
return void 0;
|
|
@@ -3481,6 +3651,9 @@ function isConnectivityFailure(error) {
|
|
|
3481
3651
|
}
|
|
3482
3652
|
return hasOpenConnectionCause(error.cause, 0);
|
|
3483
3653
|
}
|
|
3654
|
+
function isUnattributedQueryFailure(error) {
|
|
3655
|
+
return error instanceof QueryError && error.databaseCode === void 0 && error.sqlState === void 0;
|
|
3656
|
+
}
|
|
3484
3657
|
|
|
3485
3658
|
// src/tunnel/fallback.ts
|
|
3486
3659
|
var EXPIRY_SAFETY_MARGIN_MS = 3e4;
|
|
@@ -3510,30 +3683,40 @@ async function discardQuietly(work) {
|
|
|
3510
3683
|
await work.catch(() => {
|
|
3511
3684
|
});
|
|
3512
3685
|
}
|
|
3686
|
+
function shouldDiscardTunnel(error) {
|
|
3687
|
+
return isConnectivityFailure(error) || isUnattributedQueryFailure(error);
|
|
3688
|
+
}
|
|
3513
3689
|
async function tryReadyRecord(record, driver, config, directParams, cacheOptions) {
|
|
3514
3690
|
try {
|
|
3515
3691
|
const connection = await driver.connect(tunneledParams(directParams, record.localPort));
|
|
3516
3692
|
config.onTunnelStatus?.(`connected via SSH tunnel through ${record.app}`);
|
|
3517
3693
|
return connection;
|
|
3518
3694
|
} catch (error) {
|
|
3519
|
-
if (
|
|
3695
|
+
if (shouldDiscardTunnel(error)) {
|
|
3520
3696
|
await discardQuietly(evictTunnelCache(config.host, cacheOptions));
|
|
3521
3697
|
return void 0;
|
|
3522
3698
|
}
|
|
3523
3699
|
throw error;
|
|
3524
3700
|
}
|
|
3525
3701
|
}
|
|
3526
|
-
|
|
3702
|
+
function knownCandidates(targetAppName, hintApp) {
|
|
3703
|
+
return hintApp === void 0 || hintApp === targetAppName ? [targetAppName] : [targetAppName, hintApp];
|
|
3704
|
+
}
|
|
3705
|
+
async function discoverAppsStdout(ctx, timeoutMs) {
|
|
3527
3706
|
try {
|
|
3528
|
-
return ctx === void 0 ? await cfAppsDirect() : await cfApps(ctx);
|
|
3707
|
+
return ctx === void 0 ? await cfAppsDirect(timeoutMs) : await cfApps(ctx, timeoutMs);
|
|
3529
3708
|
} catch {
|
|
3530
3709
|
return;
|
|
3531
3710
|
}
|
|
3532
3711
|
}
|
|
3533
|
-
async function
|
|
3534
|
-
const
|
|
3712
|
+
async function discoverExtraCandidates(config, ctx, alreadyTried, deadline) {
|
|
3713
|
+
const remainingMs = deadline - Date.now();
|
|
3714
|
+
if (remainingMs <= 0) {
|
|
3715
|
+
return [];
|
|
3716
|
+
}
|
|
3717
|
+
const stdout = await discoverAppsStdout(ctx, remainingMs);
|
|
3535
3718
|
const base = buildCandidateList(config.appName, stdout, DEFAULT_TUNNEL_MAX_CANDIDATES);
|
|
3536
|
-
return
|
|
3719
|
+
return base.filter((app) => !alreadyTried.includes(app));
|
|
3537
3720
|
}
|
|
3538
3721
|
async function finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions) {
|
|
3539
3722
|
const readyRecord = {
|
|
@@ -3549,7 +3732,7 @@ async function finalizeCandidateConnection(app, spawned, driver, config, directP
|
|
|
3549
3732
|
config.onTunnelStatus?.(`connected via SSH tunnel through ${app}`);
|
|
3550
3733
|
return connection;
|
|
3551
3734
|
} catch (error) {
|
|
3552
|
-
if (
|
|
3735
|
+
if (shouldDiscardTunnel(error)) {
|
|
3553
3736
|
await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
|
|
3554
3737
|
killTunnelProcess(spawned.pid);
|
|
3555
3738
|
return void 0;
|
|
@@ -3566,7 +3749,8 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
|
|
|
3566
3749
|
return await tryReadyRecord(claim.record, driver, config, directParams, cacheOptions);
|
|
3567
3750
|
}
|
|
3568
3751
|
if (claim.outcome === "wait") {
|
|
3569
|
-
const
|
|
3752
|
+
const waitDeadline = Math.min(deadline, Date.now() + DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS);
|
|
3753
|
+
const ready = await waitForEstablishment(config.host, waitDeadline, void 0, cacheOptions);
|
|
3570
3754
|
return ready === void 0 ? void 0 : await tryReadyRecord(ready, driver, config, directParams, cacheOptions);
|
|
3571
3755
|
}
|
|
3572
3756
|
const spawned = await spawnTunnel(
|
|
@@ -3579,7 +3763,12 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
|
|
|
3579
3763
|
deadline,
|
|
3580
3764
|
candidateTimeoutMs: DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS
|
|
3581
3765
|
},
|
|
3582
|
-
|
|
3766
|
+
{
|
|
3767
|
+
...overrides.process,
|
|
3768
|
+
onFailureDiagnostic: (message) => {
|
|
3769
|
+
config.onTunnelStatus?.(`tunnel via ${app} failed: ${message}`);
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3583
3772
|
);
|
|
3584
3773
|
if (spawned === void 0) {
|
|
3585
3774
|
await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
|
|
@@ -3589,7 +3778,11 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
|
|
|
3589
3778
|
}
|
|
3590
3779
|
async function tryCachedTunnel(driver, config, directParams, cacheOptions) {
|
|
3591
3780
|
if (config.refreshTunnel) {
|
|
3781
|
+
const superseded = await readTunnelCacheEntry(config.host, cacheOptions);
|
|
3592
3782
|
await discardQuietly(evictTunnelCache(config.host, cacheOptions));
|
|
3783
|
+
if (superseded?.status === "ready") {
|
|
3784
|
+
(cacheOptions.killProcess ?? killTunnelProcess)(superseded.pid);
|
|
3785
|
+
}
|
|
3593
3786
|
return { connection: void 0, hintApp: void 0 };
|
|
3594
3787
|
}
|
|
3595
3788
|
const entry = await readTunnelCacheEntry(config.host, cacheOptions);
|
|
@@ -3607,8 +3800,12 @@ async function tryDirectConnect(driver, config, directParams) {
|
|
|
3607
3800
|
if (config.tunnelMode !== "auto") {
|
|
3608
3801
|
return { connection: void 0, directError: void 0 };
|
|
3609
3802
|
}
|
|
3803
|
+
const boundedDirectParams = {
|
|
3804
|
+
...directParams,
|
|
3805
|
+
connectTimeoutMs: Math.min(directParams.connectTimeoutMs, DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS)
|
|
3806
|
+
};
|
|
3610
3807
|
try {
|
|
3611
|
-
return { connection: await driver.connect(
|
|
3808
|
+
return { connection: await driver.connect(boundedDirectParams), directError: void 0 };
|
|
3612
3809
|
} catch (error) {
|
|
3613
3810
|
if (!isConnectivityFailure(error)) {
|
|
3614
3811
|
throw error;
|
|
@@ -3625,18 +3822,47 @@ function buildExhaustionError(host, directError, candidates) {
|
|
|
3625
3822
|
`Could not establish an SSH tunnel to ${host} through any candidate app (tried: ${candidates.length > 0 ? candidates.join(", ") : "none"})`
|
|
3626
3823
|
);
|
|
3627
3824
|
}
|
|
3628
|
-
async function
|
|
3629
|
-
const
|
|
3630
|
-
for (const app of candidatesTried) {
|
|
3825
|
+
async function tryCandidateList(apps, ctx, driver, config, directParams, deadline, overrides, tried) {
|
|
3826
|
+
for (const app of apps) {
|
|
3631
3827
|
if (Date.now() >= deadline) {
|
|
3632
|
-
|
|
3828
|
+
return void 0;
|
|
3633
3829
|
}
|
|
3830
|
+
tried.push(app);
|
|
3634
3831
|
const connection = await tryCandidate(app, ctx, driver, config, directParams, deadline, overrides);
|
|
3635
3832
|
if (connection !== void 0) {
|
|
3636
|
-
return
|
|
3833
|
+
return connection;
|
|
3637
3834
|
}
|
|
3638
3835
|
}
|
|
3639
|
-
return
|
|
3836
|
+
return void 0;
|
|
3837
|
+
}
|
|
3838
|
+
async function runCandidateLoop(ctx, driver, config, directParams, deadline, hintApp, overrides) {
|
|
3839
|
+
const tried = [];
|
|
3840
|
+
const known = knownCandidates(config.appName, hintApp);
|
|
3841
|
+
const knownConnection = await tryCandidateList(
|
|
3842
|
+
known,
|
|
3843
|
+
ctx,
|
|
3844
|
+
driver,
|
|
3845
|
+
config,
|
|
3846
|
+
directParams,
|
|
3847
|
+
deadline,
|
|
3848
|
+
overrides,
|
|
3849
|
+
tried
|
|
3850
|
+
);
|
|
3851
|
+
if (knownConnection !== void 0) {
|
|
3852
|
+
return { connection: knownConnection, candidatesTried: tried };
|
|
3853
|
+
}
|
|
3854
|
+
const discovered = await discoverExtraCandidates(config, ctx, tried, deadline);
|
|
3855
|
+
const discoveredConnection = await tryCandidateList(
|
|
3856
|
+
discovered,
|
|
3857
|
+
ctx,
|
|
3858
|
+
driver,
|
|
3859
|
+
config,
|
|
3860
|
+
directParams,
|
|
3861
|
+
deadline,
|
|
3862
|
+
overrides,
|
|
3863
|
+
tried
|
|
3864
|
+
);
|
|
3865
|
+
return { connection: discoveredConnection, candidatesTried: tried };
|
|
3640
3866
|
}
|
|
3641
3867
|
async function connectWithTunnelFallback(driver, config, overrides = {}) {
|
|
3642
3868
|
const directParams = directParamsOf(config);
|
|
@@ -4564,7 +4790,7 @@ function searchResultSession(session, searchTerm, options) {
|
|
|
4564
4790
|
|
|
4565
4791
|
// src/result-store.ts
|
|
4566
4792
|
import { randomBytes } from "crypto";
|
|
4567
|
-
import { mkdir as mkdir5, readFile as
|
|
4793
|
+
import { mkdir as mkdir5, readFile as readFile4, readdir as readdir3, rename as rename3, rm as rm6, writeFile as writeFile4 } from "fs/promises";
|
|
4568
4794
|
import { homedir as homedir5 } from "os";
|
|
4569
4795
|
import { join as join7 } from "path";
|
|
4570
4796
|
var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
|
|
@@ -4687,7 +4913,7 @@ function isStoredSession(value) {
|
|
|
4687
4913
|
}
|
|
4688
4914
|
async function readStoredSession(path) {
|
|
4689
4915
|
try {
|
|
4690
|
-
const parsed = JSON.parse(await
|
|
4916
|
+
const parsed = JSON.parse(await readFile4(path, "utf8"));
|
|
4691
4917
|
return isStoredSession(parsed) ? parsed : void 0;
|
|
4692
4918
|
} catch (error) {
|
|
4693
4919
|
if (error.code === "ENOENT") {
|