@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/cli.js CHANGED
@@ -1,11 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { formatCurrentCfAppSelector, readCurrentCfTarget } from "@saptools/cf-sync";
5
4
  import { Command } from "commander";
6
5
 
7
6
  // src/backup.ts
8
- import { createHash, randomUUID } from "crypto";
9
7
  import { mkdir, writeFile } from "fs/promises";
10
8
  import { homedir } from "os";
11
9
  import { join } from "path";
@@ -53,6 +51,15 @@ function errorMessage(error) {
53
51
  return String(error);
54
52
  }
55
53
 
54
+ // src/lob.ts
55
+ var TEXT_LOB_TYPES = /* @__PURE__ */ new Set(["CLOB", "NCLOB"]);
56
+ function normalizeTypeName(typeName2) {
57
+ return typeName2?.trim().toUpperCase() ?? "";
58
+ }
59
+ function isTextLobType(typeName2) {
60
+ return TEXT_LOB_TYPES.has(normalizeTypeName(typeName2));
61
+ }
62
+
56
63
  // src/result-preview.ts
57
64
  function normalizePreviewChar(value) {
58
65
  return value === "\r" || value === "\n" || value === " " ? " " : value;
@@ -93,12 +100,12 @@ function scalarText(value) {
93
100
  }
94
101
  return value.toString();
95
102
  }
96
- function previewCell(value, limit) {
103
+ function previewCell(value, limit, typeName2) {
97
104
  if (value === null) {
98
105
  return { text: "", truncated: false, originalLength: 0, unit: "chars" };
99
106
  }
100
107
  if (Buffer.isBuffer(value)) {
101
- return previewBuffer(value, limit);
108
+ return isTextLobType(typeName2) ? previewText(value.toString("utf8"), limit) : previewBuffer(value, limit);
102
109
  }
103
110
  if (typeof value === "string") {
104
111
  return previewText(value, limit);
@@ -107,7 +114,7 @@ function previewCell(value, limit) {
107
114
  }
108
115
 
109
116
  // src/format.ts
110
- function cellText(value, nullText) {
117
+ function cellText(value, nullText, column) {
111
118
  if (value === null) {
112
119
  return nullText;
113
120
  }
@@ -115,14 +122,14 @@ function cellText(value, nullText) {
115
122
  return value.toISOString();
116
123
  }
117
124
  if (Buffer.isBuffer(value)) {
118
- return `0x${value.toString("hex")}`;
125
+ return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
119
126
  }
120
127
  if (typeof value === "boolean") {
121
128
  return value ? "true" : "false";
122
129
  }
123
130
  return typeof value === "number" ? value.toString() : value;
124
131
  }
125
- function serializeCell(value) {
132
+ function serializeCell(value, column) {
126
133
  if (value === null) {
127
134
  return null;
128
135
  }
@@ -130,7 +137,7 @@ function serializeCell(value) {
130
137
  return value.toISOString();
131
138
  }
132
139
  if (Buffer.isBuffer(value)) {
133
- return `0x${value.toString("hex")}`;
140
+ return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
134
141
  }
135
142
  return value;
136
143
  }
@@ -146,7 +153,7 @@ function formatTable(result) {
146
153
  }
147
154
  const headers = result.columns.map((column) => column.name);
148
155
  const rows = result.rows.map(
149
- (row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL"))
156
+ (row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL", column))
150
157
  );
151
158
  const widths = headers.map((header, index) => {
152
159
  const widest = rows.reduce(
@@ -164,7 +171,8 @@ function formatJson(result) {
164
171
  const rows = result.rows.map((row) => {
165
172
  const serialized = {};
166
173
  for (const [key, value] of Object.entries(row)) {
167
- serialized[key] = serializeCell(value);
174
+ const column = result.columns.find((item) => item.name === key);
175
+ serialized[key] = serializeCell(value, column);
168
176
  }
169
177
  return serialized;
170
178
  });
@@ -175,7 +183,7 @@ function formatCsv(result) {
175
183
  const lines = [headers.map((header) => csvEscape(header)).join(",")];
176
184
  for (const row of result.rows) {
177
185
  lines.push(
178
- result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, ""))).join(",")
186
+ result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, "", column))).join(",")
179
187
  );
180
188
  }
181
189
  return lines.join("\r\n");
@@ -186,7 +194,7 @@ function formatCompactCsv(result, cellLimit) {
186
194
  let truncatedCells = 0;
187
195
  for (const row of result.rows) {
188
196
  const cells = result.columns.map((column) => {
189
- const preview = previewCell(row[column.name] ?? null, cellLimit);
197
+ const preview = previewCell(row[column.name] ?? null, cellLimit, column.typeName);
190
198
  if (preview.truncated) {
191
199
  truncatedCells += 1;
192
200
  }
@@ -375,6 +383,28 @@ function findTopLevelKeyword(sql, keyword, startIndex = 0) {
375
383
  }
376
384
  return void 0;
377
385
  }
386
+ function findTopLevelChar(sql, target, startIndex, endIndex) {
387
+ let index = startIndex;
388
+ let depth = 0;
389
+ while (index < endIndex) {
390
+ const skipped = skipNonCode(sql, index);
391
+ if (skipped !== void 0) {
392
+ index = skipped;
393
+ continue;
394
+ }
395
+ const char = sql[index];
396
+ if (char === target && depth === 0) {
397
+ return index;
398
+ }
399
+ if (char === "(") {
400
+ depth += 1;
401
+ } else if (char === ")" && depth > 0) {
402
+ depth -= 1;
403
+ }
404
+ index += 1;
405
+ }
406
+ return void 0;
407
+ }
378
408
  function selectParamsAfterWhere(statementSql, whereIndex, params) {
379
409
  return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
380
410
  }
@@ -412,6 +442,24 @@ function buildUpdateBackupPlan(statementSql, params) {
412
442
  params
413
443
  );
414
444
  }
445
+ function buildUpsertBackupPlan(statementSql, params) {
446
+ const upsertIndex = findTopLevelKeyword(statementSql, "UPSERT");
447
+ const valuesIndex = upsertIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "VALUES", upsertIndex + "UPSERT".length);
448
+ if (upsertIndex === void 0 || valuesIndex === void 0) {
449
+ throw new QueryError("UPSERT backup requires UPSERT <target> VALUES syntax");
450
+ }
451
+ const targetStart = upsertIndex + "UPSERT".length;
452
+ const columnListIndex = findTopLevelChar(statementSql, "(", targetStart, valuesIndex);
453
+ const targetEnd = columnListIndex ?? valuesIndex;
454
+ const whereIndex = findTopLevelKeyword(statementSql, "WHERE", valuesIndex + "VALUES".length);
455
+ return buildSelectPlan(
456
+ "upsert",
457
+ statementSql,
458
+ statementSql.slice(targetStart, targetEnd),
459
+ whereIndex,
460
+ params
461
+ );
462
+ }
415
463
  function buildDeleteBackupPlan(statementSql, params) {
416
464
  const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
417
465
  const fromIndex = deleteIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "FROM", deleteIndex + "DELETE".length);
@@ -431,8 +479,18 @@ function buildDeleteBackupPlan(statementSql, params) {
431
479
  function backupTimestamp(now) {
432
480
  return now.toISOString().replace(/:/g, "").replace(".", "");
433
481
  }
434
- function backupHash(statementSql) {
435
- return createHash("sha256").update(statementSql).update("\0").update(randomUUID()).digest("hex").slice(0, 12);
482
+ function backupMonth(now) {
483
+ const year = String(now.getUTCFullYear());
484
+ const month = String(now.getUTCMonth() + 1).padStart(2, "0");
485
+ return `${year}${month}`;
486
+ }
487
+ function sanitizePathPart(value) {
488
+ const trimmed = value?.trim() ?? "";
489
+ const normalized = trimmed.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
490
+ return normalized.length > 0 ? normalized : "unknown-target";
491
+ }
492
+ function backupBaseName(input, now) {
493
+ return [sanitizePathPart(input.selector), input.operation, backupTimestamp(now)].join("-");
436
494
  }
437
495
  function cfHanaBackupRoot(saptoolsRoot) {
438
496
  return join(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
@@ -440,33 +498,49 @@ function cfHanaBackupRoot(saptoolsRoot) {
440
498
  function buildWriteBackupPlan(sql, params = []) {
441
499
  const statementSql = trimStatementSql(sql);
442
500
  const keyword = firstKeyword(statementSql);
443
- if (keyword !== "UPDATE" && keyword !== "DELETE") {
501
+ if (keyword !== "UPDATE" && keyword !== "UPSERT" && keyword !== "DELETE") {
444
502
  return void 0;
445
503
  }
446
504
  assertParamArity(statementSql, params);
447
505
  if (keyword === "UPDATE") {
448
506
  return buildUpdateBackupPlan(statementSql, params);
449
507
  }
508
+ if (keyword === "UPSERT") {
509
+ return buildUpsertBackupPlan(statementSql, params);
510
+ }
450
511
  return buildDeleteBackupPlan(statementSql, params);
451
512
  }
452
513
  async function writeSqlBackup(input, options = {}) {
453
514
  const now = options.now ?? /* @__PURE__ */ new Date();
454
- const directory = join(
455
- cfHanaBackupRoot(options.saptoolsRoot),
456
- `${backupTimestamp(now)}-${input.operation}-${backupHash(input.statementSql)}`
457
- );
458
- const statementPath = join(directory, "statement.sql");
459
- const backupPath = join(directory, "backup.csv");
515
+ const directory = join(cfHanaBackupRoot(options.saptoolsRoot), backupMonth(now));
516
+ const baseName = backupBaseName(input, now);
517
+ const statementPath = join(directory, `${baseName}.statement.sql`);
518
+ const backupPath = join(directory, `${baseName}.sql`);
519
+ const metadataPath = join(directory, `${baseName}.json`);
520
+ const metadata = {
521
+ selector: input.selector ?? null,
522
+ operation: input.operation,
523
+ statementPath,
524
+ backupPath,
525
+ rowCount: input.result.rowCount,
526
+ createdAt: now.toISOString()
527
+ };
460
528
  await mkdir(directory, { recursive: true, mode: 448 });
461
529
  await Promise.all([
462
530
  writeFile(statementPath, `${input.statementSql}
463
531
  `, { encoding: "utf8", mode: 384 }),
464
- writeFile(backupPath, formatCsv(input.result), { encoding: "utf8", mode: 384 })
532
+ writeFile(backupPath, formatCsv(input.result), { encoding: "utf8", mode: 384 }),
533
+ writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}
534
+ `, {
535
+ encoding: "utf8",
536
+ mode: 384
537
+ })
465
538
  ]);
466
539
  return {
467
540
  directory,
468
541
  statementPath,
469
542
  backupPath,
543
+ metadataPath,
470
544
  rowCount: input.result.rowCount
471
545
  };
472
546
  }
@@ -570,6 +644,18 @@ async function listTables(connection, schema) {
570
644
  rowCount: void 0
571
645
  }));
572
646
  }
647
+ async function listCatalogObjects(connection, schema) {
648
+ const result = await connection.query(
649
+ "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",
650
+ [schema, schema],
651
+ { autoLimit: false }
652
+ );
653
+ return result.rows.map((row) => ({
654
+ schema: row.SCHEMA_NAME,
655
+ name: row.OBJECT_NAME,
656
+ type: row.OBJECT_TYPE
657
+ }));
658
+ }
573
659
  async function listColumns(connection, schema, table) {
574
660
  const result = await connection.query(
575
661
  "SELECT COLUMN_NAME, DATA_TYPE_NAME, LENGTH, SCALE, IS_NULLABLE, POSITION FROM SYS.TABLE_COLUMNS WHERE SCHEMA_NAME = ? AND TABLE_NAME = ? ORDER BY POSITION",
@@ -588,7 +674,7 @@ async function listColumns(connection, schema, table) {
588
674
 
589
675
  // src/config.ts
590
676
  var CLI_NAME = "cf-hana";
591
- var CLI_VERSION = "0.2.1";
677
+ var CLI_VERSION = "0.3.0";
592
678
  var ENV_PREFIX = "CF_HANA";
593
679
  var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
594
680
  var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
@@ -620,44 +706,381 @@ function readSapCredentials(overrides) {
620
706
  return { email, password };
621
707
  }
622
708
 
623
- // src/credentials.ts
624
- import { fetchAppDbBindings, readDbAppView } from "@saptools/cf-sync";
625
- async function resolveAppBindings(selector, options) {
626
- if (options.refresh !== true) {
627
- const view = await readDbAppView(selector);
628
- if (view !== void 0 && view.entry.bindings.length > 0) {
629
- return {
630
- selector: view.entry.selector,
631
- appName: view.entry.appName,
632
- bindings: view.entry.bindings,
633
- source: "cache"
634
- };
709
+ // src/cf.ts
710
+ import { execFile } from "child_process";
711
+ import { mkdtemp, rm } from "fs/promises";
712
+ import { tmpdir } from "os";
713
+ import { join as join2 } from "path";
714
+ import { promisify } from "util";
715
+ var execFileAsync = promisify(execFile);
716
+ var MAX_BUFFER = 16 * 1024 * 1024;
717
+ var DEFAULT_TIMEOUT_MS = 3e4;
718
+ var CF_RETRY_ATTEMPTS = 3;
719
+ var CF_RETRY_BASE_DELAY_MS = 120;
720
+ var REGION_API_MAP = {
721
+ ae01: "https://api.cf.ae01.hana.ondemand.com",
722
+ ap01: "https://api.cf.ap01.hana.ondemand.com",
723
+ ap10: "https://api.cf.ap10.hana.ondemand.com",
724
+ ap11: "https://api.cf.ap11.hana.ondemand.com",
725
+ ap12: "https://api.cf.ap12.hana.ondemand.com",
726
+ ap20: "https://api.cf.ap20.hana.ondemand.com",
727
+ ap21: "https://api.cf.ap21.hana.ondemand.com",
728
+ ap30: "https://api.cf.ap30.hana.ondemand.com",
729
+ br10: "https://api.cf.br10.hana.ondemand.com",
730
+ br20: "https://api.cf.br20.hana.ondemand.com",
731
+ br30: "https://api.cf.br30.hana.ondemand.com",
732
+ ca10: "https://api.cf.ca10.hana.ondemand.com",
733
+ ca20: "https://api.cf.ca20.hana.ondemand.com",
734
+ ch20: "https://api.cf.ch20.hana.ondemand.com",
735
+ eu10: "https://api.cf.eu10.hana.ondemand.com",
736
+ eu11: "https://api.cf.eu11.hana.ondemand.com",
737
+ eu12: "https://api.cf.eu12.hana.ondemand.com",
738
+ eu20: "https://api.cf.eu20.hana.ondemand.com",
739
+ eu21: "https://api.cf.eu21.hana.ondemand.com",
740
+ eu30: "https://api.cf.eu30.hana.ondemand.com",
741
+ "eu10-002": "https://api.cf.eu10-002.hana.ondemand.com",
742
+ jp10: "https://api.cf.jp10.hana.ondemand.com",
743
+ jp20: "https://api.cf.jp20.hana.ondemand.com",
744
+ jp30: "https://api.cf.jp30.hana.ondemand.com",
745
+ us10: "https://api.cf.us10.hana.ondemand.com",
746
+ us11: "https://api.cf.us11.hana.ondemand.com",
747
+ us20: "https://api.cf.us20.hana.ondemand.com",
748
+ us21: "https://api.cf.us21.hana.ondemand.com",
749
+ us30: "https://api.cf.us30.hana.ondemand.com",
750
+ in30: "https://api.cf.in30.hana.ondemand.com"
751
+ };
752
+ function getApiEndpointForRegion(regionKey) {
753
+ return REGION_API_MAP[regionKey];
754
+ }
755
+ function getRegionKeyForApi(apiEndpoint) {
756
+ const norm2 = apiEndpoint.trim().replace(/\/+$/, "").toLowerCase();
757
+ for (const [key, endpoint] of Object.entries(REGION_API_MAP)) {
758
+ if (endpoint.toLowerCase() === norm2) {
759
+ return key;
760
+ }
761
+ }
762
+ return void 0;
763
+ }
764
+ async function withCfSession(work) {
765
+ const cfHome = await mkdtemp(join2(tmpdir(), "saptools-cf-hana-"));
766
+ const ctx = { cfHome };
767
+ try {
768
+ return await work(ctx);
769
+ } finally {
770
+ await rm(cfHome, { recursive: true, force: true });
771
+ }
772
+ }
773
+ function resolveCfBin() {
774
+ const raw = process.env["CF_HANA_CF_BIN"] ?? "cf";
775
+ if (/\.(?:c|m)?js$/i.test(raw)) {
776
+ return { bin: process.execPath, argsPrefix: [raw] };
777
+ }
778
+ return { bin: raw, argsPrefix: [] };
779
+ }
780
+ function buildEnv(ctx, overrides = {}) {
781
+ const env = { ...process.env, ...overrides };
782
+ delete env["SAP_EMAIL"];
783
+ delete env["SAP_PASSWORD"];
784
+ env["CF_HOME"] = ctx.cfHome;
785
+ return env;
786
+ }
787
+ async function runCf(args, ctx, overrides = {}) {
788
+ const { bin, argsPrefix } = resolveCfBin();
789
+ const env = buildEnv(ctx, overrides);
790
+ let lastErr;
791
+ for (let attempt = 0; attempt < CF_RETRY_ATTEMPTS; attempt++) {
792
+ try {
793
+ const { stdout } = await execFileAsync(bin, [...argsPrefix, ...args], {
794
+ env,
795
+ maxBuffer: MAX_BUFFER,
796
+ timeout: DEFAULT_TIMEOUT_MS
797
+ });
798
+ return stdout;
799
+ } catch (err) {
800
+ lastErr = err;
801
+ if (attempt < CF_RETRY_ATTEMPTS - 1) {
802
+ await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
803
+ }
635
804
  }
636
805
  }
637
- const credentials = readSapCredentials({
638
- email: options.email,
639
- password: options.password
806
+ const e = lastErr;
807
+ const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
808
+ const cmd = args[0] === "auth" ? "cf auth" : `cf ${args.join(" ")}`;
809
+ throw new Error(`${cmd} failed: ${detail}`.trim(), { cause: lastErr });
810
+ }
811
+ async function cfApi(apiEndpoint, ctx) {
812
+ await runCf(["api", apiEndpoint], ctx);
813
+ }
814
+ async function cfAuth(email, password, ctx) {
815
+ await runCf(["auth"], ctx, {
816
+ CF_USERNAME: email,
817
+ CF_PASSWORD: password
640
818
  });
641
- if (credentials === void 0) {
642
- throw new CredentialsNotFoundError(
643
- `No cached HANA credentials for "${selector}". Run \`cf-sync db-sync ${selector}\` first, or set SAP_EMAIL and SAP_PASSWORD to fetch them live.`
819
+ }
820
+ async function cfTargetSpace(orgName, spaceName, ctx) {
821
+ await runCf(["target", "-o", orgName, "-s", spaceName], ctx);
822
+ }
823
+ async function cfEnv(appName, ctx) {
824
+ return await runCf(["env", appName], ctx);
825
+ }
826
+ async function cfEnvDirect(appName) {
827
+ const { bin, argsPrefix } = resolveCfBin();
828
+ const env = { ...process.env };
829
+ delete env["SAP_EMAIL"];
830
+ delete env["SAP_PASSWORD"];
831
+ const { stdout } = await execFileAsync(bin, [...argsPrefix, "env", appName], {
832
+ env,
833
+ maxBuffer: MAX_BUFFER,
834
+ timeout: DEFAULT_TIMEOUT_MS
835
+ });
836
+ return stdout;
837
+ }
838
+ async function readCurrentCfTarget() {
839
+ const { bin, argsPrefix } = resolveCfBin();
840
+ const env = { ...process.env };
841
+ delete env["SAP_EMAIL"];
842
+ delete env["SAP_PASSWORD"];
843
+ try {
844
+ const { stdout } = await execFileAsync(bin, [...argsPrefix, "target"], {
845
+ env,
846
+ maxBuffer: MAX_BUFFER,
847
+ timeout: 1e4
848
+ });
849
+ return parseCfTargetOutput(stdout);
850
+ } catch {
851
+ return void 0;
852
+ }
853
+ }
854
+ function parseCfTargetOutput(stdout) {
855
+ const fields = parseTargetFields(stdout);
856
+ const api = fields.get("api endpoint");
857
+ const org = fields.get("org");
858
+ const space = fields.get("space");
859
+ if (!api || !org || !space) {
860
+ return void 0;
861
+ }
862
+ const regionKey = getRegionKeyForApi(api);
863
+ return {
864
+ apiEndpoint: api,
865
+ orgName: org,
866
+ spaceName: space,
867
+ ...regionKey ? { regionKey } : {}
868
+ };
869
+ }
870
+ function parseTargetFields(stdout) {
871
+ const map = /* @__PURE__ */ new Map();
872
+ for (const line of stdout.split(/\r?\n/)) {
873
+ const idx = line.indexOf(":");
874
+ if (idx < 0) {
875
+ continue;
876
+ }
877
+ const key = line.slice(0, idx).trim().toLowerCase();
878
+ const val = line.slice(idx + 1).trim();
879
+ if (key && val) {
880
+ map.set(key, val);
881
+ }
882
+ }
883
+ return map;
884
+ }
885
+ function formatCurrentCfAppSelector(target, appName) {
886
+ const name = appName.trim();
887
+ if (!name) {
888
+ throw new Error("App name is required.");
889
+ }
890
+ if (!target.regionKey) {
891
+ throw new Error(
892
+ `Current CF API endpoint "${target.apiEndpoint}" does not match a known SAP region. Pass a full region/org/space/app selector.`
644
893
  );
645
894
  }
646
- const fetched = await fetchAppDbBindings({
647
- selector,
648
- email: credentials.email,
649
- password: credentials.password
895
+ return `${target.regionKey}/${target.orgName}/${target.spaceName}/${name}`;
896
+ }
897
+ function extractVcapSection(stdout) {
898
+ const start = stdout.indexOf("VCAP_SERVICES:");
899
+ if (start === -1) {
900
+ throw new Error("VCAP_SERVICES section not found in cf env output");
901
+ }
902
+ const after = stdout.slice(start + "VCAP_SERVICES:".length);
903
+ const end = after.indexOf("VCAP_APPLICATION:");
904
+ const block = end === -1 ? after : after.slice(0, end);
905
+ return block.trim();
906
+ }
907
+ function isRecord(v) {
908
+ return typeof v === "object" && v !== null && !Array.isArray(v);
909
+ }
910
+ function assertCreds(raw) {
911
+ if (!isRecord(raw)) {
912
+ throw new Error("HANA credentials must be an object");
913
+ }
914
+ const req = ["host", "port", "user", "password", "schema", "hdi_user", "hdi_password", "url", "database_id", "certificate"];
915
+ for (const k of req) {
916
+ if (typeof raw[k] !== "string") {
917
+ throw new Error(`Missing/invalid HANA credential field: "${k}"`);
918
+ }
919
+ }
920
+ return raw;
921
+ }
922
+ function mapCreds(raw) {
923
+ return {
924
+ host: raw.host,
925
+ port: raw.port,
926
+ user: raw.user,
927
+ password: raw.password,
928
+ schema: raw.schema,
929
+ hdiUser: raw.hdi_user,
930
+ hdiPassword: raw.hdi_password,
931
+ ...raw.url ? { url: raw.url } : {},
932
+ databaseId: raw.database_id,
933
+ certificate: raw.certificate
934
+ };
935
+ }
936
+ function extractHanaBindingsFromCfEnv(stdout) {
937
+ const jsonText = extractVcapSection(stdout);
938
+ let vcap;
939
+ try {
940
+ vcap = JSON.parse(jsonText);
941
+ } catch {
942
+ throw new Error("VCAP_SERVICES is not valid JSON");
943
+ }
944
+ if (!isRecord(vcap)) {
945
+ throw new Error("VCAP_SERVICES must be an object");
946
+ }
947
+ const hana = vcap["hana"];
948
+ if (hana === void 0) {
949
+ return [];
950
+ }
951
+ if (!Array.isArray(hana)) {
952
+ throw new Error("VCAP_SERVICES.hana must be an array when present");
953
+ }
954
+ return hana.map((b) => {
955
+ if (!isRecord(b)) {
956
+ throw new Error("HANA binding must be an object");
957
+ }
958
+ const credsRaw = assertCreds(b["credentials"]);
959
+ const name = typeof b["name"] === "string" ? b["name"] : void 0;
960
+ return {
961
+ ...name ? { name } : {},
962
+ credentials: mapCreds(credsRaw)
963
+ };
650
964
  });
651
- if (fetched.bindings.length === 0) {
652
- throw new CredentialsNotFoundError(
653
- `App "${fetched.selector}" has no HANA service binding.`
654
- );
965
+ }
966
+ function classifyCfError(stderr = "", stdout = "") {
967
+ const text = `${stderr} ${stdout}`.toLowerCase();
968
+ 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")) {
969
+ return { isAuthError: true, reason: "auth/session issue" };
970
+ }
971
+ return { isAuthError: false, reason: "" };
972
+ }
973
+
974
+ // src/credentials.ts
975
+ async function resolveAppBindings(rawSelector, options) {
976
+ const selector = rawSelector.trim();
977
+ if (!selector) {
978
+ throw new CfHanaError("CONFIG", "App selector is required");
979
+ }
980
+ let target;
981
+ const isBare = !selector.includes("/");
982
+ if (isBare) {
983
+ const current = await readCurrentCfTarget();
984
+ if (!current) {
985
+ throw new CfHanaError(
986
+ "CONFIG",
987
+ "No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector."
988
+ );
989
+ }
990
+ const displaySelector = current.regionKey ? formatCurrentCfAppSelector(current, selector) : `current/${current.orgName}/${current.spaceName}/${selector}`;
991
+ target = {
992
+ selector: displaySelector,
993
+ apiEndpoint: current.regionKey ? getApiEndpointForRegion(current.regionKey) : void 0,
994
+ orgName: current.orgName,
995
+ spaceName: current.spaceName,
996
+ appName: selector
997
+ };
998
+ } else {
999
+ const parts = selector.split("/").map((p) => p.trim());
1000
+ if (parts.length !== 4 || !parts[0] || !parts[1] || !parts[2] || !parts[3]) {
1001
+ throw new CfHanaError(
1002
+ "CONFIG",
1003
+ `Invalid selector "${selector}". Use region/org/space/app or a bare app name.`
1004
+ );
1005
+ }
1006
+ const [regionKey, orgName, spaceName, appName] = parts;
1007
+ const apiEndpoint = getApiEndpointForRegion(regionKey);
1008
+ if (!apiEndpoint) {
1009
+ throw new CfHanaError(
1010
+ "CONFIG",
1011
+ `Unknown region key "${regionKey}". Use a known region or the current CF target.`
1012
+ );
1013
+ }
1014
+ target = { selector, apiEndpoint, orgName, spaceName, appName };
1015
+ }
1016
+ let bindings;
1017
+ const source = "live";
1018
+ if (isBare) {
1019
+ try {
1020
+ const stdout = await cfEnvDirect(target.appName);
1021
+ bindings = extractHanaBindingsFromCfEnv(stdout);
1022
+ } catch (directError) {
1023
+ let stderr = "";
1024
+ if (directError && typeof directError === "object") {
1025
+ const e = directError;
1026
+ stderr = (typeof e.stderr === "string" ? e.stderr : "") || (typeof e.message === "string" ? e.message : "");
1027
+ }
1028
+ const classified = classifyCfError(stderr);
1029
+ if (classified.isAuthError) {
1030
+ const sap = readSapCredentials({ email: options.email, password: options.password });
1031
+ if (!sap) {
1032
+ throw new CredentialsNotFoundError(
1033
+ `Current CF session problem for bare app "${target.appName}" (${classified.reason}).
1034
+ Run "cf login" + "cf target", or provide SAP_EMAIL + SAP_PASSWORD.`,
1035
+ { cause: directError }
1036
+ );
1037
+ }
1038
+ if (!target.apiEndpoint) {
1039
+ throw new CfHanaError("CONFIG", "Cannot determine API endpoint for fallback auth.");
1040
+ }
1041
+ const api = target.apiEndpoint;
1042
+ bindings = await withCfSession(async (ctx) => {
1043
+ await cfApi(api, ctx);
1044
+ await cfAuth(sap.email, sap.password, ctx);
1045
+ await cfTargetSpace(target.orgName, target.spaceName, ctx);
1046
+ const stdout = await cfEnv(target.appName, ctx);
1047
+ return extractHanaBindingsFromCfEnv(stdout);
1048
+ });
1049
+ } else {
1050
+ throw new CfHanaError(
1051
+ "CONFIG",
1052
+ `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}` : ""}`,
1053
+ { cause: directError }
1054
+ );
1055
+ }
1056
+ }
1057
+ } else {
1058
+ const sap = readSapCredentials({ email: options.email, password: options.password });
1059
+ if (!sap) {
1060
+ throw new CredentialsNotFoundError(
1061
+ `SAP_EMAIL and SAP_PASSWORD are required to fetch HANA bindings for explicit selector "${selector}".`
1062
+ );
1063
+ }
1064
+ if (!target.apiEndpoint) {
1065
+ throw new CfHanaError("CONFIG", "Invalid explicit selector.");
1066
+ }
1067
+ const api = target.apiEndpoint;
1068
+ bindings = await withCfSession(async (ctx) => {
1069
+ await cfApi(api, ctx);
1070
+ await cfAuth(sap.email, sap.password, ctx);
1071
+ await cfTargetSpace(target.orgName, target.spaceName, ctx);
1072
+ const stdout = await cfEnv(target.appName, ctx);
1073
+ return extractHanaBindingsFromCfEnv(stdout);
1074
+ });
1075
+ }
1076
+ if (bindings.length === 0) {
1077
+ throw new CredentialsNotFoundError(`App "${target.selector}" has no HANA service binding.`);
655
1078
  }
656
1079
  return {
657
- selector: fetched.selector,
658
- appName: fetched.appName,
659
- bindings: fetched.bindings,
660
- source: "fresh"
1080
+ selector: target.selector,
1081
+ appName: target.appName,
1082
+ bindings,
1083
+ source
661
1084
  };
662
1085
  }
663
1086
  function selectBinding(bindings, selector) {
@@ -707,18 +1130,63 @@ function toConnectionTarget(binding, role) {
707
1130
  password: role === "hdi" ? credentials.hdiPassword : credentials.password,
708
1131
  schema: credentials.schema,
709
1132
  certificate: credentials.certificate,
710
- databaseId: credentials.databaseId
1133
+ databaseId: credentials.databaseId ?? ""
711
1134
  };
712
1135
  }
713
1136
 
714
1137
  // src/driver/fake.ts
715
1138
  import { appendFile } from "fs/promises";
1139
+ var catalogFailureInjected = false;
1140
+ function catalogObjects() {
1141
+ return [
1142
+ { SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "EXISTING_TABLE", OBJECT_TYPE: "TABLE" },
1143
+ { SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "MISSING_TABLE_FIXED", OBJECT_TYPE: "TABLE" },
1144
+ { SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "MISSING_TABLE_VIEW", OBJECT_TYPE: "VIEW" },
1145
+ { SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "STATUS_ITEMS", OBJECT_TYPE: "TABLE" }
1146
+ ];
1147
+ }
716
1148
  function fakeExec(sql) {
717
1149
  const kind = classifyStatement(sql);
718
1150
  const forcedFailure = readEnv(envName("FAKE_FAIL_STATEMENT"))?.toLowerCase();
719
1151
  if (forcedFailure === kind) {
720
1152
  throw new Error(`fake driver forced ${kind.toUpperCase()} failure`);
721
1153
  }
1154
+ const upperSql = sql.toUpperCase();
1155
+ if (upperSql.includes("SYS.TABLES") && upperSql.includes("SYS.VIEWS")) {
1156
+ if (readEnv(envName("FAKE_FAIL_CATALOG_ONCE")) === "1" && !catalogFailureInjected) {
1157
+ catalogFailureInjected = true;
1158
+ throw new QueryError("fake transient catalog metadata failure");
1159
+ }
1160
+ return {
1161
+ rows: catalogObjects(),
1162
+ columns: [
1163
+ { name: "SCHEMA_NAME", typeName: "NVARCHAR" },
1164
+ { name: "OBJECT_NAME", typeName: "NVARCHAR" },
1165
+ { name: "OBJECT_TYPE", typeName: "NVARCHAR" }
1166
+ ],
1167
+ affectedRows: 0
1168
+ };
1169
+ }
1170
+ if (upperSql.includes("MISSING_TABLE") || upperSql.includes("MISSING_TABLES")) {
1171
+ throw new QueryError("invalid table name: MISSING_TABLE", { sqlState: "42S02" });
1172
+ }
1173
+ if (sql.toUpperCase().includes("LOB_FIXTURE")) {
1174
+ return {
1175
+ rows: [
1176
+ {
1177
+ LOG_CONTENT: Buffer.from("Example log entry", "utf8"),
1178
+ CLOB_CONTENT: Buffer.from("Clob log entry", "utf8"),
1179
+ PAYLOAD: Buffer.from([0, 1, 2, 255])
1180
+ }
1181
+ ],
1182
+ columns: [
1183
+ { name: "LOG_CONTENT", typeName: "NCLOB" },
1184
+ { name: "CLOB_CONTENT", typeName: "CLOB" },
1185
+ { name: "PAYLOAD", typeName: "BLOB" }
1186
+ ],
1187
+ affectedRows: 0
1188
+ };
1189
+ }
722
1190
  if (sql.toUpperCase().includes("DUMMY")) {
723
1191
  return {
724
1192
  rows: [{ "1": 1 }],
@@ -1034,16 +1502,16 @@ function createDriver(name) {
1034
1502
  }
1035
1503
 
1036
1504
  // src/history.ts
1037
- import { appendFile as appendFile2, mkdir as mkdir2, readdir, rm } from "fs/promises";
1505
+ import { appendFile as appendFile2, mkdir as mkdir2, readdir, rm as rm2 } from "fs/promises";
1038
1506
  import { homedir as homedir2 } from "os";
1039
- import { join as join2 } from "path";
1507
+ import { join as join3 } from "path";
1040
1508
  var SAPTOOLS_DIR_NAME2 = ".saptools";
1041
1509
  var CF_HANA_DIR_NAME2 = "cf-hana";
1042
1510
  var HISTORIES_DIR_NAME = "histories";
1043
1511
  var HISTORY_RETENTION_DAYS = 5;
1044
1512
  var HISTORY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
1045
1513
  function defaultSaptoolsRoot2() {
1046
- return join2(homedir2(), SAPTOOLS_DIR_NAME2);
1514
+ return join3(homedir2(), SAPTOOLS_DIR_NAME2);
1047
1515
  }
1048
1516
  function padDatePart(value) {
1049
1517
  return value.toString().padStart(2, "0");
@@ -1065,10 +1533,10 @@ function resolveSaptoolsRoot(root) {
1065
1533
  return root ?? defaultSaptoolsRoot2();
1066
1534
  }
1067
1535
  function cfHanaHistoryDirectory(saptoolsRoot) {
1068
- return join2(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
1536
+ return join3(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
1069
1537
  }
1070
1538
  function sqlHistoryFilePath(now = /* @__PURE__ */ new Date(), saptoolsRoot) {
1071
- return join2(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
1539
+ return join3(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
1072
1540
  }
1073
1541
  async function pruneSqlHistory(now, saptoolsRoot) {
1074
1542
  const historyDir = cfHanaHistoryDirectory(saptoolsRoot);
@@ -1084,7 +1552,7 @@ async function pruneSqlHistory(now, saptoolsRoot) {
1084
1552
  const cutoffKey = retentionCutoffKey(now);
1085
1553
  await Promise.all(
1086
1554
  files.filter((file) => HISTORY_FILE_PATTERN.test(file)).filter((file) => file.slice(0, 10) < cutoffKey).map(async (file) => {
1087
- await rm(join2(historyDir, file), { force: true });
1555
+ await rm2(join3(historyDir, file), { force: true });
1088
1556
  })
1089
1557
  );
1090
1558
  }
@@ -1655,7 +2123,8 @@ var HanaClient = class _HanaClient {
1655
2123
  return await writeSqlBackup({
1656
2124
  operation: plan.operation,
1657
2125
  statementSql: plan.statementSql,
1658
- result
2126
+ result,
2127
+ selector: this.info.selector
1659
2128
  });
1660
2129
  });
1661
2130
  }
@@ -1717,6 +2186,10 @@ var HanaClient = class _HanaClient {
1717
2186
  async listTables(schema) {
1718
2187
  return await this.pool.withConnection((connection) => listTables(connection, schema));
1719
2188
  }
2189
+ /** List table and view names in a schema for typo suggestions. */
2190
+ async listCatalogObjects(schema) {
2191
+ return await this.pool.withConnection((connection) => listCatalogObjects(connection, schema));
2192
+ }
1720
2193
  /** List the columns of a table. */
1721
2194
  async listColumns(schema, table) {
1722
2195
  return await this.pool.withConnection(
@@ -1842,12 +2315,15 @@ function textRange(value, offset, length) {
1842
2315
  }
1843
2316
  return { type: "text", originalLength: index, offset, value: selected };
1844
2317
  }
1845
- function readCellWindow(value, offset, length) {
2318
+ function readCellWindow(value, offset, length, typeName2) {
1846
2319
  if (!Number.isSafeInteger(offset) || offset < 0) {
1847
2320
  throw new CfHanaError("CONFIG", "offset must be a non-negative safe integer");
1848
2321
  }
1849
2322
  assertPositiveInteger("length", length);
1850
2323
  if (Buffer.isBuffer(value)) {
2324
+ if (isTextLobType(typeName2)) {
2325
+ return textRange(value.toString("utf8"), offset, length);
2326
+ }
1851
2327
  return {
1852
2328
  type: "binary",
1853
2329
  originalLength: value.length,
@@ -2019,18 +2495,19 @@ function jsonSearchMatches(root, term, row, column, limit, previewLength) {
2019
2495
  }
2020
2496
  return matches;
2021
2497
  }
2022
- function searchCell(value, term, row, column, limit, previewLength) {
2023
- if (typeof value !== "string") {
2498
+ function searchCell(value, term, row, column, typeName2, limit, previewLength) {
2499
+ const text = Buffer.isBuffer(value) && isTextLobType(typeName2) ? value.toString("utf8") : value;
2500
+ if (typeof text !== "string") {
2024
2501
  return [];
2025
2502
  }
2026
2503
  try {
2027
- const parsed = JSON.parse(value);
2504
+ const parsed = JSON.parse(text);
2028
2505
  if (typeof parsed === "object" && parsed !== null) {
2029
2506
  return jsonSearchMatches(parsed, term, row, column, limit, previewLength);
2030
2507
  }
2031
2508
  } catch {
2032
2509
  }
2033
- return plainTextMatches(value, term, row, column, limit, previewLength);
2510
+ return plainTextMatches(text, term, row, column, limit, previewLength);
2034
2511
  }
2035
2512
  function searchResultSession(session, searchTerm, options) {
2036
2513
  assertPositiveInteger("limit", options.limit);
@@ -2052,6 +2529,7 @@ function searchResultSession(session, searchTerm, options) {
2052
2529
  term,
2053
2530
  item.rowNumber,
2054
2531
  column.name,
2532
+ column.typeName,
2055
2533
  remaining,
2056
2534
  previewLength
2057
2535
  )
@@ -2066,19 +2544,19 @@ function searchResultSession(session, searchTerm, options) {
2066
2544
 
2067
2545
  // src/result-store.ts
2068
2546
  import { randomBytes } from "crypto";
2069
- import { mkdir as mkdir3, readFile, readdir as readdir2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
2547
+ import { mkdir as mkdir3, readFile, readdir as readdir2, rename, rm as rm3, writeFile as writeFile2 } from "fs/promises";
2070
2548
  import { homedir as homedir3 } from "os";
2071
- import { join as join3 } from "path";
2549
+ import { join as join4 } from "path";
2072
2550
  var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
2073
2551
  var MANIFEST_FILE_NAME = "manifest.json";
2074
2552
  function resultsRoot(saptoolsRoot) {
2075
- return join3(saptoolsRoot ?? join3(homedir3(), ".saptools"), "cf-hana", "results");
2553
+ return join4(saptoolsRoot ?? join4(homedir3(), ".saptools"), "cf-hana", "results");
2076
2554
  }
2077
2555
  function sessionDirectory(ref, saptoolsRoot) {
2078
- return join3(resultsRoot(saptoolsRoot), ref);
2556
+ return join4(resultsRoot(saptoolsRoot), ref);
2079
2557
  }
2080
2558
  function manifestPath(ref, saptoolsRoot) {
2081
- return join3(sessionDirectory(ref, saptoolsRoot), MANIFEST_FILE_NAME);
2559
+ return join4(sessionDirectory(ref, saptoolsRoot), MANIFEST_FILE_NAME);
2082
2560
  }
2083
2561
  function encodeCell(value) {
2084
2562
  if (value === null) {
@@ -2177,11 +2655,11 @@ function toStoredSession(input, ref, now) {
2177
2655
  result: encodeResult(input.result)
2178
2656
  };
2179
2657
  }
2180
- function isRecord(value) {
2658
+ function isRecord2(value) {
2181
2659
  return typeof value === "object" && value !== null && !Array.isArray(value);
2182
2660
  }
2183
2661
  function isStoredSession(value) {
2184
- if (!isRecord(value) || !isRecord(value["result"]) || !isRecord(value["info"])) {
2662
+ if (!isRecord2(value) || !isRecord2(value["result"]) || !isRecord2(value["info"])) {
2185
2663
  return false;
2186
2664
  }
2187
2665
  const result = value["result"];
@@ -2230,16 +2708,16 @@ async function createResultSession(input, options = {}) {
2230
2708
  const finalDirectory = sessionDirectory(ref, options.saptoolsRoot);
2231
2709
  const tempDirectory = `${finalDirectory}.tmp-${process.pid.toString()}`;
2232
2710
  await mkdir3(root, { recursive: true, mode: 448 });
2233
- await rm2(tempDirectory, { recursive: true, force: true });
2711
+ await rm3(tempDirectory, { recursive: true, force: true });
2234
2712
  await mkdir3(tempDirectory, { mode: 448 });
2235
2713
  try {
2236
- await writeFile2(join3(tempDirectory, MANIFEST_FILE_NAME), serialized, {
2714
+ await writeFile2(join4(tempDirectory, MANIFEST_FILE_NAME), serialized, {
2237
2715
  encoding: "utf8",
2238
2716
  mode: 384
2239
2717
  });
2240
2718
  await rename(tempDirectory, finalDirectory);
2241
2719
  } catch (error) {
2242
- await rm2(tempDirectory, { recursive: true, force: true });
2720
+ await rm3(tempDirectory, { recursive: true, force: true });
2243
2721
  throw error;
2244
2722
  }
2245
2723
  return toSession(stored, options.saptoolsRoot);
@@ -2275,7 +2753,7 @@ async function pruneResultSessions(options = {}) {
2275
2753
  for (const ref of refs) {
2276
2754
  const stored = await readStoredSession(manifestPath(ref, options.saptoolsRoot));
2277
2755
  if (stored === void 0 || Date.parse(stored.expiresAt) <= now) {
2278
- await rm2(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
2756
+ await rm3(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
2279
2757
  removed += 1;
2280
2758
  }
2281
2759
  }
@@ -2285,7 +2763,7 @@ async function clearResultSessions(options = {}) {
2285
2763
  const refs = await listSessionRefs(options.saptoolsRoot);
2286
2764
  await Promise.all(
2287
2765
  refs.map(async (ref) => {
2288
- await rm2(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
2766
+ await rm3(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
2289
2767
  })
2290
2768
  );
2291
2769
  return refs.length;
@@ -2373,7 +2851,12 @@ async function runShow(ref, options) {
2373
2851
  print(csv(rows, ["PATH", "TYPE", "VALUE"], length));
2374
2852
  return;
2375
2853
  }
2376
- const window = readCellWindow(selected.value, nonNegative("--offset", options.offset), length);
2854
+ const window = readCellWindow(
2855
+ selected.value,
2856
+ nonNegative("--offset", options.offset),
2857
+ length,
2858
+ selected.typeName
2859
+ );
2377
2860
  print(
2378
2861
  csv(
2379
2862
  [
@@ -2426,9 +2909,9 @@ async function runSearch(ref, text, options) {
2426
2909
  }));
2427
2910
  print(csv(rows, ["ROW", "COLUMN", "OFFSET", "PATH", "PREVIEW"], length));
2428
2911
  }
2429
- function cellExportValue(value) {
2912
+ function cellExportValue(value, typeName2) {
2430
2913
  if (Buffer.isBuffer(value)) {
2431
- return value;
2914
+ return isTextLobType(typeName2) ? value.toString("utf8") : value;
2432
2915
  }
2433
2916
  if (value instanceof Date) {
2434
2917
  return value.toISOString();
@@ -2450,7 +2933,9 @@ async function runExport(ref, options) {
2450
2933
  }
2451
2934
  const session = await readResultSession(ref);
2452
2935
  const selected = selectResultCell(session, options.row, options.column);
2453
- await writeFile3(options.output, cellExportValue(selected.value), { mode: 384 });
2936
+ await writeFile3(options.output, cellExportValue(selected.value, selected.typeName), {
2937
+ mode: 384
2938
+ });
2454
2939
  print(`wrote=${options.output}`);
2455
2940
  }
2456
2941
  async function runList() {
@@ -2492,6 +2977,419 @@ function registerResultCommands(program) {
2492
2977
  });
2493
2978
  }
2494
2979
 
2980
+ // src/metadata-cache.ts
2981
+ import { createHash } from "crypto";
2982
+ import { mkdir as mkdir4, readFile as readFile2, rename as rename2, rm as rm4, writeFile as writeFile4 } from "fs/promises";
2983
+ import { homedir as homedir4 } from "os";
2984
+ import { join as join5 } from "path";
2985
+ var METADATA_CACHE_TTL_MS = 30 * 6e4;
2986
+ function metadataCacheRoot(saptoolsRoot) {
2987
+ return join5(saptoolsRoot ?? join5(homedir4(), ".saptools"), "cf-hana", "metadata");
2988
+ }
2989
+ function toMetadataCacheScope(info) {
2990
+ return {
2991
+ selector: info.selector,
2992
+ appName: info.appName,
2993
+ host: info.host,
2994
+ schema: info.schema,
2995
+ role: info.role,
2996
+ driver: info.driver
2997
+ };
2998
+ }
2999
+ function metadataCacheKey(scope) {
3000
+ return createHash("sha256").update(JSON.stringify(scope)).digest("hex");
3001
+ }
3002
+ function metadataCachePath(scope, saptoolsRoot) {
3003
+ return join5(metadataCacheRoot(saptoolsRoot), `${metadataCacheKey(scope)}.json`);
3004
+ }
3005
+ function isRecord3(value) {
3006
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3007
+ }
3008
+ function isCatalogObject(value) {
3009
+ return isRecord3(value) && typeof value["schema"] === "string" && typeof value["name"] === "string" && (value["type"] === "TABLE" || value["type"] === "VIEW");
3010
+ }
3011
+ function isScope(value) {
3012
+ return isRecord3(value) && typeof value["selector"] === "string" && typeof value["appName"] === "string" && typeof value["host"] === "string" && typeof value["schema"] === "string" && typeof value["role"] === "string" && typeof value["driver"] === "string";
3013
+ }
3014
+ function scopesEqual(left, right) {
3015
+ return metadataCacheKey(left) === metadataCacheKey(right);
3016
+ }
3017
+ function isStored(value) {
3018
+ return isRecord3(value) && value["version"] === 1 && typeof value["createdAt"] === "string" && isScope(value["scope"]) && Array.isArray(value["objects"]) && value["objects"].every(isCatalogObject);
3019
+ }
3020
+ async function readMetadataCache(scope, options = {}) {
3021
+ try {
3022
+ const parsed = JSON.parse(
3023
+ await readFile2(metadataCachePath(scope, options.saptoolsRoot), "utf8")
3024
+ );
3025
+ if (!isStored(parsed) || !scopesEqual(parsed.scope, scope)) {
3026
+ return void 0;
3027
+ }
3028
+ const createdAt = Date.parse(parsed.createdAt);
3029
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
3030
+ const ageMs = now.getTime() - createdAt;
3031
+ if (!Number.isFinite(createdAt) || ageMs < 0 || ageMs >= METADATA_CACHE_TTL_MS) {
3032
+ return void 0;
3033
+ }
3034
+ return parsed.objects;
3035
+ } catch {
3036
+ return void 0;
3037
+ }
3038
+ }
3039
+ async function writeMetadataCache(scope, objects, options = {}) {
3040
+ const root = metadataCacheRoot(options.saptoolsRoot);
3041
+ const path = metadataCachePath(scope, options.saptoolsRoot);
3042
+ const tempPath = `${path}.tmp-${process.pid.toString()}`;
3043
+ const stored = {
3044
+ version: 1,
3045
+ createdAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
3046
+ scope,
3047
+ objects
3048
+ };
3049
+ await mkdir4(root, { recursive: true, mode: 448 });
3050
+ await rm4(tempPath, { force: true });
3051
+ await writeFile4(tempPath, `${JSON.stringify(stored)}
3052
+ `, { encoding: "utf8", mode: 384 });
3053
+ await rename2(tempPath, path);
3054
+ }
3055
+ async function loadCatalogObjectsWithCache(scope, refresh, loader, options = {}) {
3056
+ if (!refresh) {
3057
+ const cached = await readMetadataCache(scope, options);
3058
+ if (cached !== void 0) {
3059
+ return cached;
3060
+ }
3061
+ }
3062
+ const objects = await loader();
3063
+ try {
3064
+ await writeMetadataCache(scope, objects, options);
3065
+ } catch {
3066
+ }
3067
+ return objects;
3068
+ }
3069
+
3070
+ // src/suggestions.ts
3071
+ var INVALID_OBJECT_PATTERNS = [
3072
+ /invalid\s+(?:table|view|object)\s+name/i,
3073
+ /(?:table|view|object)\s+[^\n]*does\s+not\s+exist/i,
3074
+ /could\s+not\s+find\s+(?:table|view|object)/i
3075
+ ];
3076
+ var REF_KEYWORDS = /* @__PURE__ */ new Set(["FROM", "JOIN", "UPDATE", "INTO", "TABLE"]);
3077
+ var STOP_WORDS = /* @__PURE__ */ new Set([
3078
+ "AS",
3079
+ "ON",
3080
+ "WHERE",
3081
+ "SET",
3082
+ "VALUES",
3083
+ "USING",
3084
+ "WHEN",
3085
+ "INNER",
3086
+ "LEFT",
3087
+ "RIGHT",
3088
+ "FULL",
3089
+ "OUTER",
3090
+ "CROSS",
3091
+ "JOIN"
3092
+ ]);
3093
+ var CTE_FOLLOWERS = /* @__PURE__ */ new Set(["AS"]);
3094
+ function isInvalidCatalogObjectError(error) {
3095
+ if (!(error instanceof QueryError)) {
3096
+ return false;
3097
+ }
3098
+ if (error.sqlState === "42S02" || error.sqlState === "42S01") {
3099
+ return true;
3100
+ }
3101
+ return INVALID_OBJECT_PATTERNS.some((pattern) => pattern.test(error.message));
3102
+ }
3103
+ function skipLineComment3(sql, index) {
3104
+ let cursor = index + 2;
3105
+ while (cursor < sql.length && sql[cursor] !== "\n") {
3106
+ cursor += 1;
3107
+ }
3108
+ return cursor;
3109
+ }
3110
+ function skipBlockComment3(sql, index) {
3111
+ let cursor = index + 2;
3112
+ while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
3113
+ cursor += 1;
3114
+ }
3115
+ return Math.min(cursor + 2, sql.length);
3116
+ }
3117
+ function skipStringLiteral(sql, index) {
3118
+ let cursor = index + 1;
3119
+ while (cursor < sql.length) {
3120
+ if (sql[cursor] === "'" && sql[cursor + 1] === "'") {
3121
+ cursor += 2;
3122
+ continue;
3123
+ }
3124
+ if (sql[cursor] === "'") {
3125
+ return cursor + 1;
3126
+ }
3127
+ cursor += 1;
3128
+ }
3129
+ return cursor;
3130
+ }
3131
+ function readQuotedIdentifier(sql, index) {
3132
+ let cursor = index + 1;
3133
+ let text = "";
3134
+ while (cursor < sql.length) {
3135
+ if (sql[cursor] === '"' && sql[cursor + 1] === '"') {
3136
+ text += '"';
3137
+ cursor += 2;
3138
+ continue;
3139
+ }
3140
+ if (sql[cursor] === '"') {
3141
+ return { text, next: cursor + 1 };
3142
+ }
3143
+ text += sql.charAt(cursor);
3144
+ cursor += 1;
3145
+ }
3146
+ return { text, next: cursor };
3147
+ }
3148
+ function readBareWord(sql, index) {
3149
+ let cursor = index;
3150
+ let text = "";
3151
+ while (cursor < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(cursor))) {
3152
+ text += sql.charAt(cursor);
3153
+ cursor += 1;
3154
+ }
3155
+ return { text, next: cursor };
3156
+ }
3157
+ function tokenize(sql) {
3158
+ const tokens = [];
3159
+ let index = 0;
3160
+ while (index < sql.length) {
3161
+ const char = sql.charAt(index);
3162
+ if (/\s/.test(char)) {
3163
+ index += 1;
3164
+ continue;
3165
+ }
3166
+ if (char === "-" && sql[index + 1] === "-") {
3167
+ index = skipLineComment3(sql, index);
3168
+ continue;
3169
+ }
3170
+ if (char === "/" && sql[index + 1] === "*") {
3171
+ index = skipBlockComment3(sql, index);
3172
+ continue;
3173
+ }
3174
+ if (char === "'") {
3175
+ index = skipStringLiteral(sql, index);
3176
+ continue;
3177
+ }
3178
+ if (char === '"') {
3179
+ const quoted = readQuotedIdentifier(sql, index);
3180
+ tokens.push({ kind: "quoted", text: quoted.text });
3181
+ index = quoted.next;
3182
+ continue;
3183
+ }
3184
+ if (/[A-Za-z_#$]/.test(char)) {
3185
+ const word = readBareWord(sql, index);
3186
+ tokens.push({ kind: "word", text: word.text });
3187
+ index = word.next;
3188
+ continue;
3189
+ }
3190
+ if (char === ".") {
3191
+ tokens.push({ kind: "dot", text: char });
3192
+ } else if (char === ",") {
3193
+ tokens.push({ kind: "comma", text: char });
3194
+ } else if (char === "(") {
3195
+ tokens.push({ kind: "open", text: char });
3196
+ } else if (char === ")") {
3197
+ tokens.push({ kind: "close", text: char });
3198
+ } else {
3199
+ tokens.push({ kind: "other", text: char });
3200
+ }
3201
+ index += 1;
3202
+ }
3203
+ return tokens;
3204
+ }
3205
+ function upper(token) {
3206
+ return token?.text.toUpperCase() ?? "";
3207
+ }
3208
+ function isIdentifier(token) {
3209
+ return token?.kind === "word" || token?.kind === "quoted";
3210
+ }
3211
+ function readName(tokens, start, allowFollowingParens = false) {
3212
+ const first = tokens[start];
3213
+ if (!isIdentifier(first)) {
3214
+ return void 0;
3215
+ }
3216
+ let next = start + 1;
3217
+ let schema;
3218
+ let name = first.text;
3219
+ const maybeObject = tokens[next + 1];
3220
+ if (tokens[next]?.kind === "dot" && isIdentifier(maybeObject)) {
3221
+ schema = name;
3222
+ name = maybeObject.text;
3223
+ next += 2;
3224
+ }
3225
+ if (!allowFollowingParens && tokens[next]?.kind === "open") {
3226
+ return void 0;
3227
+ }
3228
+ return { name: schema === void 0 ? { name } : { schema, name }, next };
3229
+ }
3230
+ function firstNameFromText(text) {
3231
+ const tokens = tokenize(text);
3232
+ for (let index = 0; index < tokens.length; index += 1) {
3233
+ const read = readName(tokens, index, true);
3234
+ if (read !== void 0) {
3235
+ return read.name;
3236
+ }
3237
+ }
3238
+ return void 0;
3239
+ }
3240
+ function extractMissingObjectNameFromError(error) {
3241
+ if (!(error instanceof QueryError)) {
3242
+ return void 0;
3243
+ }
3244
+ const message = error.message;
3245
+ const invalidName = /invalid\s+(?:table|view|object)\s+name\s*:?\s*(.+)$/i.exec(message);
3246
+ if (invalidName?.[1] !== void 0) {
3247
+ return firstNameFromText(invalidName[1]);
3248
+ }
3249
+ const missingObject = /(?:table|view|object)\s+(.+?)\s+(?:does\s+not\s+exist|not\s+found)/i.exec(message);
3250
+ if (missingObject?.[1] !== void 0) {
3251
+ return firstNameFromText(missingObject[1]);
3252
+ }
3253
+ return void 0;
3254
+ }
3255
+ function cteNames(tokens) {
3256
+ const names = /* @__PURE__ */ new Set();
3257
+ if (upper(tokens[0]) !== "WITH") {
3258
+ return names;
3259
+ }
3260
+ let depth = 0;
3261
+ for (let index = 1; index < tokens.length; index += 1) {
3262
+ const token = tokens[index];
3263
+ if (depth === 0 && upper(token) === "SELECT") {
3264
+ break;
3265
+ }
3266
+ if (depth === 0 && isIdentifier(token) && CTE_FOLLOWERS.has(upper(tokens[index + 1]))) {
3267
+ names.add(token.text.toUpperCase());
3268
+ }
3269
+ if (token?.kind === "open") {
3270
+ depth += 1;
3271
+ } else if (token?.kind === "close" && depth > 0) {
3272
+ depth -= 1;
3273
+ }
3274
+ }
3275
+ return names;
3276
+ }
3277
+ function extractMissingObjectName(sql) {
3278
+ const tokens = tokenize(sql.replace(/^;+|;+$/g, ""));
3279
+ const ctes = cteNames(tokens);
3280
+ let candidate;
3281
+ for (let index = 0; index < tokens.length; index += 1) {
3282
+ const word = upper(tokens[index]);
3283
+ if (!REF_KEYWORDS.has(word)) {
3284
+ continue;
3285
+ }
3286
+ if (word === "TABLE" && upper(tokens[index - 1]) !== "TRUNCATE") {
3287
+ continue;
3288
+ }
3289
+ if (word === "INTO" && !(upper(tokens[index - 1]) === "INSERT" || upper(tokens[index - 1]) === "MERGE")) {
3290
+ continue;
3291
+ }
3292
+ const read = readName(tokens, index + 1, word === "INTO");
3293
+ if (read === void 0) {
3294
+ continue;
3295
+ }
3296
+ if (read.name.schema === void 0 && ctes.has(read.name.name.toUpperCase())) {
3297
+ continue;
3298
+ }
3299
+ if (STOP_WORDS.has(read.name.name.toUpperCase())) {
3300
+ continue;
3301
+ }
3302
+ candidate = read.name;
3303
+ let next = read.next;
3304
+ if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
3305
+ next += 1;
3306
+ }
3307
+ while (tokens[next]?.kind === "comma") {
3308
+ const commaRead = readName(tokens, next + 1);
3309
+ if (commaRead === void 0) {
3310
+ break;
3311
+ }
3312
+ if (!(commaRead.name.schema === void 0 && ctes.has(commaRead.name.name.toUpperCase()))) {
3313
+ candidate = commaRead.name;
3314
+ }
3315
+ next = commaRead.next;
3316
+ if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
3317
+ next += 1;
3318
+ }
3319
+ }
3320
+ }
3321
+ return candidate;
3322
+ }
3323
+ function singular(value) {
3324
+ if (value.endsWith("ES")) {
3325
+ return value.slice(0, -2);
3326
+ }
3327
+ if (value.endsWith("S")) {
3328
+ return value.slice(0, -1);
3329
+ }
3330
+ return value;
3331
+ }
3332
+ function norm(value) {
3333
+ return value.toUpperCase().replace(/[^A-Z0-9]+/g, "");
3334
+ }
3335
+ function distance(a, b) {
3336
+ const previous = Array.from({ length: b.length + 1 }, (_value, index) => index);
3337
+ for (let leftIndex = 1; leftIndex <= a.length; leftIndex += 1) {
3338
+ let last = leftIndex - 1;
3339
+ previous[0] = leftIndex;
3340
+ for (let rightIndex = 1; rightIndex <= b.length; rightIndex += 1) {
3341
+ const old = previous[rightIndex] ?? 0;
3342
+ previous[rightIndex] = Math.min(
3343
+ (previous[rightIndex] ?? 0) + 1,
3344
+ (previous[rightIndex - 1] ?? 0) + 1,
3345
+ last + (a.charAt(leftIndex - 1) === b.charAt(rightIndex - 1) ? 0 : 1)
3346
+ );
3347
+ last = old;
3348
+ }
3349
+ }
3350
+ return previous[b.length] ?? 0;
3351
+ }
3352
+ function score(requested, candidate) {
3353
+ const requestedName = norm(requested.name);
3354
+ const candidateName = norm(candidate.name);
3355
+ if (requested.schema !== void 0 && norm(requested.schema) !== norm(candidate.schema)) {
3356
+ return 0;
3357
+ }
3358
+ if (requestedName === candidateName) {
3359
+ return 100;
3360
+ }
3361
+ if (singular(requestedName) === singular(candidateName)) {
3362
+ return 92;
3363
+ }
3364
+ let value = 0;
3365
+ if (candidateName.startsWith(requestedName) || requestedName.startsWith(candidateName)) {
3366
+ value = Math.max(value, 80 - Math.abs(candidateName.length - requestedName.length));
3367
+ }
3368
+ if (candidateName.endsWith(requestedName) || requestedName.endsWith(candidateName)) {
3369
+ value = Math.max(value, 70 - Math.abs(candidateName.length - requestedName.length));
3370
+ }
3371
+ const editDistance = distance(requestedName, candidateName);
3372
+ const max = Math.max(requestedName.length, candidateName.length, 1);
3373
+ if (editDistance <= Math.max(3, Math.floor(max * 0.35))) {
3374
+ value = Math.max(value, Math.round(75 * (1 - editDistance / max)));
3375
+ }
3376
+ return value;
3377
+ }
3378
+ function rankCatalogSuggestions(requested, candidates, limit = 5) {
3379
+ return candidates.map((candidate) => ({ candidate, score: score(requested, candidate) })).filter((item) => item.score >= 45).sort(
3380
+ (left, right) => right.score - left.score || left.candidate.schema.localeCompare(right.candidate.schema) || (left.candidate.type === right.candidate.type ? 0 : left.candidate.type === "TABLE" ? -1 : 1) || left.candidate.name.localeCompare(right.candidate.name)
3381
+ ).slice(0, limit).map((item) => item.candidate);
3382
+ }
3383
+ function formatSuggestions(suggestions) {
3384
+ if (suggestions.length === 0) {
3385
+ return void 0;
3386
+ }
3387
+ return [
3388
+ "Did you mean:",
3389
+ ...suggestions.map((item) => ` ${item.schema}.${item.name} (${item.type})`)
3390
+ ].join("\n");
3391
+ }
3392
+
2495
3393
  // src/cli.ts
2496
3394
  function print2(text) {
2497
3395
  process.stdout.write(`${text}
@@ -2618,7 +3516,7 @@ function formatInfo(info) {
2618
3516
  ].join("\n");
2619
3517
  }
2620
3518
  function withConnectionOptions(command) {
2621
- return command.option("--refresh", "bypass cached credentials and fetch them live", false).option("--role <role>", "HANA user role: runtime or hdi", "runtime").option("--binding <name>", "select a HANA binding by service name").option("--binding-index <n>", "select a HANA binding by index", parseIntOption2).option("--read-only", "block every DML and DDL statement", false).option("--allow-destructive", "permit destructive statements", false).option("--timeout <ms>", "connection and query timeout in milliseconds", parseIntOption2).option("--limit <n>", "row cap auto-applied to bare SELECT statements", parseIntOption2).option("--no-auto-limit", "disable the automatic SELECT row cap");
3519
+ return command.option("--refresh", "bypass cached credentials and fetch them live", false).option("--role <role>", "HANA user role: runtime or hdi", "runtime").option("--binding <name>", "select a HANA binding by service name").option("--binding-index <n>", "select a HANA binding by index", parseIntOption2).option("--read-only", "block every DML and DDL statement", false).option("--allow-destructive", "permit destructive statements", false).option("--timeout <ms>", "connection and query timeout in milliseconds", parseIntOption2).option("--limit <n>", "row cap auto-applied to bare SELECT statements", parseIntOption2).option("--no-auto-limit", "disable the automatic SELECT row cap").option("--refresh-metadata", "bypass the 30-minute table/view metadata suggestion cache", false);
2622
3520
  }
2623
3521
  function withFormattedConnectionOptions(command) {
2624
3522
  return withConnectionOptions(command).option(
@@ -2627,6 +3525,36 @@ function withFormattedConnectionOptions(command) {
2627
3525
  "table"
2628
3526
  );
2629
3527
  }
3528
+ async function loadSuggestionCatalogObjects(client, refresh) {
3529
+ try {
3530
+ return await loadCatalogObjectsWithCache(
3531
+ toMetadataCacheScope(client.info),
3532
+ refresh,
3533
+ async () => await client.listCatalogObjects(client.info.schema)
3534
+ );
3535
+ } catch {
3536
+ return await client.listCatalogObjects(client.info.schema);
3537
+ }
3538
+ }
3539
+ async function enrichAndRethrowQueryError(error, client, sql, refresh) {
3540
+ if (!isInvalidCatalogObjectError(error)) {
3541
+ throw error;
3542
+ }
3543
+ const requested = extractMissingObjectNameFromError(error) ?? extractMissingObjectName(sql);
3544
+ if (requested === void 0) {
3545
+ throw error;
3546
+ }
3547
+ try {
3548
+ const objects = await loadSuggestionCatalogObjects(client, refresh);
3549
+ const text = formatSuggestions(rankCatalogSuggestions(requested, objects));
3550
+ if (text !== void 0) {
3551
+ process.stderr.write(`${text}
3552
+ `);
3553
+ }
3554
+ } catch {
3555
+ }
3556
+ throw error;
3557
+ }
2630
3558
  async function runQuery(selector, sql, command) {
2631
3559
  const opts = command.opts();
2632
3560
  assertQueryOptions(sql, opts);
@@ -2634,12 +3562,24 @@ async function runQuery(selector, sql, command) {
2634
3562
  const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
2635
3563
  try {
2636
3564
  const params = opts.param ?? [];
2637
- const backup = await client.backupWriteStatement(sql, params);
3565
+ let backup;
3566
+ try {
3567
+ backup = await client.backupWriteStatement(sql, params);
3568
+ } catch (error) {
3569
+ await enrichAndRethrowQueryError(error, client, sql, opts.refreshMetadata || opts.refresh);
3570
+ }
2638
3571
  if (backup !== void 0) {
2639
3572
  process.stderr.write(`${CLI_NAME}: backup saved to ${backup.directory}
2640
3573
  `);
2641
3574
  }
2642
- const result = await client.query(sql, params);
3575
+ const result = await client.query(sql, params).catch(async (error) => {
3576
+ return await enrichAndRethrowQueryError(
3577
+ error,
3578
+ client,
3579
+ sql,
3580
+ opts.refreshMetadata || opts.refresh
3581
+ );
3582
+ });
2643
3583
  if (result.statement === "select") {
2644
3584
  const compact = formatCompactCsv(result, cellLimit);
2645
3585
  if (opts.save) {