@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/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
- serialized[key] = serializeCell(value);
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 backupHash(statementSql) {
362
- return createHash("sha256").update(statementSql).update("\0").update(randomUUID()).digest("hex").slice(0, 12);
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
- cfHanaBackupRoot(options.saptoolsRoot),
383
- `${backupTimestamp(now)}-${input.operation}-${backupHash(input.statementSql)}`
384
- );
385
- const statementPath = join(directory, "statement.sql");
386
- const backupPath = join(directory, "backup.csv");
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.2.1";
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/credentials.ts
546
- import { fetchAppDbBindings, readDbAppView } from "@saptools/cf-sync";
547
- async function resolveAppBindings(selector, options) {
548
- if (options.refresh !== true) {
549
- const view = await readDbAppView(selector);
550
- if (view !== void 0 && view.entry.bindings.length > 0) {
551
- return {
552
- selector: view.entry.selector,
553
- appName: view.entry.appName,
554
- bindings: view.entry.bindings,
555
- source: "cache"
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
- const credentials = readSapCredentials({
560
- email: options.email,
561
- password: options.password
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
- if (credentials === void 0) {
564
- throw new CredentialsNotFoundError(
565
- `No cached HANA credentials for "${selector}". Run \`cf-sync db-sync ${selector}\` first, or set SAP_EMAIL and SAP_PASSWORD to fetch them live.`
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
- const fetched = await fetchAppDbBindings({
569
- selector,
570
- email: credentials.email,
571
- password: credentials.password
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
- if (fetched.bindings.length === 0) {
574
- throw new CredentialsNotFoundError(
575
- `App "${fetched.selector}" has no HANA service binding.`
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: fetched.selector,
580
- appName: fetched.appName,
581
- bindings: fetched.bindings,
582
- source: "fresh"
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 join2 } from "path";
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 join2(homedir2(), SAPTOOLS_DIR_NAME2);
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 join2(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
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 join2(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
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 rm(join2(historyDir, file), { force: true });
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(