@saptools/cf-hana 0.1.6 → 0.2.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
@@ -52,6 +52,59 @@ function errorMessage(error) {
52
52
  return String(error);
53
53
  }
54
54
 
55
+ // src/result-preview.ts
56
+ function normalizePreviewChar(value) {
57
+ return value === "\r" || value === "\n" || value === " " ? " " : value;
58
+ }
59
+ function previewText(value, limit) {
60
+ let originalLength = 0;
61
+ let text = "";
62
+ for (const char of value) {
63
+ if (originalLength < limit) {
64
+ text += normalizePreviewChar(char);
65
+ }
66
+ originalLength += 1;
67
+ }
68
+ return {
69
+ text,
70
+ truncated: originalLength > limit,
71
+ originalLength,
72
+ unit: "chars"
73
+ };
74
+ }
75
+ function previewBuffer(value, limit) {
76
+ const fullLength = 2 + value.length * 2;
77
+ const visibleBytes = Math.max(0, Math.floor((limit - 2) / 2));
78
+ const hex = value.subarray(0, visibleBytes).toString("hex");
79
+ return {
80
+ text: `0x${hex}`.slice(0, limit),
81
+ truncated: fullLength > limit,
82
+ originalLength: value.length,
83
+ unit: "bytes"
84
+ };
85
+ }
86
+ function scalarText(value) {
87
+ if (value instanceof Date) {
88
+ return value.toISOString();
89
+ }
90
+ if (typeof value === "boolean") {
91
+ return value ? "true" : "false";
92
+ }
93
+ return value.toString();
94
+ }
95
+ function previewCell(value, limit) {
96
+ if (value === null) {
97
+ return { text: "", truncated: false, originalLength: 0, unit: "chars" };
98
+ }
99
+ if (Buffer.isBuffer(value)) {
100
+ return previewBuffer(value, limit);
101
+ }
102
+ if (typeof value === "string") {
103
+ return previewText(value, limit);
104
+ }
105
+ return previewText(scalarText(value), limit);
106
+ }
107
+
55
108
  // src/format.ts
56
109
  function cellText(value, nullText) {
57
110
  if (value === null) {
@@ -126,6 +179,22 @@ function formatCsv(result) {
126
179
  }
127
180
  return lines.join("\r\n");
128
181
  }
182
+ function formatCompactCsv(result, cellLimit) {
183
+ const headers = result.columns.map((column) => column.name);
184
+ const lines = [headers.map((header) => csvEscape(header)).join(",")];
185
+ let truncatedCells = 0;
186
+ for (const row of result.rows) {
187
+ const cells = result.columns.map((column) => {
188
+ const preview = previewCell(row[column.name] ?? null, cellLimit);
189
+ if (preview.truncated) {
190
+ truncatedCells += 1;
191
+ }
192
+ return csvEscape(preview.text);
193
+ });
194
+ lines.push(cells.join(","));
195
+ }
196
+ return { text: lines.join("\r\n"), truncatedCells };
197
+ }
129
198
  function formatResult(result, format) {
130
199
  switch (format) {
131
200
  case "table":
@@ -481,14 +550,17 @@ function buildDelete(schema, table, where) {
481
550
  // src/catalog.ts
482
551
  async function listSchemas(connection) {
483
552
  const result = await connection.query(
484
- "SELECT SCHEMA_NAME FROM SYS.SCHEMAS ORDER BY SCHEMA_NAME"
553
+ "SELECT SCHEMA_NAME FROM SYS.SCHEMAS ORDER BY SCHEMA_NAME",
554
+ [],
555
+ { autoLimit: false }
485
556
  );
486
557
  return result.rows.map((row) => row.SCHEMA_NAME);
487
558
  }
488
559
  async function listTables(connection, schema) {
489
560
  const result = await connection.query(
490
561
  "SELECT SCHEMA_NAME, TABLE_NAME, TABLE_TYPE FROM SYS.TABLES WHERE SCHEMA_NAME = ? ORDER BY TABLE_NAME",
491
- [schema]
562
+ [schema],
563
+ { autoLimit: false }
492
564
  );
493
565
  return result.rows.map((row) => ({
494
566
  schema: row.SCHEMA_NAME,
@@ -500,7 +572,8 @@ async function listTables(connection, schema) {
500
572
  async function listColumns(connection, schema, table) {
501
573
  const result = await connection.query(
502
574
  "SELECT COLUMN_NAME, DATA_TYPE_NAME, LENGTH, SCALE, IS_NULLABLE, POSITION FROM SYS.TABLE_COLUMNS WHERE SCHEMA_NAME = ? AND TABLE_NAME = ? ORDER BY POSITION",
503
- [schema, table]
575
+ [schema, table],
576
+ { autoLimit: false }
504
577
  );
505
578
  return result.rows.map((row) => ({
506
579
  name: row.COLUMN_NAME,
@@ -514,13 +587,18 @@ async function listColumns(connection, schema, table) {
514
587
 
515
588
  // src/config.ts
516
589
  var CLI_NAME = "cf-hana";
517
- var CLI_VERSION = "0.1.6";
590
+ var CLI_VERSION = "0.2.0";
518
591
  var ENV_PREFIX = "CF_HANA";
519
592
  var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
520
593
  var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
521
594
  var DEFAULT_POOL_MAX = 4;
522
595
  var DEFAULT_POOL_IDLE_MS = 6e4;
523
- var DEFAULT_AUTO_LIMIT = 1e3;
596
+ var DEFAULT_AUTO_LIMIT = 100;
597
+ var DEFAULT_CELL_LIMIT = 128;
598
+ var MAX_CELL_LIMIT = 1e4;
599
+ var DEFAULT_RESULT_TTL_MINUTES = 60;
600
+ var DEFAULT_RESULT_SEARCH_LIMIT = 20;
601
+ var MAX_RESULT_STORE_BYTES = 256 * 1024 * 1024;
524
602
  function envName(suffix) {
525
603
  return `${ENV_PREFIX}_${suffix}`;
526
604
  }
@@ -1174,10 +1252,28 @@ function applyAutoLimit(sql, limit) {
1174
1252
  if (/\blimit\b/i.test(stripped) || /\btop\s+\d/i.test(stripped)) {
1175
1253
  return { sql, applied: false };
1176
1254
  }
1177
- return { sql: appendLimit(sql, limit), applied: true };
1255
+ return {
1256
+ sql: appendLimit(sql, limit + 1),
1257
+ applied: true,
1258
+ requestedLimit: limit
1259
+ };
1178
1260
  }
1179
1261
 
1180
1262
  // src/connection.ts
1263
+ function assertValidAutoLimit(value) {
1264
+ if (value !== false && (!Number.isSafeInteger(value) || value <= 0 || value >= Number.MAX_SAFE_INTEGER)) {
1265
+ throw new CfHanaError("CONFIG", "autoLimit must be a positive safe integer");
1266
+ }
1267
+ }
1268
+ function boundedRows(rows, requestedLimit) {
1269
+ if (requestedLimit === void 0) {
1270
+ return { rows, truncated: false };
1271
+ }
1272
+ return {
1273
+ rows: rows.slice(0, requestedLimit),
1274
+ truncated: rows.length > requestedLimit
1275
+ };
1276
+ }
1181
1277
  async function withTimeout(work, timeoutMs, onTimeout) {
1182
1278
  let timer;
1183
1279
  const timeout = new Promise((_resolve, reject) => {
@@ -1251,6 +1347,7 @@ var Connection = class _Connection {
1251
1347
  }
1252
1348
  const kind = classifyStatement(sql);
1253
1349
  const autoLimit = options.autoLimit ?? this.config.autoLimit;
1350
+ assertValidAutoLimit(autoLimit);
1254
1351
  const limited = kind === "select" ? applyAutoLimit(sql, autoLimit) : { sql, applied: false };
1255
1352
  const timeoutMs = options.timeoutMs ?? this.config.queryTimeoutMs;
1256
1353
  const started = Date.now();
@@ -1262,12 +1359,13 @@ var Connection = class _Connection {
1262
1359
  }
1263
1360
  );
1264
1361
  const elapsedMs = Date.now() - started;
1362
+ const selected = boundedRows(execResult.rows, limited.requestedLimit);
1265
1363
  return {
1266
- rows: execResult.rows,
1364
+ rows: selected.rows,
1267
1365
  columns: execResult.columns,
1268
- rowCount: kind === "select" ? execResult.rows.length : execResult.affectedRows,
1366
+ rowCount: kind === "select" ? selected.rows.length : execResult.affectedRows,
1269
1367
  statement: kind,
1270
- truncated: limited.applied && execResult.rows.length === autoLimit,
1368
+ truncated: selected.truncated,
1271
1369
  elapsedMs
1272
1370
  };
1273
1371
  }
@@ -1699,17 +1797,711 @@ async function connect(selector, options) {
1699
1797
  return await HanaClient.connect(selector, options);
1700
1798
  }
1701
1799
 
1702
- // src/cli.ts
1800
+ // src/cli-results.ts
1801
+ import { writeFile as writeFile3 } from "fs/promises";
1802
+
1803
+ // src/result-inspect.ts
1804
+ function assertPositiveInteger(name, value) {
1805
+ if (!Number.isSafeInteger(value) || value <= 0) {
1806
+ throw new CfHanaError(
1807
+ "CONFIG",
1808
+ `${name} ${String(value)} must be a positive safe integer`
1809
+ );
1810
+ }
1811
+ }
1812
+ function selectResultRow(session, rowNumber) {
1813
+ assertPositiveInteger("row", rowNumber);
1814
+ const row = session.result.rows[rowNumber - 1];
1815
+ if (row === void 0) {
1816
+ throw new CfHanaError("QUERY", `Saved result row ${String(rowNumber)} not found`);
1817
+ }
1818
+ return row;
1819
+ }
1820
+ function selectResultCell(session, rowNumber, columnName) {
1821
+ const row = selectResultRow(session, rowNumber);
1822
+ const column = session.result.columns.find((item) => item.name === columnName);
1823
+ if (column === void 0) {
1824
+ throw new CfHanaError("QUERY", `Saved result column "${columnName}" not found`);
1825
+ }
1826
+ return {
1827
+ row: rowNumber,
1828
+ column: column.name,
1829
+ typeName: column.typeName,
1830
+ value: row[column.name] ?? null
1831
+ };
1832
+ }
1833
+ function textRange(value, offset, length) {
1834
+ let index = 0;
1835
+ let selected = "";
1836
+ for (const char of value) {
1837
+ if (index >= offset && index < offset + length) {
1838
+ selected += char;
1839
+ }
1840
+ index += 1;
1841
+ }
1842
+ return { type: "text", originalLength: index, offset, value: selected };
1843
+ }
1844
+ function readCellWindow(value, offset, length) {
1845
+ if (!Number.isSafeInteger(offset) || offset < 0) {
1846
+ throw new CfHanaError("CONFIG", "offset must be a non-negative safe integer");
1847
+ }
1848
+ assertPositiveInteger("length", length);
1849
+ if (Buffer.isBuffer(value)) {
1850
+ return {
1851
+ type: "binary",
1852
+ originalLength: value.length,
1853
+ offset,
1854
+ value: `0x${value.subarray(offset, offset + length).toString("hex")}`
1855
+ };
1856
+ }
1857
+ if (typeof value === "string") {
1858
+ return textRange(value, offset, length);
1859
+ }
1860
+ const text = previewCell(value, Number.MAX_SAFE_INTEGER).text;
1861
+ const window = textRange(text, offset, length);
1862
+ return { ...window, type: "scalar" };
1863
+ }
1864
+ function decodePointerToken(token) {
1865
+ if (/~(?:[^01]|$)/.test(token)) {
1866
+ throw new CfHanaError("CONFIG", "Invalid JSON Pointer escape");
1867
+ }
1868
+ return token.replaceAll("~1", "/").replaceAll("~0", "~");
1869
+ }
1870
+ function pointerTokens(pointer) {
1871
+ if (pointer === "") {
1872
+ return [];
1873
+ }
1874
+ if (!pointer.startsWith("/")) {
1875
+ throw new CfHanaError("CONFIG", "JSON Pointer must be empty or start with /");
1876
+ }
1877
+ return pointer.slice(1).split("/").map(decodePointerToken);
1878
+ }
1879
+ function resolvePointer(root, pointer) {
1880
+ let current = root;
1881
+ for (const token of pointerTokens(pointer)) {
1882
+ if (Array.isArray(current)) {
1883
+ const index = Number(token);
1884
+ if (!Number.isSafeInteger(index) || index < 0 || index >= current.length) {
1885
+ throw new CfHanaError("QUERY", `JSON Pointer path "${pointer}" not found`);
1886
+ }
1887
+ current = current[index];
1888
+ continue;
1889
+ }
1890
+ if (typeof current === "object" && current !== null && token in current) {
1891
+ current = current[token];
1892
+ continue;
1893
+ }
1894
+ throw new CfHanaError("QUERY", `JSON Pointer path "${pointer}" not found`);
1895
+ }
1896
+ return current;
1897
+ }
1898
+ function escapePointerToken(token) {
1899
+ return token.replaceAll("~", "~0").replaceAll("/", "~1");
1900
+ }
1901
+ function jsonType(value) {
1902
+ if (value === null) {
1903
+ return "null";
1904
+ }
1905
+ if (Array.isArray(value)) {
1906
+ return "array";
1907
+ }
1908
+ return typeof value;
1909
+ }
1910
+ function jsonValueText(value, limit) {
1911
+ if (Array.isArray(value)) {
1912
+ return `items=${String(value.length)}`;
1913
+ }
1914
+ if (typeof value === "object" && value !== null) {
1915
+ return `keys=${String(Object.keys(value).length)}`;
1916
+ }
1917
+ if (typeof value === "string") {
1918
+ return previewCell(value, limit).text;
1919
+ }
1920
+ if (value === null) {
1921
+ return "null";
1922
+ }
1923
+ if (typeof value === "number" || typeof value === "boolean") {
1924
+ return String(value);
1925
+ }
1926
+ return "";
1927
+ }
1928
+ function jsonSearchText(value) {
1929
+ if (typeof value === "string") {
1930
+ return value;
1931
+ }
1932
+ if (value === null) {
1933
+ return "null";
1934
+ }
1935
+ if (typeof value === "number" || typeof value === "boolean") {
1936
+ return String(value);
1937
+ }
1938
+ return "";
1939
+ }
1940
+ function inspectionRow(path, value, limit) {
1941
+ return { path, type: jsonType(value), value: jsonValueText(value, limit) };
1942
+ }
1943
+ function inspectJsonCell(value, pointer, limit) {
1944
+ if (typeof value !== "string") {
1945
+ throw new CfHanaError("QUERY", "JSON Pointer requires a text cell");
1946
+ }
1947
+ let parsed;
1948
+ try {
1949
+ parsed = JSON.parse(value);
1950
+ } catch (error) {
1951
+ throw new CfHanaError("QUERY", "Saved result cell does not contain valid JSON", {
1952
+ cause: error
1953
+ });
1954
+ }
1955
+ const selected = resolvePointer(parsed, pointer);
1956
+ if (Array.isArray(selected)) {
1957
+ return selected.map(
1958
+ (item, index) => inspectionRow(`${pointer}/${String(index)}`, item, limit)
1959
+ );
1960
+ }
1961
+ if (typeof selected === "object" && selected !== null) {
1962
+ return Object.entries(selected).map(
1963
+ ([key, item]) => inspectionRow(`${pointer}/${escapePointerToken(key)}`, item, limit)
1964
+ );
1965
+ }
1966
+ return [inspectionRow(pointer, selected, limit)];
1967
+ }
1968
+ function plainTextMatches(text, term, row, column, limit, previewLength) {
1969
+ const matches = [];
1970
+ const lowerText = text.toLowerCase();
1971
+ let offset = lowerText.indexOf(term);
1972
+ while (offset >= 0 && matches.length < limit) {
1973
+ const start = Math.max(0, offset - 32);
1974
+ matches.push({
1975
+ row,
1976
+ column,
1977
+ offset,
1978
+ path: "",
1979
+ preview: textRange(text, start, previewLength).value.replaceAll(/[\r\n\t]/g, " ")
1980
+ });
1981
+ offset = lowerText.indexOf(term, offset + Math.max(1, term.length));
1982
+ }
1983
+ return matches;
1984
+ }
1985
+ function jsonSearchMatches(root, term, row, column, limit, previewLength) {
1986
+ const matches = [];
1987
+ const stack = [
1988
+ { path: "", value: root }
1989
+ ];
1990
+ while (stack.length > 0 && matches.length < limit) {
1991
+ const entry = stack.pop();
1992
+ if (entry === void 0) {
1993
+ break;
1994
+ }
1995
+ if (typeof entry.value === "object" && entry.value !== null) {
1996
+ const entries = Array.isArray(entry.value) ? entry.value.map((item, index) => [String(index), item]) : Object.entries(entry.value);
1997
+ for (const [key, value] of entries.reverse()) {
1998
+ const path = `${entry.path}/${escapePointerToken(key)}`;
1999
+ if (key.toLowerCase().includes(term)) {
2000
+ matches.push({ row, column, path, preview: jsonValueText(value, previewLength) });
2001
+ if (matches.length >= limit) {
2002
+ break;
2003
+ }
2004
+ }
2005
+ stack.push({ path, value });
2006
+ }
2007
+ continue;
2008
+ }
2009
+ const text = jsonSearchText(entry.value);
2010
+ if (text.toLowerCase().includes(term)) {
2011
+ matches.push({
2012
+ row,
2013
+ column,
2014
+ path: entry.path,
2015
+ preview: previewCell(text, previewLength).text
2016
+ });
2017
+ }
2018
+ }
2019
+ return matches;
2020
+ }
2021
+ function searchCell(value, term, row, column, limit, previewLength) {
2022
+ if (typeof value !== "string") {
2023
+ return [];
2024
+ }
2025
+ try {
2026
+ const parsed = JSON.parse(value);
2027
+ if (typeof parsed === "object" && parsed !== null) {
2028
+ return jsonSearchMatches(parsed, term, row, column, limit, previewLength);
2029
+ }
2030
+ } catch {
2031
+ }
2032
+ return plainTextMatches(value, term, row, column, limit, previewLength);
2033
+ }
2034
+ function searchResultSession(session, searchTerm, options) {
2035
+ assertPositiveInteger("limit", options.limit);
2036
+ const term = searchTerm.trim().toLowerCase();
2037
+ if (term.length === 0) {
2038
+ throw new CfHanaError("CONFIG", "search text must not be empty");
2039
+ }
2040
+ const previewLength = options.previewLength ?? 128;
2041
+ assertPositiveInteger("preview length", previewLength);
2042
+ const rows = options.row === void 0 ? session.result.rows.map((row, index) => ({ row, rowNumber: index + 1 })) : [{ row: selectResultRow(session, options.row), rowNumber: options.row }];
2043
+ const columns = options.column === void 0 ? session.result.columns : [selectResultCell(session, rows[0]?.rowNumber ?? 1, options.column)].map((item) => ({ name: item.column, typeName: item.typeName }));
2044
+ const matches = [];
2045
+ for (const item of rows) {
2046
+ for (const column of columns) {
2047
+ const remaining = options.limit - matches.length;
2048
+ matches.push(
2049
+ ...searchCell(
2050
+ item.row[column.name] ?? null,
2051
+ term,
2052
+ item.rowNumber,
2053
+ column.name,
2054
+ remaining,
2055
+ previewLength
2056
+ )
2057
+ );
2058
+ if (matches.length >= options.limit) {
2059
+ return matches;
2060
+ }
2061
+ }
2062
+ }
2063
+ return matches;
2064
+ }
2065
+
2066
+ // src/result-store.ts
2067
+ import { randomBytes } from "crypto";
2068
+ import { mkdir as mkdir3, readFile, readdir as readdir2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
2069
+ import { homedir as homedir3 } from "os";
2070
+ import { join as join3 } from "path";
2071
+ var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
2072
+ var MANIFEST_FILE_NAME = "manifest.json";
2073
+ function resultsRoot(saptoolsRoot) {
2074
+ return join3(saptoolsRoot ?? join3(homedir3(), ".saptools"), "cf-hana", "results");
2075
+ }
2076
+ function sessionDirectory(ref, saptoolsRoot) {
2077
+ return join3(resultsRoot(saptoolsRoot), ref);
2078
+ }
2079
+ function manifestPath(ref, saptoolsRoot) {
2080
+ return join3(sessionDirectory(ref, saptoolsRoot), MANIFEST_FILE_NAME);
2081
+ }
2082
+ function encodeCell(value) {
2083
+ if (value === null) {
2084
+ return { kind: "null" };
2085
+ }
2086
+ if (Buffer.isBuffer(value)) {
2087
+ return { kind: "buffer", value: value.toString("base64") };
2088
+ }
2089
+ if (value instanceof Date) {
2090
+ return { kind: "date", value: value.toISOString() };
2091
+ }
2092
+ if (typeof value === "string") {
2093
+ return { kind: "string", value };
2094
+ }
2095
+ if (typeof value === "number") {
2096
+ return { kind: "number", value };
2097
+ }
2098
+ return { kind: "boolean", value };
2099
+ }
2100
+ function decodeCell(cell) {
2101
+ switch (cell.kind) {
2102
+ case "null":
2103
+ return null;
2104
+ case "buffer":
2105
+ return Buffer.from(cell.value, "base64");
2106
+ case "date":
2107
+ return new Date(cell.value);
2108
+ case "string":
2109
+ return cell.value;
2110
+ case "number":
2111
+ return cell.value;
2112
+ case "boolean":
2113
+ return cell.value;
2114
+ }
2115
+ }
2116
+ function assertUniqueColumns(columns) {
2117
+ const names = /* @__PURE__ */ new Set();
2118
+ for (const column of columns) {
2119
+ if (names.has(column.name)) {
2120
+ throw new CfHanaError(
2121
+ "CONFIG",
2122
+ `Saved results require unique SQL aliases; duplicate column "${column.name}"`
2123
+ );
2124
+ }
2125
+ names.add(column.name);
2126
+ }
2127
+ }
2128
+ function encodeResult(result) {
2129
+ assertUniqueColumns(result.columns);
2130
+ return {
2131
+ columns: result.columns,
2132
+ rows: result.rows.map(
2133
+ (row) => result.columns.map((column) => encodeCell(row[column.name] ?? null))
2134
+ ),
2135
+ rowCount: result.rowCount,
2136
+ statement: result.statement,
2137
+ truncated: result.truncated,
2138
+ elapsedMs: result.elapsedMs
2139
+ };
2140
+ }
2141
+ function decodeResult(result) {
2142
+ const rows = result.rows.map((cells) => {
2143
+ const row = {};
2144
+ let index = 0;
2145
+ for (const column of result.columns) {
2146
+ row[column.name] = decodeCell(cells[index] ?? { kind: "null" });
2147
+ index += 1;
2148
+ }
2149
+ return row;
2150
+ });
2151
+ return { ...result, rows };
2152
+ }
2153
+ function resolveRef(value) {
2154
+ const ref = value ?? `q${randomBytes(4).toString("hex")}`;
2155
+ if (!RESULT_REF_PATTERN.test(ref)) {
2156
+ throw new CfHanaError("CONFIG", "Invalid saved result ref");
2157
+ }
2158
+ return ref;
2159
+ }
2160
+ function resolveTtl(value) {
2161
+ const ttl = value ?? DEFAULT_RESULT_TTL_MINUTES;
2162
+ if (!Number.isSafeInteger(ttl) || ttl <= 0) {
2163
+ throw new CfHanaError("CONFIG", "Result TTL must be a positive safe integer");
2164
+ }
2165
+ return ttl;
2166
+ }
2167
+ function toStoredSession(input, ref, now) {
2168
+ const ttlMinutes = resolveTtl(input.ttlMinutes);
2169
+ return {
2170
+ version: 1,
2171
+ ref,
2172
+ createdAt: now.toISOString(),
2173
+ expiresAt: new Date(now.getTime() + ttlMinutes * 6e4).toISOString(),
2174
+ ttlMinutes,
2175
+ info: input.info,
2176
+ result: encodeResult(input.result)
2177
+ };
2178
+ }
2179
+ function isRecord(value) {
2180
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2181
+ }
2182
+ function isStoredSession(value) {
2183
+ if (!isRecord(value) || !isRecord(value["result"]) || !isRecord(value["info"])) {
2184
+ return false;
2185
+ }
2186
+ const result = value["result"];
2187
+ return value["version"] === 1 && typeof value["ref"] === "string" && typeof value["createdAt"] === "string" && typeof value["expiresAt"] === "string" && typeof value["ttlMinutes"] === "number" && Array.isArray(result["columns"]) && Array.isArray(result["rows"]);
2188
+ }
2189
+ async function readStoredSession(path) {
2190
+ try {
2191
+ const parsed = JSON.parse(await readFile(path, "utf8"));
2192
+ return isStoredSession(parsed) ? parsed : void 0;
2193
+ } catch (error) {
2194
+ if (error.code === "ENOENT") {
2195
+ return void 0;
2196
+ }
2197
+ return void 0;
2198
+ }
2199
+ }
2200
+ function toSession(stored, saptoolsRoot) {
2201
+ return {
2202
+ ...stored,
2203
+ result: decodeResult(stored.result),
2204
+ directory: sessionDirectory(stored.ref, saptoolsRoot),
2205
+ path: manifestPath(stored.ref, saptoolsRoot)
2206
+ };
2207
+ }
2208
+ async function listSessionRefs(saptoolsRoot) {
2209
+ try {
2210
+ const entries = await readdir2(resultsRoot(saptoolsRoot), { withFileTypes: true });
2211
+ return entries.filter((entry) => entry.isDirectory() && RESULT_REF_PATTERN.test(entry.name)).map((entry) => entry.name);
2212
+ } catch (error) {
2213
+ if (error.code === "ENOENT") {
2214
+ return [];
2215
+ }
2216
+ throw error;
2217
+ }
2218
+ }
2219
+ async function createResultSession(input, options = {}) {
2220
+ await pruneResultSessions(options);
2221
+ const ref = resolveRef(options.ref);
2222
+ const stored = toStoredSession(input, ref, options.now?.() ?? /* @__PURE__ */ new Date());
2223
+ const serialized = `${JSON.stringify(stored)}
2224
+ `;
2225
+ if (Buffer.byteLength(serialized) > (options.maxBytes ?? MAX_RESULT_STORE_BYTES)) {
2226
+ throw new CfHanaError("CONFIG", "Saved result exceeds the storage limit");
2227
+ }
2228
+ const root = resultsRoot(options.saptoolsRoot);
2229
+ const finalDirectory = sessionDirectory(ref, options.saptoolsRoot);
2230
+ const tempDirectory = `${finalDirectory}.tmp-${process.pid.toString()}`;
2231
+ await mkdir3(root, { recursive: true, mode: 448 });
2232
+ await rm2(tempDirectory, { recursive: true, force: true });
2233
+ await mkdir3(tempDirectory, { mode: 448 });
2234
+ try {
2235
+ await writeFile2(join3(tempDirectory, MANIFEST_FILE_NAME), serialized, {
2236
+ encoding: "utf8",
2237
+ mode: 384
2238
+ });
2239
+ await rename(tempDirectory, finalDirectory);
2240
+ } catch (error) {
2241
+ await rm2(tempDirectory, { recursive: true, force: true });
2242
+ throw error;
2243
+ }
2244
+ return toSession(stored, options.saptoolsRoot);
2245
+ }
2246
+ async function readResultSession(ref, options = {}) {
2247
+ const resolvedRef = resolveRef(ref);
2248
+ await pruneResultSessions(options);
2249
+ const stored = await readStoredSession(manifestPath(resolvedRef, options.saptoolsRoot));
2250
+ if (stored === void 0) {
2251
+ throw new CfHanaError("QUERY", "Saved result not found or expired");
2252
+ }
2253
+ return toSession(stored, options.saptoolsRoot);
2254
+ }
2255
+ async function listResultSessions(options = {}) {
2256
+ await pruneResultSessions(options);
2257
+ const refs = await listSessionRefs(options.saptoolsRoot);
2258
+ const stored = await Promise.all(
2259
+ refs.map(async (ref) => await readStoredSession(manifestPath(ref, options.saptoolsRoot)))
2260
+ );
2261
+ return stored.filter((item) => item !== void 0).map((item) => ({
2262
+ ref: item.ref,
2263
+ createdAt: item.createdAt,
2264
+ expiresAt: item.expiresAt,
2265
+ rowCount: item.result.rowCount,
2266
+ columnCount: item.result.columns.length,
2267
+ truncated: item.result.truncated
2268
+ })).sort((left, right) => left.ref.localeCompare(right.ref));
2269
+ }
2270
+ async function pruneResultSessions(options = {}) {
2271
+ const refs = await listSessionRefs(options.saptoolsRoot);
2272
+ const now = (options.now?.() ?? /* @__PURE__ */ new Date()).getTime();
2273
+ let removed = 0;
2274
+ for (const ref of refs) {
2275
+ const stored = await readStoredSession(manifestPath(ref, options.saptoolsRoot));
2276
+ if (stored === void 0 || Date.parse(stored.expiresAt) <= now) {
2277
+ await rm2(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
2278
+ removed += 1;
2279
+ }
2280
+ }
2281
+ return removed;
2282
+ }
2283
+ async function clearResultSessions(options = {}) {
2284
+ const refs = await listSessionRefs(options.saptoolsRoot);
2285
+ await Promise.all(
2286
+ refs.map(async (ref) => {
2287
+ await rm2(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
2288
+ })
2289
+ );
2290
+ return refs.length;
2291
+ }
2292
+
2293
+ // src/cli-results.ts
1703
2294
  function print(text) {
1704
2295
  process.stdout.write(`${text}
1705
2296
  `);
1706
2297
  }
2298
+ function parseIntOption(value) {
2299
+ const trimmed = value.trim();
2300
+ if (!/^-?\d+$/.test(trimmed)) {
2301
+ throw new CfHanaError("CONFIG", `Expected an integer but received "${value}"`);
2302
+ }
2303
+ const parsed = Number(trimmed);
2304
+ if (!Number.isSafeInteger(parsed)) {
2305
+ throw new CfHanaError("CONFIG", `Expected a safe integer but received "${value}"`);
2306
+ }
2307
+ return parsed;
2308
+ }
2309
+ function positive(name, value, fallback) {
2310
+ const resolved = value ?? fallback;
2311
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) {
2312
+ throw new CfHanaError("CONFIG", `${name} must be a positive safe integer`);
2313
+ }
2314
+ return resolved;
2315
+ }
2316
+ function boundedLength(value) {
2317
+ const resolved = positive("--length", value, DEFAULT_CELL_LIMIT);
2318
+ if (resolved > MAX_CELL_LIMIT) {
2319
+ throw new CfHanaError("CONFIG", `--length must be at most ${String(MAX_CELL_LIMIT)}`);
2320
+ }
2321
+ return resolved;
2322
+ }
2323
+ function nonNegative(name, value) {
2324
+ const resolved = value ?? 0;
2325
+ if (!Number.isSafeInteger(resolved) || resolved < 0) {
2326
+ throw new CfHanaError("CONFIG", `${name} must be a non-negative safe integer`);
2327
+ }
2328
+ return resolved;
2329
+ }
2330
+ function resultFromRows(rows, columns) {
2331
+ return {
2332
+ rows,
2333
+ columns,
2334
+ rowCount: rows.length,
2335
+ statement: "select",
2336
+ truncated: false,
2337
+ elapsedMs: 0
2338
+ };
2339
+ }
2340
+ function csv(rows, columns, limit) {
2341
+ const result = resultFromRows(
2342
+ rows,
2343
+ columns.map((name) => ({ name, typeName: "" }))
2344
+ );
2345
+ return formatCompactCsv(result, limit).text;
2346
+ }
2347
+ async function runShow(ref, options) {
2348
+ const session = await readResultSession(ref);
2349
+ const length = boundedLength(options.length);
2350
+ if (options.row === void 0) {
2351
+ if (options.column !== void 0 || options.path !== void 0 || options.offset !== void 0) {
2352
+ throw new CfHanaError("CONFIG", "--column, --path, and --offset require --row");
2353
+ }
2354
+ print(summaryCsv(session, length));
2355
+ return;
2356
+ }
2357
+ if (options.column === void 0) {
2358
+ if (options.path !== void 0 || options.offset !== void 0) {
2359
+ throw new CfHanaError("CONFIG", "--path and --offset require --column");
2360
+ }
2361
+ const row = selectResultRow(session, options.row);
2362
+ print(formatCompactCsv(resultFromRows([row], session.result.columns), length).text);
2363
+ return;
2364
+ }
2365
+ const selected = selectResultCell(session, options.row, options.column);
2366
+ if (options.path !== void 0) {
2367
+ const rows = inspectJsonCell(selected.value, options.path, length).map((row) => ({
2368
+ PATH: row.path,
2369
+ TYPE: row.type,
2370
+ VALUE: row.value
2371
+ }));
2372
+ print(csv(rows, ["PATH", "TYPE", "VALUE"], length));
2373
+ return;
2374
+ }
2375
+ const window = readCellWindow(selected.value, nonNegative("--offset", options.offset), length);
2376
+ print(
2377
+ csv(
2378
+ [
2379
+ {
2380
+ ROW: selected.row,
2381
+ COLUMN: selected.column,
2382
+ TYPE: window.type,
2383
+ ORIGINAL_LENGTH: window.originalLength,
2384
+ OFFSET: window.offset,
2385
+ VALUE: window.value
2386
+ }
2387
+ ],
2388
+ ["ROW", "COLUMN", "TYPE", "ORIGINAL_LENGTH", "OFFSET", "VALUE"],
2389
+ length
2390
+ )
2391
+ );
2392
+ }
2393
+ function summaryCsv(session, length) {
2394
+ const compact = formatCompactCsv(session.result, DEFAULT_CELL_LIMIT);
2395
+ return csv(
2396
+ [
2397
+ {
2398
+ REF: session.ref,
2399
+ ROWS: session.result.rowCount,
2400
+ COLUMNS: session.result.columns.length,
2401
+ ROW_TRUNCATED: session.result.truncated,
2402
+ TRUNCATED_CELLS: compact.truncatedCells,
2403
+ EXPIRES_AT: session.expiresAt
2404
+ }
2405
+ ],
2406
+ ["REF", "ROWS", "COLUMNS", "ROW_TRUNCATED", "TRUNCATED_CELLS", "EXPIRES_AT"],
2407
+ length
2408
+ );
2409
+ }
2410
+ async function runSearch(ref, text, options) {
2411
+ const session = await readResultSession(ref);
2412
+ const length = boundedLength(options.length);
2413
+ const matches = searchResultSession(session, text, {
2414
+ limit: positive("--limit", options.limit, DEFAULT_RESULT_SEARCH_LIMIT),
2415
+ previewLength: length,
2416
+ ...options.row === void 0 ? {} : { row: options.row },
2417
+ ...options.column === void 0 ? {} : { column: options.column }
2418
+ });
2419
+ const rows = matches.map((match) => ({
2420
+ ROW: match.row,
2421
+ COLUMN: match.column,
2422
+ OFFSET: match.offset ?? null,
2423
+ PATH: match.path,
2424
+ PREVIEW: match.preview
2425
+ }));
2426
+ print(csv(rows, ["ROW", "COLUMN", "OFFSET", "PATH", "PREVIEW"], length));
2427
+ }
2428
+ function cellExportValue(value) {
2429
+ if (Buffer.isBuffer(value)) {
2430
+ return value;
2431
+ }
2432
+ if (value instanceof Date) {
2433
+ return value.toISOString();
2434
+ }
2435
+ if (value === null) {
2436
+ return "";
2437
+ }
2438
+ if (typeof value === "boolean") {
2439
+ return value ? "true" : "false";
2440
+ }
2441
+ return value.toString();
2442
+ }
2443
+ async function runExport(ref, options) {
2444
+ if (options.row === void 0 || options.column === void 0) {
2445
+ throw new CfHanaError("CONFIG", "result export requires --row and --column");
2446
+ }
2447
+ if (options.output === void 0 || options.output.trim().length === 0) {
2448
+ throw new CfHanaError("CONFIG", "result export requires --output");
2449
+ }
2450
+ const session = await readResultSession(ref);
2451
+ const selected = selectResultCell(session, options.row, options.column);
2452
+ await writeFile3(options.output, cellExportValue(selected.value), { mode: 384 });
2453
+ print(`wrote=${options.output}`);
2454
+ }
2455
+ async function runList() {
2456
+ const summaries = await listResultSessions();
2457
+ const rows = summaries.map((summary) => ({
2458
+ REF: summary.ref,
2459
+ ROWS: summary.rowCount,
2460
+ COLUMNS: summary.columnCount,
2461
+ ROW_TRUNCATED: summary.truncated,
2462
+ EXPIRES_AT: summary.expiresAt
2463
+ }));
2464
+ print(csv(rows, ["REF", "ROWS", "COLUMNS", "ROW_TRUNCATED", "EXPIRES_AT"], DEFAULT_CELL_LIMIT));
2465
+ }
2466
+ async function runPrune() {
2467
+ print(`removed=${String(await pruneResultSessions())}`);
2468
+ }
2469
+ async function runClear() {
2470
+ print(`removed=${String(await clearResultSessions())}`);
2471
+ }
2472
+ function registerResultCommands(program) {
2473
+ const result = program.command("result").description("inspect saved query refs");
2474
+ result.command("show <ref>").description("show a saved result, row, cell, or JSON path").option("--row <n>", "one-based result row", parseIntOption).option("--column <name>", "exact column name").option("--offset <n>", "text code-point or binary byte offset", parseIntOption).option("--length <n>", "maximum characters or bytes to print", parseIntOption).option("--path <pointer>", "JSON Pointer inside a saved text cell").action(async (ref, _options, command) => {
2475
+ await runShow(ref, command.opts());
2476
+ });
2477
+ result.command("search <ref> <text>").description("search saved text and JSON values").option("--row <n>", "one-based result row", parseIntOption).option("--column <name>", "exact column name").option("--limit <n>", "maximum matches to print", parseIntOption).option("--length <n>", "maximum preview characters", parseIntOption).action(async (ref, text, _options, command) => {
2478
+ await runSearch(ref, text, command.opts());
2479
+ });
2480
+ result.command("export <ref>").description("write one exact saved cell to a file").requiredOption("--output <path>", "output file path").option("--row <n>", "one-based result row", parseIntOption).option("--column <name>", "exact column name").action(async (ref, _options, command) => {
2481
+ await runExport(ref, command.opts());
2482
+ });
2483
+ result.command("list").description("list active saved refs").action(async () => {
2484
+ await runList();
2485
+ });
2486
+ result.command("prune").description("remove expired saved refs").action(async () => {
2487
+ await runPrune();
2488
+ });
2489
+ result.command("clear").description("remove all saved refs").action(async () => {
2490
+ await runClear();
2491
+ });
2492
+ }
2493
+
2494
+ // src/cli.ts
2495
+ function print2(text) {
2496
+ process.stdout.write(`${text}
2497
+ `);
2498
+ }
1707
2499
  function fail(message) {
1708
2500
  process.stderr.write(`${CLI_NAME}: ${message}
1709
2501
  `);
1710
2502
  process.exit(1);
1711
2503
  }
1712
- function parseIntOption(value) {
2504
+ function parseIntOption2(value) {
1713
2505
  const trimmed = value.trim();
1714
2506
  if (!/^-?\d+$/.test(trimmed)) {
1715
2507
  throw new CfHanaError("CONFIG", `Expected an integer but received "${value}"`);
@@ -1767,6 +2559,21 @@ function toConnectOptions(opts) {
1767
2559
  ...opts.timeout === void 0 ? {} : { queryTimeoutMs: opts.timeout, connectTimeoutMs: opts.timeout }
1768
2560
  };
1769
2561
  }
2562
+ function resolveCellLimit(value) {
2563
+ const limit = value ?? DEFAULT_CELL_LIMIT;
2564
+ assertPositiveOption("--cell-limit", limit);
2565
+ if (limit > MAX_CELL_LIMIT) {
2566
+ throw new CfHanaError("CONFIG", `--cell-limit must be at most ${String(MAX_CELL_LIMIT)}`);
2567
+ }
2568
+ return limit;
2569
+ }
2570
+ function assertQueryOptions(sql, opts) {
2571
+ resolveCellLimit(opts.cellLimit);
2572
+ assertPositiveOption("--result-ttl-minutes", opts.resultTtlMinutes);
2573
+ if (opts.save && classifyStatement(sql) !== "select") {
2574
+ throw new CfHanaError("CONFIG", "--save is only available for SELECT/WITH statements");
2575
+ }
2576
+ }
1770
2577
  function rowsToResult(rows) {
1771
2578
  const first = rows[0];
1772
2579
  const columns = first === void 0 ? [] : Object.keys(first).map((name) => ({ name, typeName: "" }));
@@ -1791,10 +2598,19 @@ function formatInfo(info) {
1791
2598
  ].join("\n");
1792
2599
  }
1793
2600
  function withConnectionOptions(command) {
1794
- return command.option("--format <format>", "output format: table, json, or csv", "table").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", parseIntOption).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", parseIntOption).option("--limit <n>", "row cap auto-applied to bare SELECT statements", parseIntOption).option("--no-auto-limit", "disable the automatic SELECT row cap");
2601
+ 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");
2602
+ }
2603
+ function withFormattedConnectionOptions(command) {
2604
+ return withConnectionOptions(command).option(
2605
+ "--format <format>",
2606
+ "output format: table, json, or csv",
2607
+ "table"
2608
+ );
1795
2609
  }
1796
2610
  async function runQuery(selector, sql, command) {
1797
2611
  const opts = command.opts();
2612
+ assertQueryOptions(sql, opts);
2613
+ const cellLimit = resolveCellLimit(opts.cellLimit);
1798
2614
  const client = await connect(selector, toConnectOptions(opts));
1799
2615
  try {
1800
2616
  const params = opts.param ?? [];
@@ -1804,7 +2620,32 @@ async function runQuery(selector, sql, command) {
1804
2620
  `);
1805
2621
  }
1806
2622
  const result = await client.query(sql, params);
1807
- print(formatResult(result, parseFormat(opts.format)));
2623
+ if (result.statement === "select") {
2624
+ const compact = formatCompactCsv(result, cellLimit);
2625
+ if (opts.save) {
2626
+ const session = await createResultSession({
2627
+ result,
2628
+ info: client.info,
2629
+ ...opts.resultTtlMinutes === void 0 ? {} : { ttlMinutes: opts.resultTtlMinutes }
2630
+ });
2631
+ print2(`ref=${session.ref}`);
2632
+ process.stderr.write(`${CLI_NAME}: saved result expires at ${session.expiresAt}
2633
+ `);
2634
+ }
2635
+ print2(compact.text);
2636
+ if (result.truncated) {
2637
+ process.stderr.write(`${CLI_NAME}: row limit reached; rerun with --limit for more rows
2638
+ `);
2639
+ }
2640
+ if (compact.truncatedCells > 0) {
2641
+ process.stderr.write(
2642
+ `${CLI_NAME}: compacted ${String(compact.truncatedCells)} cell(s); use --save to inspect exact values by ref
2643
+ `
2644
+ );
2645
+ }
2646
+ return;
2647
+ }
2648
+ print2(formatTable(result));
1808
2649
  } finally {
1809
2650
  await client.close();
1810
2651
  }
@@ -1819,7 +2660,7 @@ async function runTables(selector, schema, command) {
1819
2660
  TABLE: table.name,
1820
2661
  TYPE: table.type
1821
2662
  }));
1822
- print(formatResult(rowsToResult(rows), parseFormat(opts.format)));
2663
+ print2(formatResult(rowsToResult(rows), parseFormat(opts.format)));
1823
2664
  } finally {
1824
2665
  await client.close();
1825
2666
  }
@@ -1837,7 +2678,7 @@ async function runColumns(selector, target, command) {
1837
2678
  NULLABLE: column.nullable,
1838
2679
  POSITION: column.position
1839
2680
  }));
1840
- print(formatResult(rowsToResult(rows), parseFormat(opts.format)));
2681
+ print2(formatResult(rowsToResult(rows), parseFormat(opts.format)));
1841
2682
  } finally {
1842
2683
  await client.close();
1843
2684
  }
@@ -1848,7 +2689,7 @@ async function runCount(selector, target, command) {
1848
2689
  const client = await connect(selector, toConnectOptions(opts));
1849
2690
  try {
1850
2691
  const total = await client.count({ schema, table });
1851
- print(String(total));
2692
+ print2(String(total));
1852
2693
  } finally {
1853
2694
  await client.close();
1854
2695
  }
@@ -1859,7 +2700,7 @@ async function runPing(selector, command) {
1859
2700
  try {
1860
2701
  const started = Date.now();
1861
2702
  await client.query("SELECT 1 FROM DUMMY");
1862
- print(
2703
+ print2(
1863
2704
  `OK ${client.info.host} schema=${client.info.schema} ${String(Date.now() - started)}ms`
1864
2705
  );
1865
2706
  } finally {
@@ -1870,7 +2711,7 @@ async function runInfo(selector, command) {
1870
2711
  const opts = command.opts();
1871
2712
  const client = await connect(selector, toConnectOptions(opts));
1872
2713
  try {
1873
- print(formatInfo(client.info));
2714
+ print2(formatInfo(client.info));
1874
2715
  } finally {
1875
2716
  await client.close();
1876
2717
  }
@@ -1879,18 +2720,22 @@ function buildProgram() {
1879
2720
  const program = new Command();
1880
2721
  program.name(CLI_NAME).description("Run SQL against SAP HANA Cloud databases bound to a Cloud Foundry app").version(CLI_VERSION);
1881
2722
  withConnectionOptions(
1882
- program.command("query <selector> <sql>").description("run a single SQL statement").option("--param <value>", "bind a SQL parameter (repeatable)", collectParam, [])
2723
+ program.command("query <selector> <sql>").description("run a single SQL statement").option("--param <value>", "bind a SQL parameter (repeatable)", collectParam, []).option("--save", "save exact returned rows for follow-up inspection", false).option("--cell-limit <n>", "maximum visible characters per data cell", parseIntOption2).option(
2724
+ "--result-ttl-minutes <n>",
2725
+ "minutes before a saved result expires",
2726
+ parseIntOption2
2727
+ )
1883
2728
  ).action(async (selector, sql, _options, command) => {
1884
2729
  await runQuery(selector, sql, command);
1885
2730
  });
1886
- withConnectionOptions(
2731
+ withFormattedConnectionOptions(
1887
2732
  program.command("tables <selector> [schema]").description("list tables in a schema")
1888
2733
  ).action(
1889
2734
  async (selector, schema, _options, command) => {
1890
2735
  await runTables(selector, schema, command);
1891
2736
  }
1892
2737
  );
1893
- withConnectionOptions(
2738
+ withFormattedConnectionOptions(
1894
2739
  program.command("columns <selector> <schema.table>").description("list the columns of a table")
1895
2740
  ).action(async (selector, target, _options, command) => {
1896
2741
  await runColumns(selector, target, command);
@@ -1910,6 +2755,7 @@ function buildProgram() {
1910
2755
  ).action(async (selector, _options, command) => {
1911
2756
  await runInfo(selector, command);
1912
2757
  });
2758
+ registerResultCommands(program);
1913
2759
  return program;
1914
2760
  }
1915
2761
  try {