@saptools/cf-hana 0.2.1 → 0.3.0
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 +6 -0
- package/README.md +47 -15
- package/dist/cli.js +1025 -85
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.js +531 -57
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
package/dist/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/backup.ts
|
|
4
|
-
import { createHash, randomUUID } from "crypto";
|
|
5
4
|
import { mkdir, writeFile } from "fs/promises";
|
|
6
5
|
import { homedir } from "os";
|
|
7
6
|
import { join } from "path";
|
|
@@ -49,8 +48,17 @@ function errorMessage(error) {
|
|
|
49
48
|
return String(error);
|
|
50
49
|
}
|
|
51
50
|
|
|
51
|
+
// src/lob.ts
|
|
52
|
+
var TEXT_LOB_TYPES = /* @__PURE__ */ new Set(["CLOB", "NCLOB"]);
|
|
53
|
+
function normalizeTypeName(typeName2) {
|
|
54
|
+
return typeName2?.trim().toUpperCase() ?? "";
|
|
55
|
+
}
|
|
56
|
+
function isTextLobType(typeName2) {
|
|
57
|
+
return TEXT_LOB_TYPES.has(normalizeTypeName(typeName2));
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
// src/format.ts
|
|
53
|
-
function cellText(value, nullText) {
|
|
61
|
+
function cellText(value, nullText, column) {
|
|
54
62
|
if (value === null) {
|
|
55
63
|
return nullText;
|
|
56
64
|
}
|
|
@@ -58,14 +66,14 @@ function cellText(value, nullText) {
|
|
|
58
66
|
return value.toISOString();
|
|
59
67
|
}
|
|
60
68
|
if (Buffer.isBuffer(value)) {
|
|
61
|
-
return `0x${value.toString("hex")}`;
|
|
69
|
+
return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
|
|
62
70
|
}
|
|
63
71
|
if (typeof value === "boolean") {
|
|
64
72
|
return value ? "true" : "false";
|
|
65
73
|
}
|
|
66
74
|
return typeof value === "number" ? value.toString() : value;
|
|
67
75
|
}
|
|
68
|
-
function serializeCell(value) {
|
|
76
|
+
function serializeCell(value, column) {
|
|
69
77
|
if (value === null) {
|
|
70
78
|
return null;
|
|
71
79
|
}
|
|
@@ -73,7 +81,7 @@ function serializeCell(value) {
|
|
|
73
81
|
return value.toISOString();
|
|
74
82
|
}
|
|
75
83
|
if (Buffer.isBuffer(value)) {
|
|
76
|
-
return `0x${value.toString("hex")}`;
|
|
84
|
+
return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
|
|
77
85
|
}
|
|
78
86
|
return value;
|
|
79
87
|
}
|
|
@@ -89,7 +97,7 @@ function formatTable(result) {
|
|
|
89
97
|
}
|
|
90
98
|
const headers = result.columns.map((column) => column.name);
|
|
91
99
|
const rows = result.rows.map(
|
|
92
|
-
(row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL"))
|
|
100
|
+
(row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL", column))
|
|
93
101
|
);
|
|
94
102
|
const widths = headers.map((header, index) => {
|
|
95
103
|
const widest = rows.reduce(
|
|
@@ -107,7 +115,8 @@ function formatJson(result) {
|
|
|
107
115
|
const rows = result.rows.map((row) => {
|
|
108
116
|
const serialized = {};
|
|
109
117
|
for (const [key, value] of Object.entries(row)) {
|
|
110
|
-
|
|
118
|
+
const column = result.columns.find((item) => item.name === key);
|
|
119
|
+
serialized[key] = serializeCell(value, column);
|
|
111
120
|
}
|
|
112
121
|
return serialized;
|
|
113
122
|
});
|
|
@@ -118,7 +127,7 @@ function formatCsv(result) {
|
|
|
118
127
|
const lines = [headers.map((header) => csvEscape(header)).join(",")];
|
|
119
128
|
for (const row of result.rows) {
|
|
120
129
|
lines.push(
|
|
121
|
-
result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, ""))).join(",")
|
|
130
|
+
result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, "", column))).join(",")
|
|
122
131
|
);
|
|
123
132
|
}
|
|
124
133
|
return lines.join("\r\n");
|
|
@@ -302,6 +311,28 @@ function findTopLevelKeyword(sql, keyword, startIndex = 0) {
|
|
|
302
311
|
}
|
|
303
312
|
return void 0;
|
|
304
313
|
}
|
|
314
|
+
function findTopLevelChar(sql, target, startIndex, endIndex) {
|
|
315
|
+
let index = startIndex;
|
|
316
|
+
let depth = 0;
|
|
317
|
+
while (index < endIndex) {
|
|
318
|
+
const skipped = skipNonCode(sql, index);
|
|
319
|
+
if (skipped !== void 0) {
|
|
320
|
+
index = skipped;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const char = sql[index];
|
|
324
|
+
if (char === target && depth === 0) {
|
|
325
|
+
return index;
|
|
326
|
+
}
|
|
327
|
+
if (char === "(") {
|
|
328
|
+
depth += 1;
|
|
329
|
+
} else if (char === ")" && depth > 0) {
|
|
330
|
+
depth -= 1;
|
|
331
|
+
}
|
|
332
|
+
index += 1;
|
|
333
|
+
}
|
|
334
|
+
return void 0;
|
|
335
|
+
}
|
|
305
336
|
function selectParamsAfterWhere(statementSql, whereIndex, params) {
|
|
306
337
|
return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
|
|
307
338
|
}
|
|
@@ -339,6 +370,24 @@ function buildUpdateBackupPlan(statementSql, params) {
|
|
|
339
370
|
params
|
|
340
371
|
);
|
|
341
372
|
}
|
|
373
|
+
function buildUpsertBackupPlan(statementSql, params) {
|
|
374
|
+
const upsertIndex = findTopLevelKeyword(statementSql, "UPSERT");
|
|
375
|
+
const valuesIndex = upsertIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "VALUES", upsertIndex + "UPSERT".length);
|
|
376
|
+
if (upsertIndex === void 0 || valuesIndex === void 0) {
|
|
377
|
+
throw new QueryError("UPSERT backup requires UPSERT <target> VALUES syntax");
|
|
378
|
+
}
|
|
379
|
+
const targetStart = upsertIndex + "UPSERT".length;
|
|
380
|
+
const columnListIndex = findTopLevelChar(statementSql, "(", targetStart, valuesIndex);
|
|
381
|
+
const targetEnd = columnListIndex ?? valuesIndex;
|
|
382
|
+
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", valuesIndex + "VALUES".length);
|
|
383
|
+
return buildSelectPlan(
|
|
384
|
+
"upsert",
|
|
385
|
+
statementSql,
|
|
386
|
+
statementSql.slice(targetStart, targetEnd),
|
|
387
|
+
whereIndex,
|
|
388
|
+
params
|
|
389
|
+
);
|
|
390
|
+
}
|
|
342
391
|
function buildDeleteBackupPlan(statementSql, params) {
|
|
343
392
|
const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
|
|
344
393
|
const fromIndex = deleteIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "FROM", deleteIndex + "DELETE".length);
|
|
@@ -358,8 +407,18 @@ function buildDeleteBackupPlan(statementSql, params) {
|
|
|
358
407
|
function backupTimestamp(now) {
|
|
359
408
|
return now.toISOString().replace(/:/g, "").replace(".", "");
|
|
360
409
|
}
|
|
361
|
-
function
|
|
362
|
-
|
|
410
|
+
function backupMonth(now) {
|
|
411
|
+
const year = String(now.getUTCFullYear());
|
|
412
|
+
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
413
|
+
return `${year}${month}`;
|
|
414
|
+
}
|
|
415
|
+
function sanitizePathPart(value) {
|
|
416
|
+
const trimmed = value?.trim() ?? "";
|
|
417
|
+
const normalized = trimmed.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
418
|
+
return normalized.length > 0 ? normalized : "unknown-target";
|
|
419
|
+
}
|
|
420
|
+
function backupBaseName(input, now) {
|
|
421
|
+
return [sanitizePathPart(input.selector), input.operation, backupTimestamp(now)].join("-");
|
|
363
422
|
}
|
|
364
423
|
function cfHanaBackupRoot(saptoolsRoot) {
|
|
365
424
|
return join(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
|
|
@@ -367,33 +426,49 @@ function cfHanaBackupRoot(saptoolsRoot) {
|
|
|
367
426
|
function buildWriteBackupPlan(sql, params = []) {
|
|
368
427
|
const statementSql = trimStatementSql(sql);
|
|
369
428
|
const keyword = firstKeyword(statementSql);
|
|
370
|
-
if (keyword !== "UPDATE" && keyword !== "DELETE") {
|
|
429
|
+
if (keyword !== "UPDATE" && keyword !== "UPSERT" && keyword !== "DELETE") {
|
|
371
430
|
return void 0;
|
|
372
431
|
}
|
|
373
432
|
assertParamArity(statementSql, params);
|
|
374
433
|
if (keyword === "UPDATE") {
|
|
375
434
|
return buildUpdateBackupPlan(statementSql, params);
|
|
376
435
|
}
|
|
436
|
+
if (keyword === "UPSERT") {
|
|
437
|
+
return buildUpsertBackupPlan(statementSql, params);
|
|
438
|
+
}
|
|
377
439
|
return buildDeleteBackupPlan(statementSql, params);
|
|
378
440
|
}
|
|
379
441
|
async function writeSqlBackup(input, options = {}) {
|
|
380
442
|
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
381
|
-
const directory = join(
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
);
|
|
385
|
-
const
|
|
386
|
-
const
|
|
443
|
+
const directory = join(cfHanaBackupRoot(options.saptoolsRoot), backupMonth(now));
|
|
444
|
+
const baseName = backupBaseName(input, now);
|
|
445
|
+
const statementPath = join(directory, `${baseName}.statement.sql`);
|
|
446
|
+
const backupPath = join(directory, `${baseName}.sql`);
|
|
447
|
+
const metadataPath = join(directory, `${baseName}.json`);
|
|
448
|
+
const metadata = {
|
|
449
|
+
selector: input.selector ?? null,
|
|
450
|
+
operation: input.operation,
|
|
451
|
+
statementPath,
|
|
452
|
+
backupPath,
|
|
453
|
+
rowCount: input.result.rowCount,
|
|
454
|
+
createdAt: now.toISOString()
|
|
455
|
+
};
|
|
387
456
|
await mkdir(directory, { recursive: true, mode: 448 });
|
|
388
457
|
await Promise.all([
|
|
389
458
|
writeFile(statementPath, `${input.statementSql}
|
|
390
459
|
`, { encoding: "utf8", mode: 384 }),
|
|
391
|
-
writeFile(backupPath, formatCsv(input.result), { encoding: "utf8", mode: 384 })
|
|
460
|
+
writeFile(backupPath, formatCsv(input.result), { encoding: "utf8", mode: 384 }),
|
|
461
|
+
writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}
|
|
462
|
+
`, {
|
|
463
|
+
encoding: "utf8",
|
|
464
|
+
mode: 384
|
|
465
|
+
})
|
|
392
466
|
]);
|
|
393
467
|
return {
|
|
394
468
|
directory,
|
|
395
469
|
statementPath,
|
|
396
470
|
backupPath,
|
|
471
|
+
metadataPath,
|
|
397
472
|
rowCount: input.result.rowCount
|
|
398
473
|
};
|
|
399
474
|
}
|
|
@@ -497,6 +572,18 @@ async function listTables(connection, schema) {
|
|
|
497
572
|
rowCount: void 0
|
|
498
573
|
}));
|
|
499
574
|
}
|
|
575
|
+
async function listCatalogObjects(connection, schema) {
|
|
576
|
+
const result = await connection.query(
|
|
577
|
+
"SELECT SCHEMA_NAME, TABLE_NAME AS OBJECT_NAME, 'TABLE' AS OBJECT_TYPE FROM SYS.TABLES WHERE SCHEMA_NAME = ? UNION ALL SELECT SCHEMA_NAME, VIEW_NAME AS OBJECT_NAME, 'VIEW' AS OBJECT_TYPE FROM SYS.VIEWS WHERE SCHEMA_NAME = ? ORDER BY OBJECT_NAME",
|
|
578
|
+
[schema, schema],
|
|
579
|
+
{ autoLimit: false }
|
|
580
|
+
);
|
|
581
|
+
return result.rows.map((row) => ({
|
|
582
|
+
schema: row.SCHEMA_NAME,
|
|
583
|
+
name: row.OBJECT_NAME,
|
|
584
|
+
type: row.OBJECT_TYPE
|
|
585
|
+
}));
|
|
586
|
+
}
|
|
500
587
|
async function listColumns(connection, schema, table) {
|
|
501
588
|
const result = await connection.query(
|
|
502
589
|
"SELECT COLUMN_NAME, DATA_TYPE_NAME, LENGTH, SCALE, IS_NULLABLE, POSITION FROM SYS.TABLE_COLUMNS WHERE SCHEMA_NAME = ? AND TABLE_NAME = ? ORDER BY POSITION",
|
|
@@ -514,7 +601,7 @@ async function listColumns(connection, schema, table) {
|
|
|
514
601
|
}
|
|
515
602
|
|
|
516
603
|
// src/config.ts
|
|
517
|
-
var CLI_VERSION = "0.
|
|
604
|
+
var CLI_VERSION = "0.3.0";
|
|
518
605
|
var ENV_PREFIX = "CF_HANA";
|
|
519
606
|
var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
|
|
520
607
|
var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
@@ -542,44 +629,381 @@ function readSapCredentials(overrides) {
|
|
|
542
629
|
return { email, password };
|
|
543
630
|
}
|
|
544
631
|
|
|
545
|
-
// src/
|
|
546
|
-
import {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
632
|
+
// src/cf.ts
|
|
633
|
+
import { execFile } from "child_process";
|
|
634
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
635
|
+
import { tmpdir } from "os";
|
|
636
|
+
import { join as join2 } from "path";
|
|
637
|
+
import { promisify } from "util";
|
|
638
|
+
var execFileAsync = promisify(execFile);
|
|
639
|
+
var MAX_BUFFER = 16 * 1024 * 1024;
|
|
640
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
641
|
+
var CF_RETRY_ATTEMPTS = 3;
|
|
642
|
+
var CF_RETRY_BASE_DELAY_MS = 120;
|
|
643
|
+
var REGION_API_MAP = {
|
|
644
|
+
ae01: "https://api.cf.ae01.hana.ondemand.com",
|
|
645
|
+
ap01: "https://api.cf.ap01.hana.ondemand.com",
|
|
646
|
+
ap10: "https://api.cf.ap10.hana.ondemand.com",
|
|
647
|
+
ap11: "https://api.cf.ap11.hana.ondemand.com",
|
|
648
|
+
ap12: "https://api.cf.ap12.hana.ondemand.com",
|
|
649
|
+
ap20: "https://api.cf.ap20.hana.ondemand.com",
|
|
650
|
+
ap21: "https://api.cf.ap21.hana.ondemand.com",
|
|
651
|
+
ap30: "https://api.cf.ap30.hana.ondemand.com",
|
|
652
|
+
br10: "https://api.cf.br10.hana.ondemand.com",
|
|
653
|
+
br20: "https://api.cf.br20.hana.ondemand.com",
|
|
654
|
+
br30: "https://api.cf.br30.hana.ondemand.com",
|
|
655
|
+
ca10: "https://api.cf.ca10.hana.ondemand.com",
|
|
656
|
+
ca20: "https://api.cf.ca20.hana.ondemand.com",
|
|
657
|
+
ch20: "https://api.cf.ch20.hana.ondemand.com",
|
|
658
|
+
eu10: "https://api.cf.eu10.hana.ondemand.com",
|
|
659
|
+
eu11: "https://api.cf.eu11.hana.ondemand.com",
|
|
660
|
+
eu12: "https://api.cf.eu12.hana.ondemand.com",
|
|
661
|
+
eu20: "https://api.cf.eu20.hana.ondemand.com",
|
|
662
|
+
eu21: "https://api.cf.eu21.hana.ondemand.com",
|
|
663
|
+
eu30: "https://api.cf.eu30.hana.ondemand.com",
|
|
664
|
+
"eu10-002": "https://api.cf.eu10-002.hana.ondemand.com",
|
|
665
|
+
jp10: "https://api.cf.jp10.hana.ondemand.com",
|
|
666
|
+
jp20: "https://api.cf.jp20.hana.ondemand.com",
|
|
667
|
+
jp30: "https://api.cf.jp30.hana.ondemand.com",
|
|
668
|
+
us10: "https://api.cf.us10.hana.ondemand.com",
|
|
669
|
+
us11: "https://api.cf.us11.hana.ondemand.com",
|
|
670
|
+
us20: "https://api.cf.us20.hana.ondemand.com",
|
|
671
|
+
us21: "https://api.cf.us21.hana.ondemand.com",
|
|
672
|
+
us30: "https://api.cf.us30.hana.ondemand.com",
|
|
673
|
+
in30: "https://api.cf.in30.hana.ondemand.com"
|
|
674
|
+
};
|
|
675
|
+
function getApiEndpointForRegion(regionKey) {
|
|
676
|
+
return REGION_API_MAP[regionKey];
|
|
677
|
+
}
|
|
678
|
+
function getRegionKeyForApi(apiEndpoint) {
|
|
679
|
+
const norm = apiEndpoint.trim().replace(/\/+$/, "").toLowerCase();
|
|
680
|
+
for (const [key, endpoint] of Object.entries(REGION_API_MAP)) {
|
|
681
|
+
if (endpoint.toLowerCase() === norm) {
|
|
682
|
+
return key;
|
|
557
683
|
}
|
|
558
684
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
685
|
+
return void 0;
|
|
686
|
+
}
|
|
687
|
+
async function withCfSession(work) {
|
|
688
|
+
const cfHome = await mkdtemp(join2(tmpdir(), "saptools-cf-hana-"));
|
|
689
|
+
const ctx = { cfHome };
|
|
690
|
+
try {
|
|
691
|
+
return await work(ctx);
|
|
692
|
+
} finally {
|
|
693
|
+
await rm(cfHome, { recursive: true, force: true });
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
function resolveCfBin() {
|
|
697
|
+
const raw = process.env["CF_HANA_CF_BIN"] ?? "cf";
|
|
698
|
+
if (/\.(?:c|m)?js$/i.test(raw)) {
|
|
699
|
+
return { bin: process.execPath, argsPrefix: [raw] };
|
|
700
|
+
}
|
|
701
|
+
return { bin: raw, argsPrefix: [] };
|
|
702
|
+
}
|
|
703
|
+
function buildEnv(ctx, overrides = {}) {
|
|
704
|
+
const env = { ...process.env, ...overrides };
|
|
705
|
+
delete env["SAP_EMAIL"];
|
|
706
|
+
delete env["SAP_PASSWORD"];
|
|
707
|
+
env["CF_HOME"] = ctx.cfHome;
|
|
708
|
+
return env;
|
|
709
|
+
}
|
|
710
|
+
async function runCf(args, ctx, overrides = {}) {
|
|
711
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
712
|
+
const env = buildEnv(ctx, overrides);
|
|
713
|
+
let lastErr;
|
|
714
|
+
for (let attempt = 0; attempt < CF_RETRY_ATTEMPTS; attempt++) {
|
|
715
|
+
try {
|
|
716
|
+
const { stdout } = await execFileAsync(bin, [...argsPrefix, ...args], {
|
|
717
|
+
env,
|
|
718
|
+
maxBuffer: MAX_BUFFER,
|
|
719
|
+
timeout: DEFAULT_TIMEOUT_MS
|
|
720
|
+
});
|
|
721
|
+
return stdout;
|
|
722
|
+
} catch (err) {
|
|
723
|
+
lastErr = err;
|
|
724
|
+
if (attempt < CF_RETRY_ATTEMPTS - 1) {
|
|
725
|
+
await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const e = lastErr;
|
|
730
|
+
const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
|
|
731
|
+
const cmd = args[0] === "auth" ? "cf auth" : `cf ${args.join(" ")}`;
|
|
732
|
+
throw new Error(`${cmd} failed: ${detail}`.trim(), { cause: lastErr });
|
|
733
|
+
}
|
|
734
|
+
async function cfApi(apiEndpoint, ctx) {
|
|
735
|
+
await runCf(["api", apiEndpoint], ctx);
|
|
736
|
+
}
|
|
737
|
+
async function cfAuth(email, password, ctx) {
|
|
738
|
+
await runCf(["auth"], ctx, {
|
|
739
|
+
CF_USERNAME: email,
|
|
740
|
+
CF_PASSWORD: password
|
|
562
741
|
});
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
742
|
+
}
|
|
743
|
+
async function cfTargetSpace(orgName, spaceName, ctx) {
|
|
744
|
+
await runCf(["target", "-o", orgName, "-s", spaceName], ctx);
|
|
745
|
+
}
|
|
746
|
+
async function cfEnv(appName, ctx) {
|
|
747
|
+
return await runCf(["env", appName], ctx);
|
|
748
|
+
}
|
|
749
|
+
async function cfEnvDirect(appName) {
|
|
750
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
751
|
+
const env = { ...process.env };
|
|
752
|
+
delete env["SAP_EMAIL"];
|
|
753
|
+
delete env["SAP_PASSWORD"];
|
|
754
|
+
const { stdout } = await execFileAsync(bin, [...argsPrefix, "env", appName], {
|
|
755
|
+
env,
|
|
756
|
+
maxBuffer: MAX_BUFFER,
|
|
757
|
+
timeout: DEFAULT_TIMEOUT_MS
|
|
758
|
+
});
|
|
759
|
+
return stdout;
|
|
760
|
+
}
|
|
761
|
+
async function readCurrentCfTarget() {
|
|
762
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
763
|
+
const env = { ...process.env };
|
|
764
|
+
delete env["SAP_EMAIL"];
|
|
765
|
+
delete env["SAP_PASSWORD"];
|
|
766
|
+
try {
|
|
767
|
+
const { stdout } = await execFileAsync(bin, [...argsPrefix, "target"], {
|
|
768
|
+
env,
|
|
769
|
+
maxBuffer: MAX_BUFFER,
|
|
770
|
+
timeout: 1e4
|
|
771
|
+
});
|
|
772
|
+
return parseCfTargetOutput(stdout);
|
|
773
|
+
} catch {
|
|
774
|
+
return void 0;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
function parseCfTargetOutput(stdout) {
|
|
778
|
+
const fields = parseTargetFields(stdout);
|
|
779
|
+
const api = fields.get("api endpoint");
|
|
780
|
+
const org = fields.get("org");
|
|
781
|
+
const space = fields.get("space");
|
|
782
|
+
if (!api || !org || !space) {
|
|
783
|
+
return void 0;
|
|
784
|
+
}
|
|
785
|
+
const regionKey = getRegionKeyForApi(api);
|
|
786
|
+
return {
|
|
787
|
+
apiEndpoint: api,
|
|
788
|
+
orgName: org,
|
|
789
|
+
spaceName: space,
|
|
790
|
+
...regionKey ? { regionKey } : {}
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
function parseTargetFields(stdout) {
|
|
794
|
+
const map = /* @__PURE__ */ new Map();
|
|
795
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
796
|
+
const idx = line.indexOf(":");
|
|
797
|
+
if (idx < 0) {
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
801
|
+
const val = line.slice(idx + 1).trim();
|
|
802
|
+
if (key && val) {
|
|
803
|
+
map.set(key, val);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return map;
|
|
807
|
+
}
|
|
808
|
+
function formatCurrentCfAppSelector(target, appName) {
|
|
809
|
+
const name = appName.trim();
|
|
810
|
+
if (!name) {
|
|
811
|
+
throw new Error("App name is required.");
|
|
812
|
+
}
|
|
813
|
+
if (!target.regionKey) {
|
|
814
|
+
throw new Error(
|
|
815
|
+
`Current CF API endpoint "${target.apiEndpoint}" does not match a known SAP region. Pass a full region/org/space/app selector.`
|
|
566
816
|
);
|
|
567
817
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
818
|
+
return `${target.regionKey}/${target.orgName}/${target.spaceName}/${name}`;
|
|
819
|
+
}
|
|
820
|
+
function extractVcapSection(stdout) {
|
|
821
|
+
const start = stdout.indexOf("VCAP_SERVICES:");
|
|
822
|
+
if (start === -1) {
|
|
823
|
+
throw new Error("VCAP_SERVICES section not found in cf env output");
|
|
824
|
+
}
|
|
825
|
+
const after = stdout.slice(start + "VCAP_SERVICES:".length);
|
|
826
|
+
const end = after.indexOf("VCAP_APPLICATION:");
|
|
827
|
+
const block = end === -1 ? after : after.slice(0, end);
|
|
828
|
+
return block.trim();
|
|
829
|
+
}
|
|
830
|
+
function isRecord(v) {
|
|
831
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
832
|
+
}
|
|
833
|
+
function assertCreds(raw) {
|
|
834
|
+
if (!isRecord(raw)) {
|
|
835
|
+
throw new Error("HANA credentials must be an object");
|
|
836
|
+
}
|
|
837
|
+
const req = ["host", "port", "user", "password", "schema", "hdi_user", "hdi_password", "url", "database_id", "certificate"];
|
|
838
|
+
for (const k of req) {
|
|
839
|
+
if (typeof raw[k] !== "string") {
|
|
840
|
+
throw new Error(`Missing/invalid HANA credential field: "${k}"`);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return raw;
|
|
844
|
+
}
|
|
845
|
+
function mapCreds(raw) {
|
|
846
|
+
return {
|
|
847
|
+
host: raw.host,
|
|
848
|
+
port: raw.port,
|
|
849
|
+
user: raw.user,
|
|
850
|
+
password: raw.password,
|
|
851
|
+
schema: raw.schema,
|
|
852
|
+
hdiUser: raw.hdi_user,
|
|
853
|
+
hdiPassword: raw.hdi_password,
|
|
854
|
+
...raw.url ? { url: raw.url } : {},
|
|
855
|
+
databaseId: raw.database_id,
|
|
856
|
+
certificate: raw.certificate
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
function extractHanaBindingsFromCfEnv(stdout) {
|
|
860
|
+
const jsonText = extractVcapSection(stdout);
|
|
861
|
+
let vcap;
|
|
862
|
+
try {
|
|
863
|
+
vcap = JSON.parse(jsonText);
|
|
864
|
+
} catch {
|
|
865
|
+
throw new Error("VCAP_SERVICES is not valid JSON");
|
|
866
|
+
}
|
|
867
|
+
if (!isRecord(vcap)) {
|
|
868
|
+
throw new Error("VCAP_SERVICES must be an object");
|
|
869
|
+
}
|
|
870
|
+
const hana = vcap["hana"];
|
|
871
|
+
if (hana === void 0) {
|
|
872
|
+
return [];
|
|
873
|
+
}
|
|
874
|
+
if (!Array.isArray(hana)) {
|
|
875
|
+
throw new Error("VCAP_SERVICES.hana must be an array when present");
|
|
876
|
+
}
|
|
877
|
+
return hana.map((b) => {
|
|
878
|
+
if (!isRecord(b)) {
|
|
879
|
+
throw new Error("HANA binding must be an object");
|
|
880
|
+
}
|
|
881
|
+
const credsRaw = assertCreds(b["credentials"]);
|
|
882
|
+
const name = typeof b["name"] === "string" ? b["name"] : void 0;
|
|
883
|
+
return {
|
|
884
|
+
...name ? { name } : {},
|
|
885
|
+
credentials: mapCreds(credsRaw)
|
|
886
|
+
};
|
|
572
887
|
});
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
888
|
+
}
|
|
889
|
+
function classifyCfError(stderr = "", stdout = "") {
|
|
890
|
+
const text = `${stderr} ${stdout}`.toLowerCase();
|
|
891
|
+
if (text.includes("not logged in") || text.includes("unauthorized") || text.includes("forbidden") || text.includes("credentials were rejected") || text.includes("authentication failed") || text.includes("cf auth") || text.includes("token") || text.includes("expired") || text.includes("session")) {
|
|
892
|
+
return { isAuthError: true, reason: "auth/session issue" };
|
|
893
|
+
}
|
|
894
|
+
return { isAuthError: false, reason: "" };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// src/credentials.ts
|
|
898
|
+
async function resolveAppBindings(rawSelector, options) {
|
|
899
|
+
const selector = rawSelector.trim();
|
|
900
|
+
if (!selector) {
|
|
901
|
+
throw new CfHanaError("CONFIG", "App selector is required");
|
|
902
|
+
}
|
|
903
|
+
let target;
|
|
904
|
+
const isBare = !selector.includes("/");
|
|
905
|
+
if (isBare) {
|
|
906
|
+
const current = await readCurrentCfTarget();
|
|
907
|
+
if (!current) {
|
|
908
|
+
throw new CfHanaError(
|
|
909
|
+
"CONFIG",
|
|
910
|
+
"No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector."
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
const displaySelector = current.regionKey ? formatCurrentCfAppSelector(current, selector) : `current/${current.orgName}/${current.spaceName}/${selector}`;
|
|
914
|
+
target = {
|
|
915
|
+
selector: displaySelector,
|
|
916
|
+
apiEndpoint: current.regionKey ? getApiEndpointForRegion(current.regionKey) : void 0,
|
|
917
|
+
orgName: current.orgName,
|
|
918
|
+
spaceName: current.spaceName,
|
|
919
|
+
appName: selector
|
|
920
|
+
};
|
|
921
|
+
} else {
|
|
922
|
+
const parts = selector.split("/").map((p) => p.trim());
|
|
923
|
+
if (parts.length !== 4 || !parts[0] || !parts[1] || !parts[2] || !parts[3]) {
|
|
924
|
+
throw new CfHanaError(
|
|
925
|
+
"CONFIG",
|
|
926
|
+
`Invalid selector "${selector}". Use region/org/space/app or a bare app name.`
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
const [regionKey, orgName, spaceName, appName] = parts;
|
|
930
|
+
const apiEndpoint = getApiEndpointForRegion(regionKey);
|
|
931
|
+
if (!apiEndpoint) {
|
|
932
|
+
throw new CfHanaError(
|
|
933
|
+
"CONFIG",
|
|
934
|
+
`Unknown region key "${regionKey}". Use a known region or the current CF target.`
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
target = { selector, apiEndpoint, orgName, spaceName, appName };
|
|
938
|
+
}
|
|
939
|
+
let bindings;
|
|
940
|
+
const source = "live";
|
|
941
|
+
if (isBare) {
|
|
942
|
+
try {
|
|
943
|
+
const stdout = await cfEnvDirect(target.appName);
|
|
944
|
+
bindings = extractHanaBindingsFromCfEnv(stdout);
|
|
945
|
+
} catch (directError) {
|
|
946
|
+
let stderr = "";
|
|
947
|
+
if (directError && typeof directError === "object") {
|
|
948
|
+
const e = directError;
|
|
949
|
+
stderr = (typeof e.stderr === "string" ? e.stderr : "") || (typeof e.message === "string" ? e.message : "");
|
|
950
|
+
}
|
|
951
|
+
const classified = classifyCfError(stderr);
|
|
952
|
+
if (classified.isAuthError) {
|
|
953
|
+
const sap = readSapCredentials({ email: options.email, password: options.password });
|
|
954
|
+
if (!sap) {
|
|
955
|
+
throw new CredentialsNotFoundError(
|
|
956
|
+
`Current CF session problem for bare app "${target.appName}" (${classified.reason}).
|
|
957
|
+
Run "cf login" + "cf target", or provide SAP_EMAIL + SAP_PASSWORD.`,
|
|
958
|
+
{ cause: directError }
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
if (!target.apiEndpoint) {
|
|
962
|
+
throw new CfHanaError("CONFIG", "Cannot determine API endpoint for fallback auth.");
|
|
963
|
+
}
|
|
964
|
+
const api = target.apiEndpoint;
|
|
965
|
+
bindings = await withCfSession(async (ctx) => {
|
|
966
|
+
await cfApi(api, ctx);
|
|
967
|
+
await cfAuth(sap.email, sap.password, ctx);
|
|
968
|
+
await cfTargetSpace(target.orgName, target.spaceName, ctx);
|
|
969
|
+
const stdout = await cfEnv(target.appName, ctx);
|
|
970
|
+
return extractHanaBindingsFromCfEnv(stdout);
|
|
971
|
+
});
|
|
972
|
+
} else {
|
|
973
|
+
throw new CfHanaError(
|
|
974
|
+
"CONFIG",
|
|
975
|
+
`Failed to get HANA bindings for bare app "${target.appName}" using current target (org=${target.orgName}, space=${target.spaceName}). Verify with "cf target" and "cf env ${target.appName}". ${stderr ? `Details: ${stderr}` : ""}`,
|
|
976
|
+
{ cause: directError }
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
} else {
|
|
981
|
+
const sap = readSapCredentials({ email: options.email, password: options.password });
|
|
982
|
+
if (!sap) {
|
|
983
|
+
throw new CredentialsNotFoundError(
|
|
984
|
+
`SAP_EMAIL and SAP_PASSWORD are required to fetch HANA bindings for explicit selector "${selector}".`
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
if (!target.apiEndpoint) {
|
|
988
|
+
throw new CfHanaError("CONFIG", "Invalid explicit selector.");
|
|
989
|
+
}
|
|
990
|
+
const api = target.apiEndpoint;
|
|
991
|
+
bindings = await withCfSession(async (ctx) => {
|
|
992
|
+
await cfApi(api, ctx);
|
|
993
|
+
await cfAuth(sap.email, sap.password, ctx);
|
|
994
|
+
await cfTargetSpace(target.orgName, target.spaceName, ctx);
|
|
995
|
+
const stdout = await cfEnv(target.appName, ctx);
|
|
996
|
+
return extractHanaBindingsFromCfEnv(stdout);
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
if (bindings.length === 0) {
|
|
1000
|
+
throw new CredentialsNotFoundError(`App "${target.selector}" has no HANA service binding.`);
|
|
577
1001
|
}
|
|
578
1002
|
return {
|
|
579
|
-
selector:
|
|
580
|
-
appName:
|
|
581
|
-
bindings
|
|
582
|
-
source
|
|
1003
|
+
selector: target.selector,
|
|
1004
|
+
appName: target.appName,
|
|
1005
|
+
bindings,
|
|
1006
|
+
source
|
|
583
1007
|
};
|
|
584
1008
|
}
|
|
585
1009
|
function selectBinding(bindings, selector) {
|
|
@@ -629,18 +1053,63 @@ function toConnectionTarget(binding, role) {
|
|
|
629
1053
|
password: role === "hdi" ? credentials.hdiPassword : credentials.password,
|
|
630
1054
|
schema: credentials.schema,
|
|
631
1055
|
certificate: credentials.certificate,
|
|
632
|
-
databaseId: credentials.databaseId
|
|
1056
|
+
databaseId: credentials.databaseId ?? ""
|
|
633
1057
|
};
|
|
634
1058
|
}
|
|
635
1059
|
|
|
636
1060
|
// src/driver/fake.ts
|
|
637
1061
|
import { appendFile } from "fs/promises";
|
|
1062
|
+
var catalogFailureInjected = false;
|
|
1063
|
+
function catalogObjects() {
|
|
1064
|
+
return [
|
|
1065
|
+
{ SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "EXISTING_TABLE", OBJECT_TYPE: "TABLE" },
|
|
1066
|
+
{ SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "MISSING_TABLE_FIXED", OBJECT_TYPE: "TABLE" },
|
|
1067
|
+
{ SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "MISSING_TABLE_VIEW", OBJECT_TYPE: "VIEW" },
|
|
1068
|
+
{ SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "STATUS_ITEMS", OBJECT_TYPE: "TABLE" }
|
|
1069
|
+
];
|
|
1070
|
+
}
|
|
638
1071
|
function fakeExec(sql) {
|
|
639
1072
|
const kind = classifyStatement(sql);
|
|
640
1073
|
const forcedFailure = readEnv(envName("FAKE_FAIL_STATEMENT"))?.toLowerCase();
|
|
641
1074
|
if (forcedFailure === kind) {
|
|
642
1075
|
throw new Error(`fake driver forced ${kind.toUpperCase()} failure`);
|
|
643
1076
|
}
|
|
1077
|
+
const upperSql = sql.toUpperCase();
|
|
1078
|
+
if (upperSql.includes("SYS.TABLES") && upperSql.includes("SYS.VIEWS")) {
|
|
1079
|
+
if (readEnv(envName("FAKE_FAIL_CATALOG_ONCE")) === "1" && !catalogFailureInjected) {
|
|
1080
|
+
catalogFailureInjected = true;
|
|
1081
|
+
throw new QueryError("fake transient catalog metadata failure");
|
|
1082
|
+
}
|
|
1083
|
+
return {
|
|
1084
|
+
rows: catalogObjects(),
|
|
1085
|
+
columns: [
|
|
1086
|
+
{ name: "SCHEMA_NAME", typeName: "NVARCHAR" },
|
|
1087
|
+
{ name: "OBJECT_NAME", typeName: "NVARCHAR" },
|
|
1088
|
+
{ name: "OBJECT_TYPE", typeName: "NVARCHAR" }
|
|
1089
|
+
],
|
|
1090
|
+
affectedRows: 0
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
if (upperSql.includes("MISSING_TABLE") || upperSql.includes("MISSING_TABLES")) {
|
|
1094
|
+
throw new QueryError("invalid table name: MISSING_TABLE", { sqlState: "42S02" });
|
|
1095
|
+
}
|
|
1096
|
+
if (sql.toUpperCase().includes("LOB_FIXTURE")) {
|
|
1097
|
+
return {
|
|
1098
|
+
rows: [
|
|
1099
|
+
{
|
|
1100
|
+
LOG_CONTENT: Buffer.from("Example log entry", "utf8"),
|
|
1101
|
+
CLOB_CONTENT: Buffer.from("Clob log entry", "utf8"),
|
|
1102
|
+
PAYLOAD: Buffer.from([0, 1, 2, 255])
|
|
1103
|
+
}
|
|
1104
|
+
],
|
|
1105
|
+
columns: [
|
|
1106
|
+
{ name: "LOG_CONTENT", typeName: "NCLOB" },
|
|
1107
|
+
{ name: "CLOB_CONTENT", typeName: "CLOB" },
|
|
1108
|
+
{ name: "PAYLOAD", typeName: "BLOB" }
|
|
1109
|
+
],
|
|
1110
|
+
affectedRows: 0
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
644
1113
|
if (sql.toUpperCase().includes("DUMMY")) {
|
|
645
1114
|
return {
|
|
646
1115
|
rows: [{ "1": 1 }],
|
|
@@ -956,16 +1425,16 @@ function createDriver(name) {
|
|
|
956
1425
|
}
|
|
957
1426
|
|
|
958
1427
|
// src/history.ts
|
|
959
|
-
import { appendFile as appendFile2, mkdir as mkdir2, readdir, rm } from "fs/promises";
|
|
1428
|
+
import { appendFile as appendFile2, mkdir as mkdir2, readdir, rm as rm2 } from "fs/promises";
|
|
960
1429
|
import { homedir as homedir2 } from "os";
|
|
961
|
-
import { join as
|
|
1430
|
+
import { join as join3 } from "path";
|
|
962
1431
|
var SAPTOOLS_DIR_NAME2 = ".saptools";
|
|
963
1432
|
var CF_HANA_DIR_NAME2 = "cf-hana";
|
|
964
1433
|
var HISTORIES_DIR_NAME = "histories";
|
|
965
1434
|
var HISTORY_RETENTION_DAYS = 5;
|
|
966
1435
|
var HISTORY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
967
1436
|
function defaultSaptoolsRoot2() {
|
|
968
|
-
return
|
|
1437
|
+
return join3(homedir2(), SAPTOOLS_DIR_NAME2);
|
|
969
1438
|
}
|
|
970
1439
|
function padDatePart(value) {
|
|
971
1440
|
return value.toString().padStart(2, "0");
|
|
@@ -987,10 +1456,10 @@ function resolveSaptoolsRoot(root) {
|
|
|
987
1456
|
return root ?? defaultSaptoolsRoot2();
|
|
988
1457
|
}
|
|
989
1458
|
function cfHanaHistoryDirectory(saptoolsRoot) {
|
|
990
|
-
return
|
|
1459
|
+
return join3(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
|
|
991
1460
|
}
|
|
992
1461
|
function sqlHistoryFilePath(now = /* @__PURE__ */ new Date(), saptoolsRoot) {
|
|
993
|
-
return
|
|
1462
|
+
return join3(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
|
|
994
1463
|
}
|
|
995
1464
|
async function pruneSqlHistory(now, saptoolsRoot) {
|
|
996
1465
|
const historyDir = cfHanaHistoryDirectory(saptoolsRoot);
|
|
@@ -1006,7 +1475,7 @@ async function pruneSqlHistory(now, saptoolsRoot) {
|
|
|
1006
1475
|
const cutoffKey = retentionCutoffKey(now);
|
|
1007
1476
|
await Promise.all(
|
|
1008
1477
|
files.filter((file) => HISTORY_FILE_PATTERN.test(file)).filter((file) => file.slice(0, 10) < cutoffKey).map(async (file) => {
|
|
1009
|
-
await
|
|
1478
|
+
await rm2(join3(historyDir, file), { force: true });
|
|
1010
1479
|
})
|
|
1011
1480
|
);
|
|
1012
1481
|
}
|
|
@@ -1577,7 +2046,8 @@ var HanaClient = class _HanaClient {
|
|
|
1577
2046
|
return await writeSqlBackup({
|
|
1578
2047
|
operation: plan.operation,
|
|
1579
2048
|
statementSql: plan.statementSql,
|
|
1580
|
-
result
|
|
2049
|
+
result,
|
|
2050
|
+
selector: this.info.selector
|
|
1581
2051
|
});
|
|
1582
2052
|
});
|
|
1583
2053
|
}
|
|
@@ -1639,6 +2109,10 @@ var HanaClient = class _HanaClient {
|
|
|
1639
2109
|
async listTables(schema) {
|
|
1640
2110
|
return await this.pool.withConnection((connection) => listTables(connection, schema));
|
|
1641
2111
|
}
|
|
2112
|
+
/** List table and view names in a schema for typo suggestions. */
|
|
2113
|
+
async listCatalogObjects(schema) {
|
|
2114
|
+
return await this.pool.withConnection((connection) => listCatalogObjects(connection, schema));
|
|
2115
|
+
}
|
|
1642
2116
|
/** List the columns of a table. */
|
|
1643
2117
|
async listColumns(schema, table) {
|
|
1644
2118
|
return await this.pool.withConnection(
|