@saptools/cf-hana 0.5.0 → 0.5.2

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
@@ -3,9 +3,14 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
 
6
+ // src/backup.ts
7
+ import { mkdir, writeFile } from "fs/promises";
8
+ import { homedir } from "os";
9
+ import { join } from "path";
10
+
6
11
  // src/config.ts
7
12
  var CLI_NAME = "cf-hana";
8
- var CLI_VERSION = "0.5.0";
13
+ var CLI_VERSION = "0.5.2";
9
14
  var ENV_PREFIX = "CF_HANA";
10
15
  var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
11
16
  var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
@@ -140,1404 +145,1041 @@ function errorMessage(error) {
140
145
  return String(error);
141
146
  }
142
147
 
143
- // src/metadata-cache.ts
144
- import { createHash } from "crypto";
145
- import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
146
- import { homedir } from "os";
147
- import { join } from "path";
148
- var METADATA_CACHE_TTL_MS = 30 * 6e4;
149
- function metadataCacheRoot(saptoolsRoot) {
150
- return join(saptoolsRoot ?? join(homedir(), ".saptools"), "cf-hana", "metadata");
151
- }
152
- function toMetadataCacheScope(info) {
153
- return {
154
- selector: info.selector,
155
- appName: info.appName,
156
- host: info.host,
157
- schema: info.schema,
158
- role: info.role,
159
- driver: info.driver,
160
- ...info.bindingName === void 0 ? {} : { bindingName: info.bindingName },
161
- ...info.bindingIndex === void 0 ? {} : { bindingIndex: info.bindingIndex }
162
- };
163
- }
164
- function metadataCacheKey(scope) {
165
- return createHash("sha256").update(JSON.stringify(scope)).digest("hex");
166
- }
167
- function metadataCachePath(scope, saptoolsRoot) {
168
- return join(metadataCacheRoot(saptoolsRoot), `${metadataCacheKey(scope)}.json`);
169
- }
170
- function isRecord(value) {
171
- return typeof value === "object" && value !== null && !Array.isArray(value);
172
- }
173
- function isCatalogObject(value) {
174
- return isRecord(value) && typeof value["schema"] === "string" && typeof value["name"] === "string" && (value["type"] === "TABLE" || value["type"] === "VIEW");
175
- }
176
- function hasRequiredScopeFields(value) {
177
- return typeof value["selector"] === "string" && typeof value["appName"] === "string" && typeof value["host"] === "string" && typeof value["schema"] === "string" && typeof value["role"] === "string" && typeof value["driver"] === "string";
178
- }
179
- function hasOptionalScopeFields(value) {
180
- return (value["bindingName"] === void 0 || typeof value["bindingName"] === "string") && (value["bindingIndex"] === void 0 || typeof value["bindingIndex"] === "number");
181
- }
182
- function isScope(value) {
183
- return isRecord(value) && hasRequiredScopeFields(value) && hasOptionalScopeFields(value);
148
+ // src/lob.ts
149
+ var TEXT_LOB_TYPES = /* @__PURE__ */ new Set(["CLOB", "NCLOB"]);
150
+ function normalizeTypeName(typeName2) {
151
+ return typeName2?.trim().toUpperCase() ?? "";
184
152
  }
185
- function scopesEqual(left, right) {
186
- return metadataCacheKey(left) === metadataCacheKey(right);
153
+ function isTextLobType(typeName2) {
154
+ return TEXT_LOB_TYPES.has(normalizeTypeName(typeName2));
187
155
  }
188
- function isStored(value) {
189
- return isRecord(value) && value["version"] === 1 && typeof value["createdAt"] === "string" && isScope(value["scope"]) && Array.isArray(value["objects"]) && value["objects"].every(isCatalogObject);
156
+
157
+ // src/result-preview.ts
158
+ function normalizePreviewChar(value) {
159
+ return value === "\r" || value === "\n" || value === " " ? " " : value;
190
160
  }
191
- async function readMetadataCache(scope, options = {}) {
192
- try {
193
- const parsed = JSON.parse(
194
- await readFile(metadataCachePath(scope, options.saptoolsRoot), "utf8")
195
- );
196
- if (!isStored(parsed) || !scopesEqual(parsed.scope, scope)) {
197
- return void 0;
198
- }
199
- const createdAt = Date.parse(parsed.createdAt);
200
- const now = options.now?.() ?? /* @__PURE__ */ new Date();
201
- const ageMs = now.getTime() - createdAt;
202
- if (!Number.isFinite(createdAt) || ageMs < 0 || ageMs >= METADATA_CACHE_TTL_MS) {
203
- return void 0;
161
+ function previewText(value, limit) {
162
+ let originalLength = 0;
163
+ let text = "";
164
+ for (const char of value) {
165
+ if (originalLength < limit) {
166
+ text += normalizePreviewChar(char);
204
167
  }
205
- return parsed.objects;
206
- } catch {
207
- return void 0;
168
+ originalLength += 1;
208
169
  }
170
+ return {
171
+ text,
172
+ truncated: originalLength > limit,
173
+ originalLength,
174
+ unit: "chars"
175
+ };
209
176
  }
210
- async function writeMetadataCache(scope, objects, options = {}) {
211
- const root = metadataCacheRoot(options.saptoolsRoot);
212
- const path = metadataCachePath(scope, options.saptoolsRoot);
213
- const tempPath = `${path}.tmp-${process.pid.toString()}`;
214
- const stored = {
215
- version: 1,
216
- createdAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
217
- scope,
218
- objects
177
+ function previewBuffer(value, limit) {
178
+ const fullLength = 2 + value.length * 2;
179
+ const visibleBytes = Math.max(0, Math.floor((limit - 2) / 2));
180
+ const hex = value.subarray(0, visibleBytes).toString("hex");
181
+ return {
182
+ text: `0x${hex}`.slice(0, limit),
183
+ truncated: fullLength > limit,
184
+ originalLength: value.length,
185
+ unit: "bytes"
219
186
  };
220
- await mkdir(root, { recursive: true, mode: 448 });
221
- await rm(tempPath, { force: true });
222
- await writeFile(tempPath, `${JSON.stringify(stored)}
223
- `, { encoding: "utf8", mode: 384 });
224
- await rename(tempPath, path);
225
187
  }
226
- async function loadCatalogObjectsWithCache(scope, refresh, loader, options = {}) {
227
- if (!refresh) {
228
- const cached = await readMetadataCache(scope, options);
229
- if (cached !== void 0) {
230
- return cached;
231
- }
188
+ function scalarText(value) {
189
+ if (value instanceof Date) {
190
+ return value.toISOString();
232
191
  }
233
- const objects = await loader();
234
- try {
235
- await writeMetadataCache(scope, objects, options);
236
- } catch {
192
+ if (typeof value === "boolean") {
193
+ return value ? "true" : "false";
237
194
  }
238
- return objects;
195
+ return value.toString();
239
196
  }
240
-
241
- // src/suggestions.ts
242
- var INVALID_OBJECT_PATTERNS = [
243
- /invalid\s+(?:table|view|object)\s+name/i,
244
- /(?:table|view|object)\s+[^\n]*does\s+not\s+exist/i,
245
- /could\s+not\s+find\s+(?:table|view|object)/i
246
- ];
247
- var REF_KEYWORDS = /* @__PURE__ */ new Set(["FROM", "JOIN", "UPDATE", "INTO", "TABLE"]);
248
- var STOP_WORDS = /* @__PURE__ */ new Set([
249
- "AS",
250
- "ON",
251
- "WHERE",
252
- "SET",
253
- "VALUES",
254
- "USING",
255
- "WHEN",
256
- "INNER",
257
- "LEFT",
258
- "RIGHT",
259
- "FULL",
260
- "OUTER",
261
- "CROSS",
262
- "JOIN"
263
- ]);
264
- var CTE_FOLLOWERS = /* @__PURE__ */ new Set(["AS"]);
265
- function isInvalidCatalogObjectError(error) {
266
- if (!(error instanceof QueryError)) {
267
- return false;
197
+ function previewCell(value, limit, typeName2) {
198
+ if (value === null) {
199
+ return { text: "", truncated: false, originalLength: 0, unit: "chars" };
268
200
  }
269
- if (error.sqlState === "42S02" || error.sqlState === "42S01") {
270
- return true;
201
+ if (Buffer.isBuffer(value)) {
202
+ return isTextLobType(typeName2) ? previewText(value.toString("utf8"), limit) : previewBuffer(value, limit);
271
203
  }
272
- return INVALID_OBJECT_PATTERNS.some((pattern) => pattern.test(error.message));
273
- }
274
- function skipLineComment(sql, index) {
275
- let cursor = index + 2;
276
- while (cursor < sql.length && sql[cursor] !== "\n") {
277
- cursor += 1;
204
+ if (typeof value === "string") {
205
+ return previewText(value, limit);
278
206
  }
279
- return cursor;
207
+ return previewText(scalarText(value), limit);
280
208
  }
281
- function skipBlockComment(sql, index) {
282
- let cursor = index + 2;
283
- while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
284
- cursor += 1;
209
+
210
+ // src/format.ts
211
+ function cellText(value, nullText, column) {
212
+ if (value === null) {
213
+ return nullText;
285
214
  }
286
- return Math.min(cursor + 2, sql.length);
215
+ if (value instanceof Date) {
216
+ return value.toISOString();
217
+ }
218
+ if (Buffer.isBuffer(value)) {
219
+ return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
220
+ }
221
+ if (typeof value === "boolean") {
222
+ return value ? "true" : "false";
223
+ }
224
+ return typeof value === "number" ? value.toString() : value;
287
225
  }
288
- function skipStringLiteral(sql, index) {
289
- let cursor = index + 1;
290
- while (cursor < sql.length) {
291
- if (sql[cursor] === "'" && sql[cursor + 1] === "'") {
292
- cursor += 2;
293
- continue;
294
- }
295
- if (sql[cursor] === "'") {
296
- return cursor + 1;
297
- }
298
- cursor += 1;
226
+ function serializeCell(value, column) {
227
+ if (value === null) {
228
+ return null;
299
229
  }
300
- return cursor;
230
+ if (value instanceof Date) {
231
+ return value.toISOString();
232
+ }
233
+ if (Buffer.isBuffer(value)) {
234
+ return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
235
+ }
236
+ return value;
301
237
  }
302
- function readQuotedIdentifier(sql, index) {
303
- let cursor = index + 1;
304
- let text = "";
305
- while (cursor < sql.length) {
306
- if (sql[cursor] === '"' && sql[cursor + 1] === '"') {
307
- text += '"';
308
- cursor += 2;
309
- continue;
310
- }
311
- if (sql[cursor] === '"') {
312
- return { text, next: cursor + 1 };
313
- }
314
- text += sql.charAt(cursor);
315
- cursor += 1;
238
+ function csvEscape(text) {
239
+ if (/[",\r\n]/.test(text)) {
240
+ return `"${text.replace(/"/g, '""')}"`;
316
241
  }
317
- return { text, next: cursor };
242
+ return text;
318
243
  }
319
- function readBareWord(sql, index) {
320
- let cursor = index;
321
- let text = "";
322
- while (cursor < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(cursor))) {
323
- text += sql.charAt(cursor);
324
- cursor += 1;
244
+ function formatTable(result) {
245
+ if (result.columns.length === 0) {
246
+ return `(${String(result.rowCount)} row(s) affected)`;
325
247
  }
326
- return { text, next: cursor };
248
+ const headers = result.columns.map((column) => column.name);
249
+ const rows = result.rows.map(
250
+ (row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL", column))
251
+ );
252
+ const widths = headers.map((header, index) => {
253
+ const widest = rows.reduce(
254
+ (max, cells) => Math.max(max, (cells[index] ?? "").length),
255
+ header.length
256
+ );
257
+ return widest;
258
+ });
259
+ const renderRow = (cells) => cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(" | ");
260
+ const separator = widths.map((width) => "-".repeat(width)).join("-+-");
261
+ const body = rows.map((cells) => renderRow(cells));
262
+ return [renderRow(headers), separator, ...body].join("\n");
327
263
  }
328
- function tokenize(sql) {
329
- const tokens = [];
330
- let index = 0;
331
- while (index < sql.length) {
332
- const char = sql.charAt(index);
333
- if (/\s/.test(char)) {
334
- index += 1;
335
- continue;
336
- }
337
- if (char === "-" && sql[index + 1] === "-") {
338
- index = skipLineComment(sql, index);
339
- continue;
340
- }
341
- if (char === "/" && sql[index + 1] === "*") {
342
- index = skipBlockComment(sql, index);
343
- continue;
344
- }
345
- if (char === "'") {
346
- index = skipStringLiteral(sql, index);
347
- continue;
348
- }
349
- if (char === '"') {
350
- const quoted = readQuotedIdentifier(sql, index);
351
- tokens.push({ kind: "quoted", text: quoted.text });
352
- index = quoted.next;
353
- continue;
354
- }
355
- if (/[A-Za-z_#$]/.test(char)) {
356
- const word = readBareWord(sql, index);
357
- tokens.push({ kind: "word", text: word.text });
358
- index = word.next;
359
- continue;
264
+ function formatJson(result) {
265
+ const rows = result.rows.map((row) => {
266
+ const serialized = {};
267
+ for (const [key, value] of Object.entries(row)) {
268
+ const column = result.columns.find((item) => item.name === key);
269
+ serialized[key] = serializeCell(value, column);
360
270
  }
361
- if (char === ".") {
362
- tokens.push({ kind: "dot", text: char });
363
- } else if (char === ",") {
364
- tokens.push({ kind: "comma", text: char });
365
- } else if (char === "(") {
366
- tokens.push({ kind: "open", text: char });
367
- } else if (char === ")") {
368
- tokens.push({ kind: "close", text: char });
369
- } else {
370
- tokens.push({ kind: "other", text: char });
271
+ return serialized;
272
+ });
273
+ return JSON.stringify(rows, null, 2);
274
+ }
275
+ function formatJsonCompact(result, preferredColumn) {
276
+ const singleColumn = result.columns.length === 1 ? result.columns[0]?.name : void 0;
277
+ const columnName = preferredColumn ?? singleColumn;
278
+ if (columnName === void 0) {
279
+ return formatJson(result);
280
+ }
281
+ const column = result.columns.find((item) => item.name === columnName);
282
+ const values = result.rows.map((row) => serializeCell(row[columnName] ?? null, column));
283
+ return JSON.stringify(values, null, 2);
284
+ }
285
+ function formatCsv(result) {
286
+ const headers = result.columns.map((column) => column.name);
287
+ const lines = [headers.map((header) => csvEscape(header)).join(",")];
288
+ for (const row of result.rows) {
289
+ lines.push(
290
+ result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, "", column))).join(",")
291
+ );
292
+ }
293
+ return lines.join("\r\n");
294
+ }
295
+ function formatCompactCsv(result, cellLimit) {
296
+ const headers = result.columns.map((column) => column.name);
297
+ const lines = [headers.map((header) => csvEscape(header)).join(",")];
298
+ let truncatedCells = 0;
299
+ for (const row of result.rows) {
300
+ const cells = result.columns.map((column) => {
301
+ const preview = previewCell(row[column.name] ?? null, cellLimit, column.typeName);
302
+ if (preview.truncated) {
303
+ truncatedCells += 1;
304
+ }
305
+ return csvEscape(preview.text);
306
+ });
307
+ lines.push(cells.join(","));
308
+ }
309
+ return { text: lines.join("\r\n"), truncatedCells };
310
+ }
311
+ function formatResult(result, format, compactColumn) {
312
+ switch (format) {
313
+ case "table":
314
+ return formatTable(result);
315
+ case "json":
316
+ return formatJson(result);
317
+ case "json-compact":
318
+ return formatJsonCompact(result, compactColumn);
319
+ case "csv":
320
+ return formatCsv(result);
321
+ }
322
+ }
323
+
324
+ // src/backup-sql-parser.ts
325
+ function isIdentifierChar(char) {
326
+ return char !== void 0 && /[A-Za-z0-9_$#]/.test(char);
327
+ }
328
+ function skipQuotedText(sql, index) {
329
+ const quote = sql[index];
330
+ let cursor = index + 1;
331
+ while (cursor < sql.length) {
332
+ if (sql[cursor] === quote) {
333
+ if (sql[cursor + 1] === quote) {
334
+ cursor += 2;
335
+ continue;
336
+ }
337
+ return cursor + 1;
371
338
  }
372
- index += 1;
339
+ cursor += 1;
373
340
  }
374
- return tokens;
341
+ return cursor;
375
342
  }
376
- function upper(token) {
377
- return token?.text.toUpperCase() ?? "";
343
+ function skipLineComment(sql, index) {
344
+ let cursor = index + 2;
345
+ while (cursor < sql.length && sql[cursor] !== "\n") {
346
+ cursor += 1;
347
+ }
348
+ return cursor;
378
349
  }
379
- function isIdentifier(token) {
380
- return token?.kind === "word" || token?.kind === "quoted";
350
+ function skipBlockComment(sql, index) {
351
+ let cursor = index + 2;
352
+ while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
353
+ cursor += 1;
354
+ }
355
+ return Math.min(cursor + 2, sql.length);
381
356
  }
382
- function readName(tokens, start, allowFollowingParens = false) {
383
- const first = tokens[start];
384
- if (!isIdentifier(first)) {
385
- return void 0;
357
+ function skipNonCode(sql, index) {
358
+ const char = sql[index];
359
+ if (char === "'" || char === '"') {
360
+ return skipQuotedText(sql, index);
386
361
  }
387
- let next = start + 1;
388
- let schema;
389
- let name = first.text;
390
- const maybeObject = tokens[next + 1];
391
- if (tokens[next]?.kind === "dot" && isIdentifier(maybeObject)) {
392
- schema = name;
393
- name = maybeObject.text;
394
- next += 2;
362
+ if (char === "-" && sql[index + 1] === "-") {
363
+ return skipLineComment(sql, index);
395
364
  }
396
- if (!allowFollowingParens && tokens[next]?.kind === "open") {
397
- return void 0;
365
+ if (char === "/" && sql[index + 1] === "*") {
366
+ return skipBlockComment(sql, index);
398
367
  }
399
- return { name: schema === void 0 ? { name } : { schema, name }, next };
368
+ return void 0;
400
369
  }
401
- function firstNameFromText(text) {
402
- const tokens = tokenize(text);
403
- for (let index = 0; index < tokens.length; index += 1) {
404
- const read = readName(tokens, index, true);
405
- if (read !== void 0) {
406
- return read.name;
370
+ function skipTrivia(sql, start, end) {
371
+ let index = start;
372
+ while (index < end) {
373
+ if (/\s/.test(sql.charAt(index))) {
374
+ index += 1;
375
+ continue;
376
+ }
377
+ if (sql[index] === "-" && sql[index + 1] === "-") {
378
+ index = skipLineComment(sql, index);
379
+ continue;
380
+ }
381
+ if (sql[index] === "/" && sql[index + 1] === "*") {
382
+ index = skipBlockComment(sql, index);
383
+ continue;
407
384
  }
385
+ break;
408
386
  }
409
- return void 0;
387
+ return index;
410
388
  }
411
- function extractMissingObjectNameFromError(error) {
412
- if (!(error instanceof QueryError)) {
413
- return void 0;
389
+ function readIdentifierPart(sql, start, end) {
390
+ const index = skipTrivia(sql, start, end);
391
+ if (sql[index] === '"') {
392
+ const next2 = skipQuotedText(sql, index);
393
+ return next2 <= end && sql[next2 - 1] === '"' ? { sql: sql.slice(index, next2), end: next2 } : void 0;
414
394
  }
415
- const message = error.message;
416
- const invalidName = /invalid\s+(?:table|view|object)\s+name\s*:?\s*(.+)$/i.exec(message);
417
- if (invalidName?.[1] !== void 0) {
418
- return firstNameFromText(invalidName[1]);
395
+ if (!/[A-Za-z_#$]/.test(sql.charAt(index))) {
396
+ return void 0;
419
397
  }
420
- const missingObject = /(?:table|view|object)\s+(.+?)\s+(?:does\s+not\s+exist|not\s+found)/i.exec(message);
421
- if (missingObject?.[1] !== void 0) {
422
- return firstNameFromText(missingObject[1]);
398
+ let next = index + 1;
399
+ while (next < end && /[A-Za-z0-9_#$]/.test(sql.charAt(next))) {
400
+ next += 1;
423
401
  }
424
- return void 0;
402
+ return { sql: sql.slice(index, next), end: next };
425
403
  }
426
- function cteNames(tokens) {
427
- const names = /* @__PURE__ */ new Set();
428
- if (upper(tokens[0]) !== "WITH") {
429
- return names;
404
+ function readQualifiedTarget(sql, start, end) {
405
+ const first = readIdentifierPart(sql, start, end);
406
+ if (first === void 0) {
407
+ return void 0;
430
408
  }
431
- let depth = 0;
432
- for (let index = 1; index < tokens.length; index += 1) {
433
- const token = tokens[index];
434
- if (depth === 0 && upper(token) === "SELECT") {
409
+ const parts = [first.sql];
410
+ let cursor = first.end;
411
+ while (parts.length < 3) {
412
+ const dot = skipTrivia(sql, cursor, end);
413
+ if (sql[dot] !== ".") {
435
414
  break;
436
415
  }
437
- if (depth === 0 && isIdentifier(token) && CTE_FOLLOWERS.has(upper(tokens[index + 1]))) {
438
- names.add(token.text.toUpperCase());
439
- }
440
- if (token?.kind === "open") {
441
- depth += 1;
442
- } else if (token?.kind === "close" && depth > 0) {
443
- depth -= 1;
416
+ const part = readIdentifierPart(sql, dot + 1, end);
417
+ if (part === void 0) {
418
+ return void 0;
444
419
  }
420
+ parts.push(part.sql);
421
+ cursor = part.end;
445
422
  }
446
- return names;
423
+ return { sql: parts.join("."), reference: parts.at(-1) ?? first.sql, end: cursor };
447
424
  }
448
- function extractMissingObjectName(sql) {
449
- const tokens = tokenize(sql.replace(/^;+|;+$/g, ""));
450
- const ctes = cteNames(tokens);
451
- let candidate;
452
- for (let index = 0; index < tokens.length; index += 1) {
453
- const word = upper(tokens[index]);
454
- if (!REF_KEYWORDS.has(word)) {
425
+ function topLevelTokens(sql) {
426
+ const tokens = [];
427
+ let index = 0;
428
+ let depth = 0;
429
+ while (index < sql.length) {
430
+ const skipped = skipNonCode(sql, index);
431
+ if (skipped !== void 0) {
432
+ index = skipped;
455
433
  continue;
456
434
  }
457
- if (word === "TABLE" && upper(tokens[index - 1]) !== "TRUNCATE") {
435
+ const char = sql.charAt(index);
436
+ if (char === "(") {
437
+ depth += 1;
438
+ index += 1;
458
439
  continue;
459
440
  }
460
- if (word === "INTO" && !(upper(tokens[index - 1]) === "INSERT" || upper(tokens[index - 1]) === "MERGE")) {
441
+ if (char === ")") {
442
+ depth = Math.max(0, depth - 1);
443
+ index += 1;
461
444
  continue;
462
445
  }
463
- const read = readName(tokens, index + 1, word === "INTO");
464
- if (read === void 0) {
446
+ if (!/[A-Za-z_#$]/.test(char)) {
447
+ index += 1;
465
448
  continue;
466
449
  }
467
- if (read.name.schema === void 0 && ctes.has(read.name.name.toUpperCase())) {
468
- continue;
450
+ let end = index + 1;
451
+ while (end < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(end))) {
452
+ end += 1;
469
453
  }
470
- if (STOP_WORDS.has(read.name.name.toUpperCase())) {
471
- continue;
472
- }
473
- candidate = read.name;
474
- let next = read.next;
475
- if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
476
- next += 1;
477
- }
478
- while (tokens[next]?.kind === "comma") {
479
- const commaRead = readName(tokens, next + 1);
480
- if (commaRead === void 0) {
481
- break;
482
- }
483
- if (!(commaRead.name.schema === void 0 && ctes.has(commaRead.name.name.toUpperCase()))) {
484
- candidate = commaRead.name;
485
- }
486
- next = commaRead.next;
487
- if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
488
- next += 1;
489
- }
454
+ if (depth === 0) {
455
+ tokens.push({ keyword: sql.slice(index, end).toUpperCase(), start: index, end });
490
456
  }
457
+ index = end;
491
458
  }
492
- return candidate;
459
+ return tokens;
493
460
  }
494
- function singular(value) {
495
- if (value.endsWith("ES")) {
496
- return value.slice(0, -2);
497
- }
498
- if (value.endsWith("S")) {
499
- return value.slice(0, -1);
461
+ function findToken(tokens, keyword, start = 0) {
462
+ for (let index = start; index < tokens.length; index += 1) {
463
+ if (tokens[index]?.keyword === keyword) {
464
+ return index;
465
+ }
500
466
  }
501
- return value;
467
+ return void 0;
502
468
  }
503
- function norm(value) {
504
- return value.toUpperCase().replace(/[^A-Z0-9]+/g, "");
469
+ function keywordMatches(sql, index, keyword) {
470
+ const end = index + keyword.length;
471
+ return sql.slice(index, end).toUpperCase() === keyword && !isIdentifierChar(sql[index - 1]) && !isIdentifierChar(sql[end]);
505
472
  }
506
- function distance(a, b) {
507
- const previous = Array.from({ length: b.length + 1 }, (_value, index) => index);
508
- for (let leftIndex = 1; leftIndex <= a.length; leftIndex += 1) {
509
- let last = leftIndex - 1;
510
- previous[0] = leftIndex;
511
- for (let rightIndex = 1; rightIndex <= b.length; rightIndex += 1) {
512
- const old = previous[rightIndex] ?? 0;
513
- previous[rightIndex] = Math.min(
514
- (previous[rightIndex] ?? 0) + 1,
515
- (previous[rightIndex - 1] ?? 0) + 1,
516
- last + (a.charAt(leftIndex - 1) === b.charAt(rightIndex - 1) ? 0 : 1)
517
- );
518
- last = old;
473
+ function findTopLevelKeyword(sql, keyword, startIndex = 0) {
474
+ let index = startIndex;
475
+ let depth = 0;
476
+ while (index < sql.length) {
477
+ const skipped = skipNonCode(sql, index);
478
+ if (skipped !== void 0) {
479
+ index = skipped;
480
+ continue;
519
481
  }
482
+ const char = sql[index];
483
+ if (char === "(") {
484
+ depth += 1;
485
+ } else if (char === ")" && depth > 0) {
486
+ depth -= 1;
487
+ } else if (depth === 0 && keywordMatches(sql, index, keyword)) {
488
+ return index;
489
+ }
490
+ index += 1;
520
491
  }
521
- return previous[b.length] ?? 0;
492
+ return void 0;
522
493
  }
523
- function score(requested, candidate) {
524
- const requestedName = norm(requested.name);
525
- const candidateName = norm(candidate.name);
526
- if (requested.schema !== void 0 && norm(requested.schema) !== norm(candidate.schema)) {
527
- return 0;
528
- }
529
- if (requestedName === candidateName) {
530
- return 100;
494
+ function findTopLevelChar(sql, target, startIndex, endIndex) {
495
+ let index = startIndex;
496
+ let depth = 0;
497
+ while (index < endIndex) {
498
+ const skipped = skipNonCode(sql, index);
499
+ if (skipped !== void 0) {
500
+ index = skipped;
501
+ continue;
502
+ }
503
+ const char = sql[index];
504
+ if (char === target && depth === 0) {
505
+ return index;
506
+ }
507
+ if (char === "(") {
508
+ depth += 1;
509
+ } else if (char === ")" && depth > 0) {
510
+ depth -= 1;
511
+ }
512
+ index += 1;
531
513
  }
532
- if (singular(requestedName) === singular(candidateName)) {
533
- return 92;
514
+ return void 0;
515
+ }
516
+
517
+ // src/statements.ts
518
+ var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT"]);
519
+ var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
520
+ var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
521
+ function firstKeyword(sql) {
522
+ let index = 0;
523
+ while (index < sql.length) {
524
+ const char = sql.charAt(index);
525
+ if (char.trim().length === 0) {
526
+ index += 1;
527
+ continue;
528
+ }
529
+ if (char === "-" && sql[index + 1] === "-") {
530
+ index += 2;
531
+ while (index < sql.length && sql[index] !== "\n") {
532
+ index += 1;
533
+ }
534
+ continue;
535
+ }
536
+ if (char === "/" && sql[index + 1] === "*") {
537
+ const commentEnd = sql.indexOf("*/", index + 2);
538
+ if (commentEnd === -1) {
539
+ return "";
540
+ }
541
+ index = commentEnd + 2;
542
+ continue;
543
+ }
544
+ break;
534
545
  }
535
- let value = 0;
536
- if (candidateName.startsWith(requestedName) || requestedName.startsWith(candidateName)) {
537
- value = Math.max(value, 80 - Math.abs(candidateName.length - requestedName.length));
546
+ let keywordEnd = index;
547
+ while (keywordEnd < sql.length) {
548
+ const code = sql.charCodeAt(keywordEnd);
549
+ const isUpperCase = code >= 65 && code <= 90;
550
+ const isLowerCase = code >= 97 && code <= 122;
551
+ if (!isUpperCase && !isLowerCase) {
552
+ break;
553
+ }
554
+ keywordEnd += 1;
538
555
  }
539
- if (candidateName.endsWith(requestedName) || requestedName.endsWith(candidateName)) {
540
- value = Math.max(value, 70 - Math.abs(candidateName.length - requestedName.length));
556
+ return sql.slice(index, keywordEnd).toUpperCase();
557
+ }
558
+ function skipQuotedText2(sql, start) {
559
+ const quote = sql[start];
560
+ let index = start + 1;
561
+ while (index < sql.length) {
562
+ if (sql[index] === quote) {
563
+ if (sql[index + 1] === quote) {
564
+ index += 2;
565
+ continue;
566
+ }
567
+ index += 1;
568
+ break;
569
+ }
570
+ index += 1;
541
571
  }
542
- const editDistance = distance(requestedName, candidateName);
543
- const max = Math.max(requestedName.length, candidateName.length, 1);
544
- if (editDistance <= Math.max(3, Math.floor(max * 0.35))) {
545
- value = Math.max(value, Math.round(75 * (1 - editDistance / max)));
572
+ return index;
573
+ }
574
+ function skipLineComment2(sql, start) {
575
+ let index = start + 2;
576
+ while (index < sql.length && sql[index] !== "\n") {
577
+ index += 1;
546
578
  }
547
- return value;
579
+ return index;
548
580
  }
549
- function extractInvalidColumnNameFromError(error) {
550
- if (!(error instanceof QueryError)) {
551
- return void 0;
581
+ function skipBlockComment2(sql, start) {
582
+ let index = start + 2;
583
+ while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
584
+ index += 1;
552
585
  }
553
- const match = /invalid column name:\s*([^:]+)/i.exec(error.message);
554
- const name = match?.[1]?.trim();
555
- return name === void 0 || name.length === 0 ? void 0 : name;
586
+ return Math.min(index + 2, sql.length);
556
587
  }
557
- function rankCatalogSuggestions(requested, candidates, limit = 5) {
558
- return candidates.map((candidate) => ({ candidate, score: score(requested, candidate) })).filter((item) => item.score >= 45).sort(
559
- (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)
560
- ).slice(0, limit).map((item) => item.candidate);
588
+ function maskIgnoredSqlText(sql) {
589
+ let masked = "";
590
+ let index = 0;
591
+ while (index < sql.length) {
592
+ const char = sql[index];
593
+ if (char === "'" || char === '"') {
594
+ const end = skipQuotedText2(sql, index);
595
+ masked += " ".repeat(end - index);
596
+ index = end;
597
+ continue;
598
+ }
599
+ if (char === "-" && sql[index + 1] === "-") {
600
+ const end = skipLineComment2(sql, index);
601
+ masked += " ".repeat(end - index);
602
+ index = end;
603
+ continue;
604
+ }
605
+ if (char === "/" && sql[index + 1] === "*") {
606
+ const end = skipBlockComment2(sql, index);
607
+ masked += " ".repeat(end - index);
608
+ index = end;
609
+ continue;
610
+ }
611
+ masked += char ?? "";
612
+ index += 1;
613
+ }
614
+ return masked;
561
615
  }
562
- function formatSuggestions(suggestions) {
563
- if (suggestions.length === 0) {
564
- return void 0;
616
+ function topLevelKeywordIndex(sql, keyword) {
617
+ const masked = maskIgnoredSqlText(sql);
618
+ let depth = 0;
619
+ for (let index = 0; index < masked.length; index += 1) {
620
+ const char = masked[index];
621
+ if (char === "(") {
622
+ depth += 1;
623
+ continue;
624
+ }
625
+ if (char === ")") {
626
+ depth = Math.max(0, depth - 1);
627
+ continue;
628
+ }
629
+ if (depth === 0 && masked.slice(index, index + keyword.length).toUpperCase() === keyword && !/[A-Za-z0-9_$#]/.test(masked.charAt(index - 1)) && !/[A-Za-z0-9_$#]/.test(masked.charAt(index + keyword.length))) {
630
+ return index;
631
+ }
565
632
  }
566
- return [
567
- "Did you mean:",
568
- ...suggestions.map((item) => ` ${item.schema}.${item.name} (${item.type})`)
569
- ].join("\n");
633
+ return void 0;
570
634
  }
571
- function rankNameSuggestions(requested, candidates, limit = 5) {
572
- const requestedName = { name: requested };
573
- return candidates.map((candidate) => ({
574
- candidate,
575
- score: score(requestedName, { schema: "", name: candidate.name, type: "TABLE" })
576
- })).filter((item) => item.score >= 45).sort(
577
- (left, right) => right.score - left.score || left.candidate.name.localeCompare(right.candidate.name)
578
- ).slice(0, limit).map((item) => item.candidate);
635
+ function hasTopLevelKeyword(sql, keyword) {
636
+ return topLevelKeywordIndex(sql, keyword) !== void 0;
579
637
  }
580
- function formatColumnSuggestions(suggestions) {
581
- if (suggestions.length === 0) {
582
- return void 0;
638
+ function isIdentifierChar2(char) {
639
+ return /[A-Za-z0-9_$#]/.test(char);
640
+ }
641
+ function skipWhitespace(masked, start) {
642
+ let index = start;
643
+ while (index < masked.length && /\s/.test(masked.charAt(index))) {
644
+ index += 1;
583
645
  }
584
- return [
585
- "Did you mean column:",
586
- ...suggestions.map((item) => ` ${item.name}`)
587
- ].join("\n");
646
+ return index;
588
647
  }
589
-
590
- // src/002-cli-query-hints.ts
591
- async function loadSuggestionCatalogObjects(client, refresh) {
592
- try {
593
- return await loadCatalogObjectsWithCache(
594
- toMetadataCacheScope(client.info),
595
- refresh,
596
- async () => await client.listCatalogObjects(client.info.schema)
597
- );
598
- } catch {
599
- return await client.listCatalogObjects(client.info.schema);
600
- }
601
- }
602
- function isLobSortOrGroupError(error) {
603
- const code = databaseCode(error);
604
- if (code !== 266 && code !== 274) {
605
- return false;
606
- }
607
- return error instanceof QueryError && /LOB type is not allowed in (?:ORDER BY|GROUP BY) clause/i.test(error.message);
608
- }
609
- function printLobSortOrGroupHint() {
610
- const lines = [
611
- `${CLI_NAME}: HANA cannot ORDER BY or GROUP BY NCLOB/CLOB/BLOB columns directly.`,
612
- `${CLI_NAME}: Remove the LOB column from ORDER BY/GROUP BY or wrap it as TO_VARCHAR(<column>).`
613
- ];
614
- process.stderr.write(`${lines.join("\n")}
615
- `);
616
- }
617
- function isInsufficientPrivilegeError(error) {
618
- return databaseCode(error) === 258 || /\binsufficient privilege\b/i.test(errorMessage(error));
619
- }
620
- function printInsufficientPrivilegeHint(client, schema) {
621
- const binding = client.info.bindingName ?? (client.info.bindingIndex === void 0 ? "unknown" : `#${String(client.info.bindingIndex)}`);
622
- const otherBindings = (client.info.availableBindingNames ?? []).filter(
623
- (name) => name !== client.info.bindingName
624
- );
625
- const retryBinding = otherBindings[0];
626
- const lines = [
627
- `${CLI_NAME}: insufficient privilege for schema ${schema} as database user ${client.databaseUser || "unknown"} (current binding: ${binding}).`
628
- ];
629
- if (retryBinding !== void 0) {
630
- lines.push(
631
- `${CLI_NAME}: other HANA bindings on this app: ${otherBindings.join(", ")}; retry with --binding ${retryBinding}.`
632
- );
648
+ function matchingCloseParenIndex(masked, openIndex) {
649
+ let depth = 0;
650
+ for (let index = openIndex; index < masked.length; index += 1) {
651
+ const char = masked.charAt(index);
652
+ if (char === "(") {
653
+ depth += 1;
654
+ } else if (char === ")") {
655
+ depth -= 1;
656
+ if (depth === 0) {
657
+ return index;
658
+ }
659
+ }
633
660
  }
634
- lines.push(
635
- `${CLI_NAME}: try another app or full selector whose binding has the grant; no automatic retry was attempted.`
636
- );
637
- process.stderr.write(`${lines.join("\n")}
638
- `);
661
+ return void 0;
639
662
  }
640
- function rethrowWithPrivilegeHint(error, client, schema) {
641
- if (isInsufficientPrivilegeError(error)) {
642
- printInsufficientPrivilegeHint(client, schema);
643
- }
644
- throw error;
663
+ function isAsKeywordAt(masked, index) {
664
+ return masked.slice(index, index + 2).toUpperCase() === "AS" && !isIdentifierChar2(masked.charAt(index - 1)) && !isIdentifierChar2(masked.charAt(index + 2));
645
665
  }
646
- async function printColumnSuggestions(error, client, sql) {
647
- if (databaseCode(error) !== 260) {
648
- return;
649
- }
650
- const columnName = extractInvalidColumnNameFromError(error);
651
- const tableName = extractMissingObjectName(sql);
652
- if (columnName === void 0 || tableName === void 0) {
653
- return;
654
- }
655
- try {
656
- const columns = await client.listColumns(
657
- tableName.schema ?? client.info.schema,
658
- tableName.name
659
- );
660
- const text = formatColumnSuggestions(rankNameSuggestions(columnName, columns));
661
- if (text !== void 0) {
662
- process.stderr.write(`${text}
663
- `);
666
+ function skipCteName(sql, masked, start) {
667
+ let index = start;
668
+ while (index < sql.length) {
669
+ const char = sql.charAt(index);
670
+ if (char.trim().length === 0) {
671
+ index += 1;
672
+ continue;
664
673
  }
665
- } catch {
666
- }
667
- }
668
- async function printCatalogObjectSuggestions(error, client, sql, refresh) {
669
- if (!isInvalidCatalogObjectError(error)) {
670
- return;
671
- }
672
- const requested = extractMissingObjectNameFromError(error) ?? extractMissingObjectName(sql);
673
- if (requested === void 0) {
674
- return;
675
- }
676
- try {
677
- const objects = await loadSuggestionCatalogObjects(client, refresh);
678
- const text = formatSuggestions(rankCatalogSuggestions(requested, objects));
679
- if (text !== void 0) {
680
- process.stderr.write(`${text}
681
- `);
674
+ if (char === "-" && sql.charAt(index + 1) === "-") {
675
+ index = skipLineComment2(sql, index);
676
+ continue;
682
677
  }
683
- } catch {
678
+ if (char === "/" && sql.charAt(index + 1) === "*") {
679
+ index = skipBlockComment2(sql, index);
680
+ continue;
681
+ }
682
+ break;
684
683
  }
685
- }
686
- async function enrichAndRethrowQueryError(error, client, sql, refresh) {
687
- if (isInsufficientPrivilegeError(error)) {
688
- const schema = extractMissingObjectName(sql)?.schema ?? client.info.schema;
689
- printInsufficientPrivilegeHint(client, schema);
690
- throw error;
684
+ if (sql.charAt(index) === "'" || sql.charAt(index) === '"') {
685
+ return skipQuotedText2(sql, index);
691
686
  }
692
- if (isLobSortOrGroupError(error)) {
693
- printLobSortOrGroupHint();
694
- throw error;
687
+ let end = index;
688
+ while (end < masked.length && isIdentifierChar2(masked.charAt(end))) {
689
+ end += 1;
695
690
  }
696
- await printColumnSuggestions(error, client, sql);
697
- await printCatalogObjectSuggestions(error, client, sql, refresh);
698
- throw error;
699
- }
700
-
701
- // src/backup.ts
702
- import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
703
- import { homedir as homedir2 } from "os";
704
- import { join as join2 } from "path";
705
-
706
- // src/lob.ts
707
- var TEXT_LOB_TYPES = /* @__PURE__ */ new Set(["CLOB", "NCLOB"]);
708
- function normalizeTypeName(typeName2) {
709
- return typeName2?.trim().toUpperCase() ?? "";
710
- }
711
- function isTextLobType(typeName2) {
712
- return TEXT_LOB_TYPES.has(normalizeTypeName(typeName2));
713
- }
714
-
715
- // src/result-preview.ts
716
- function normalizePreviewChar(value) {
717
- return value === "\r" || value === "\n" || value === " " ? " " : value;
691
+ return end;
718
692
  }
719
- function previewText(value, limit) {
720
- let originalLength = 0;
721
- let text = "";
722
- for (const char of value) {
723
- if (originalLength < limit) {
724
- text += normalizePreviewChar(char);
693
+ function cteListEndIndex(sql, masked, afterWith) {
694
+ let index = afterWith;
695
+ for (; ; ) {
696
+ index = skipCteName(sql, masked, index);
697
+ index = skipWhitespace(masked, index);
698
+ if (masked.charAt(index) === "(") {
699
+ const columnListEnd = matchingCloseParenIndex(masked, index);
700
+ if (columnListEnd === void 0) {
701
+ return void 0;
702
+ }
703
+ index = skipWhitespace(masked, columnListEnd + 1);
725
704
  }
726
- originalLength += 1;
727
- }
728
- return {
729
- text,
730
- truncated: originalLength > limit,
731
- originalLength,
732
- unit: "chars"
733
- };
734
- }
735
- function previewBuffer(value, limit) {
736
- const fullLength = 2 + value.length * 2;
737
- const visibleBytes = Math.max(0, Math.floor((limit - 2) / 2));
738
- const hex = value.subarray(0, visibleBytes).toString("hex");
739
- return {
740
- text: `0x${hex}`.slice(0, limit),
741
- truncated: fullLength > limit,
742
- originalLength: value.length,
743
- unit: "bytes"
744
- };
745
- }
746
- function scalarText(value) {
747
- if (value instanceof Date) {
748
- return value.toISOString();
749
- }
750
- if (typeof value === "boolean") {
751
- return value ? "true" : "false";
705
+ if (!isAsKeywordAt(masked, index)) {
706
+ return void 0;
707
+ }
708
+ index = skipWhitespace(masked, index + 2);
709
+ if (masked.charAt(index) !== "(") {
710
+ return void 0;
711
+ }
712
+ const closeIndex = matchingCloseParenIndex(masked, index);
713
+ if (closeIndex === void 0) {
714
+ return void 0;
715
+ }
716
+ index = skipWhitespace(masked, closeIndex + 1);
717
+ if (masked.charAt(index) === ",") {
718
+ index += 1;
719
+ continue;
720
+ }
721
+ return index;
752
722
  }
753
- return value.toString();
754
723
  }
755
- function previewCell(value, limit, typeName2) {
756
- if (value === null) {
757
- return { text: "", truncated: false, originalLength: 0, unit: "chars" };
724
+ function resolveWithStatement(sql) {
725
+ const withIndex = topLevelKeywordIndex(sql, "WITH");
726
+ if (withIndex === void 0) {
727
+ return void 0;
758
728
  }
759
- if (Buffer.isBuffer(value)) {
760
- return isTextLobType(typeName2) ? previewText(value.toString("utf8"), limit) : previewBuffer(value, limit);
729
+ const masked = maskIgnoredSqlText(sql);
730
+ const trailingIndex = cteListEndIndex(sql, masked, withIndex + "WITH".length);
731
+ if (trailingIndex === void 0) {
732
+ return void 0;
761
733
  }
762
- if (typeof value === "string") {
763
- return previewText(value, limit);
734
+ let keywordEnd = trailingIndex;
735
+ while (keywordEnd < masked.length && isIdentifierChar2(masked.charAt(keywordEnd))) {
736
+ keywordEnd += 1;
764
737
  }
765
- return previewText(scalarText(value), limit);
738
+ return { keyword: masked.slice(trailingIndex, keywordEnd).toUpperCase(), index: trailingIndex };
766
739
  }
767
-
768
- // src/format.ts
769
- function cellText(value, nullText, column) {
770
- if (value === null) {
771
- return nullText;
772
- }
773
- if (value instanceof Date) {
774
- return value.toISOString();
740
+ function classifyByKeyword(keyword) {
741
+ if (SELECT_KEYWORDS.has(keyword)) {
742
+ return "select";
775
743
  }
776
- if (Buffer.isBuffer(value)) {
777
- return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
744
+ if (DML_KEYWORDS.has(keyword)) {
745
+ return "dml";
778
746
  }
779
- if (typeof value === "boolean") {
780
- return value ? "true" : "false";
747
+ if (DDL_KEYWORDS.has(keyword)) {
748
+ return "ddl";
781
749
  }
782
- return typeof value === "number" ? value.toString() : value;
750
+ return "unknown";
783
751
  }
784
- function serializeCell(value, column) {
785
- if (value === null) {
786
- return null;
752
+ function classifyStatement(sql) {
753
+ const keyword = firstKeyword(sql);
754
+ if (keyword === "WITH") {
755
+ const resolved = resolveWithStatement(sql);
756
+ return resolved === void 0 ? "unknown" : classifyByKeyword(resolved.keyword);
787
757
  }
788
- if (value instanceof Date) {
789
- return value.toISOString();
758
+ return classifyByKeyword(keyword);
759
+ }
760
+ function quoteIdentifier(identifier) {
761
+ if (identifier.length === 0) {
762
+ throw new QueryError("A SQL identifier must not be empty");
790
763
  }
791
- if (Buffer.isBuffer(value)) {
792
- return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
764
+ if (identifier.includes("\0")) {
765
+ throw new QueryError("A SQL identifier must not contain a NUL character");
793
766
  }
794
- return value;
767
+ return `"${identifier.replace(/"/g, '""')}"`;
795
768
  }
796
- function csvEscape(text) {
797
- if (/[",\r\n]/.test(text)) {
798
- return `"${text.replace(/"/g, '""')}"`;
799
- }
800
- return text;
769
+ function qualifiedName(schema, table) {
770
+ return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
801
771
  }
802
- function formatTable(result) {
803
- if (result.columns.length === 0) {
804
- return `(${String(result.rowCount)} row(s) affected)`;
805
- }
806
- const headers = result.columns.map((column) => column.name);
807
- const rows = result.rows.map(
808
- (row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL", column))
809
- );
810
- const widths = headers.map((header, index) => {
811
- const widest = rows.reduce(
812
- (max, cells) => Math.max(max, (cells[index] ?? "").length),
813
- header.length
814
- );
815
- return widest;
816
- });
817
- const renderRow = (cells) => cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(" | ");
818
- const separator = widths.map((width) => "-".repeat(width)).join("-+-");
819
- const body = rows.map((cells) => renderRow(cells));
820
- return [renderRow(headers), separator, ...body].join("\n");
821
- }
822
- function formatJson(result) {
823
- const rows = result.rows.map((row) => {
824
- const serialized = {};
825
- for (const [key, value] of Object.entries(row)) {
826
- const column = result.columns.find((item) => item.name === key);
827
- serialized[key] = serializeCell(value, column);
772
+ function countPlaceholders(sql) {
773
+ let count = 0;
774
+ let index = 0;
775
+ const length = sql.length;
776
+ while (index < length) {
777
+ const char = sql[index];
778
+ if (char === "'" || char === '"') {
779
+ const quote = char;
780
+ index += 1;
781
+ while (index < length) {
782
+ if (sql[index] === quote) {
783
+ if (sql[index + 1] === quote) {
784
+ index += 2;
785
+ continue;
786
+ }
787
+ index += 1;
788
+ break;
789
+ }
790
+ index += 1;
791
+ }
792
+ continue;
828
793
  }
829
- return serialized;
830
- });
831
- return JSON.stringify(rows, null, 2);
832
- }
833
- function formatJsonCompact(result, preferredColumn) {
834
- const singleColumn = result.columns.length === 1 ? result.columns[0]?.name : void 0;
835
- const columnName = preferredColumn ?? singleColumn;
836
- if (columnName === void 0) {
837
- return formatJson(result);
794
+ if (char === "-" && sql[index + 1] === "-") {
795
+ index += 2;
796
+ while (index < length && sql[index] !== "\n") {
797
+ index += 1;
798
+ }
799
+ continue;
800
+ }
801
+ if (char === "/" && sql[index + 1] === "*") {
802
+ index += 2;
803
+ while (index < length && !(sql[index] === "*" && sql[index + 1] === "/")) {
804
+ index += 1;
805
+ }
806
+ index += 2;
807
+ continue;
808
+ }
809
+ if (char === "?") {
810
+ count += 1;
811
+ }
812
+ index += 1;
838
813
  }
839
- const column = result.columns.find((item) => item.name === columnName);
840
- const values = result.rows.map((row) => serializeCell(row[columnName] ?? null, column));
841
- return JSON.stringify(values, null, 2);
814
+ return count;
842
815
  }
843
- function formatCsv(result) {
844
- const headers = result.columns.map((column) => column.name);
845
- const lines = [headers.map((header) => csvEscape(header)).join(",")];
846
- for (const row of result.rows) {
847
- lines.push(
848
- result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, "", column))).join(",")
816
+ function assertParamArity(sql, params) {
817
+ const expected = countPlaceholders(sql);
818
+ if (expected !== params.length) {
819
+ throw new QueryError(
820
+ `SQL expects ${String(expected)} bound parameter(s) but received ${String(params.length)} value(s)`
849
821
  );
850
822
  }
851
- return lines.join("\r\n");
852
823
  }
853
- function formatCompactCsv(result, cellLimit) {
854
- const headers = result.columns.map((column) => column.name);
855
- const lines = [headers.map((header) => csvEscape(header)).join(",")];
856
- let truncatedCells = 0;
857
- for (const row of result.rows) {
858
- const cells = result.columns.map((column) => {
859
- const preview = previewCell(row[column.name] ?? null, cellLimit, column.typeName);
860
- if (preview.truncated) {
861
- truncatedCells += 1;
862
- }
863
- return csvEscape(preview.text);
864
- });
865
- lines.push(cells.join(","));
824
+
825
+ // src/backup-planner.ts
826
+ var WRITE_KEYWORDS = /* @__PURE__ */ new Set([
827
+ "UPDATE",
828
+ "UPSERT",
829
+ "REPLACE",
830
+ "MERGE",
831
+ "DELETE"
832
+ ]);
833
+ var MERGE_MODIFY_ACTIONS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
834
+ function trimStatementSql(sql) {
835
+ const trimmed = sql.trim();
836
+ let end = trimmed.length;
837
+ while (end > 0 && trimmed.charAt(end - 1) === ";") {
838
+ end -= 1;
866
839
  }
867
- return { text: lines.join("\r\n"), truncatedCells };
840
+ return trimmed.slice(0, end).trim();
868
841
  }
869
- function formatResult(result, format, compactColumn) {
870
- switch (format) {
871
- case "table":
872
- return formatTable(result);
873
- case "json":
874
- return formatJson(result);
875
- case "json-compact":
876
- return formatJsonCompact(result, compactColumn);
877
- case "csv":
878
- return formatCsv(result);
842
+ function isMergeClauseStart(tokens, index) {
843
+ if (tokens[index]?.keyword !== "WHEN") {
844
+ return false;
879
845
  }
846
+ return tokens[index + 1]?.keyword === "MATCHED" || tokens[index + 1]?.keyword === "NOT" && tokens[index + 2]?.keyword === "MATCHED";
880
847
  }
881
-
882
- // src/003-backup-sql-parser.ts
883
- function isIdentifierChar(char) {
884
- return char !== void 0 && /[A-Za-z0-9_$#]/.test(char);
885
- }
886
- function skipQuotedText(sql, index) {
887
- const quote = sql[index];
888
- let cursor = index + 1;
889
- while (cursor < sql.length) {
890
- if (sql[cursor] === quote) {
891
- if (sql[cursor + 1] === quote) {
892
- cursor += 2;
893
- continue;
894
- }
895
- return cursor + 1;
848
+ function mergeClauseIndexes(tokens, start) {
849
+ const indexes = [];
850
+ for (let index = start; index < tokens.length; index += 1) {
851
+ if (isMergeClauseStart(tokens, index)) {
852
+ indexes.push(index);
896
853
  }
897
- cursor += 1;
898
854
  }
899
- return cursor;
855
+ return indexes;
900
856
  }
901
- function skipLineComment2(sql, index) {
902
- let cursor = index + 2;
903
- while (cursor < sql.length && sql[cursor] !== "\n") {
904
- cursor += 1;
905
- }
906
- return cursor;
857
+ function selectParamsAfterWhere(statementSql, whereIndex, params) {
858
+ return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
907
859
  }
908
- function skipBlockComment2(sql, index) {
909
- let cursor = index + 2;
910
- while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
911
- cursor += 1;
860
+ function buildSelectPlan(operation, statementSql, targetSql, whereIndex, params) {
861
+ const target = targetSql.trim();
862
+ if (target.length === 0) {
863
+ throw new QueryError(`${operation.toUpperCase()} backup requires a target table`);
912
864
  }
913
- return Math.min(cursor + 2, sql.length);
865
+ if (whereIndex === void 0) {
866
+ return { operation, statementSql, selectSql: `SELECT * FROM ${target}`, selectParams: [] };
867
+ }
868
+ const whereSql = statementSql.slice(whereIndex + "WHERE".length).trim();
869
+ if (whereSql.length === 0) {
870
+ throw new QueryError(`${operation.toUpperCase()} backup requires a non-empty WHERE clause`);
871
+ }
872
+ return {
873
+ operation,
874
+ statementSql,
875
+ selectSql: `SELECT * FROM ${target} WHERE ${whereSql}`,
876
+ selectParams: selectParamsAfterWhere(statementSql, whereIndex, params)
877
+ };
914
878
  }
915
- function skipNonCode(sql, index) {
916
- const char = sql[index];
917
- if (char === "'" || char === '"') {
918
- return skipQuotedText(sql, index);
879
+ function buildUpdateBackupPlan(statementSql, params) {
880
+ const updateIndex = findTopLevelKeyword(statementSql, "UPDATE");
881
+ const setIndex = updateIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "SET", updateIndex + "UPDATE".length);
882
+ if (updateIndex === void 0 || setIndex === void 0) {
883
+ throw new QueryError("UPDATE backup requires UPDATE <target> SET syntax");
919
884
  }
920
- if (char === "-" && sql[index + 1] === "-") {
921
- return skipLineComment2(sql, index);
885
+ const whereIndex = findTopLevelKeyword(statementSql, "WHERE", setIndex + "SET".length);
886
+ return buildSelectPlan(
887
+ "update",
888
+ statementSql,
889
+ statementSql.slice(updateIndex + "UPDATE".length, setIndex),
890
+ whereIndex,
891
+ params
892
+ );
893
+ }
894
+ function buildUpsertBackupPlan(statementSql, params, keyword) {
895
+ const operation = keyword === "UPSERT" ? "upsert" : "replace";
896
+ const keywordIndex = findTopLevelKeyword(statementSql, keyword);
897
+ const target = keywordIndex === void 0 ? void 0 : readQualifiedTarget(statementSql, keywordIndex + keyword.length, statementSql.length);
898
+ if (keywordIndex === void 0 || target === void 0) {
899
+ throw new BackupRequiredError(
900
+ `${keyword} write refused: cannot derive a trustworthy backup target`
901
+ );
922
902
  }
923
- if (char === "/" && sql[index + 1] === "*") {
924
- return skipBlockComment2(sql, index);
903
+ const valuesIndex = findTopLevelKeyword(statementSql, "VALUES", target.end);
904
+ if (valuesIndex === void 0) {
905
+ const selectIndex = findTopLevelKeyword(statementSql, "SELECT", target.end);
906
+ const withIndex = findTopLevelKeyword(statementSql, "WITH", target.end);
907
+ if (selectIndex === void 0 && withIndex === void 0) {
908
+ throw new BackupRequiredError(
909
+ `${keyword} write refused: expected VALUES, WITH PRIMARY KEY, or a subquery`
910
+ );
911
+ }
912
+ return buildSelectPlan(operation, statementSql, target.sql, void 0, params);
925
913
  }
926
- return void 0;
914
+ const whereIndex = findTopLevelKeyword(statementSql, "WHERE", valuesIndex + "VALUES".length);
915
+ return buildSelectPlan(operation, statementSql, target.sql, whereIndex, params);
927
916
  }
928
- function skipTrivia(sql, start, end) {
929
- let index = start;
930
- while (index < end) {
931
- if (/\s/.test(sql.charAt(index))) {
932
- index += 1;
933
- continue;
917
+ function mergeTargetReference(statementSql, target, usingStart) {
918
+ let suffix = statementSql.slice(target.end, usingStart).trim();
919
+ if (suffix.length === 0) {
920
+ return target.reference;
921
+ }
922
+ if (/^PARTITION\b/i.test(suffix)) {
923
+ const open2 = suffix.indexOf("(");
924
+ if (open2 === -1) {
925
+ return void 0;
934
926
  }
935
- if (sql[index] === "-" && sql[index + 1] === "-") {
936
- index = skipLineComment2(sql, index);
937
- continue;
927
+ const close = findTopLevelChar(suffix, ")", open2 + 1, suffix.length);
928
+ if (close === void 0) {
929
+ return void 0;
938
930
  }
939
- if (sql[index] === "/" && sql[index + 1] === "*") {
940
- index = skipBlockComment2(sql, index);
941
- continue;
931
+ suffix = suffix.slice(close + 1).trim();
932
+ if (suffix.length === 0) {
933
+ return target.reference;
942
934
  }
943
- break;
944
- }
945
- return index;
946
- }
947
- function readIdentifierPart(sql, start, end) {
948
- const index = skipTrivia(sql, start, end);
949
- if (sql[index] === '"') {
950
- const next2 = skipQuotedText(sql, index);
951
- return next2 <= end && sql[next2 - 1] === '"' ? { sql: sql.slice(index, next2), end: next2 } : void 0;
952
935
  }
953
- if (!/[A-Za-z_#$]/.test(sql.charAt(index))) {
936
+ suffix = suffix.replace(/^AS\b/i, "").trim();
937
+ const alias = readIdentifierPart(suffix, 0, suffix.length);
938
+ if (alias === void 0 || skipTrivia(suffix, alias.end, suffix.length) !== suffix.length) {
954
939
  return void 0;
955
940
  }
956
- let next = index + 1;
957
- while (next < end && /[A-Za-z0-9_#$]/.test(sql.charAt(next))) {
958
- next += 1;
959
- }
960
- return { sql: sql.slice(index, next), end: next };
941
+ return alias.sql;
961
942
  }
962
- function readQualifiedTarget(sql, start, end) {
963
- const first = readIdentifierPart(sql, start, end);
964
- if (first === void 0) {
965
- return void 0;
966
- }
967
- const parts = [first.sql];
968
- let cursor = first.end;
969
- while (parts.length < 3) {
970
- const dot = skipTrivia(sql, cursor, end);
971
- if (sql[dot] !== ".") {
972
- break;
973
- }
974
- const part = readIdentifierPart(sql, dot + 1, end);
975
- if (part === void 0) {
976
- return void 0;
977
- }
978
- parts.push(part.sql);
979
- cursor = part.end;
980
- }
981
- return { sql: parts.join("."), reference: parts.at(-1) ?? first.sql, end: cursor };
943
+ function paramsInRange(sql, params, start, end) {
944
+ const offset = countPlaceholders(sql.slice(0, start));
945
+ const length = countPlaceholders(sql.slice(start, end));
946
+ return params.slice(offset, offset + length);
982
947
  }
983
- function topLevelTokens(sql) {
984
- const tokens = [];
985
- let index = 0;
986
- let depth = 0;
987
- while (index < sql.length) {
988
- const skipped = skipNonCode(sql, index);
989
- if (skipped !== void 0) {
990
- index = skipped;
991
- continue;
992
- }
993
- const char = sql.charAt(index);
994
- if (char === "(") {
995
- depth += 1;
996
- index += 1;
997
- continue;
998
- }
999
- if (char === ")") {
1000
- depth = Math.max(0, depth - 1);
1001
- index += 1;
1002
- continue;
1003
- }
1004
- if (!/[A-Za-z_#$]/.test(char)) {
1005
- index += 1;
1006
- continue;
1007
- }
1008
- let end = index + 1;
1009
- while (end < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(end))) {
1010
- end += 1;
1011
- }
1012
- if (depth === 0) {
1013
- tokens.push({ keyword: sql.slice(index, end).toUpperCase(), start: index, end });
1014
- }
1015
- index = end;
1016
- }
1017
- return tokens;
948
+ function wholeTargetPlan(operation, statementSql, target) {
949
+ return buildSelectPlan(operation, statementSql, target.sql, void 0, []);
1018
950
  }
1019
- function findToken(tokens, keyword, start = 0) {
1020
- for (let index = start; index < tokens.length; index += 1) {
1021
- if (tokens[index]?.keyword === keyword) {
1022
- return index;
1023
- }
951
+ function readRequiredMergeToken(tokens, keyword, start) {
952
+ const index = findToken(tokens, keyword, start);
953
+ const token = index === void 0 ? void 0 : tokens[index];
954
+ if (index === void 0 || token === void 0) {
955
+ throw new BackupRequiredError(
956
+ "MERGE write refused: cannot derive a trustworthy backup target"
957
+ );
1024
958
  }
1025
- return void 0;
1026
- }
1027
- function keywordMatches(sql, index, keyword) {
1028
- const end = index + keyword.length;
1029
- return sql.slice(index, end).toUpperCase() === keyword && !isIdentifierChar(sql[index - 1]) && !isIdentifierChar(sql[end]);
959
+ return { index, token };
1030
960
  }
1031
- function findTopLevelKeyword(sql, keyword, startIndex = 0) {
1032
- let index = startIndex;
1033
- let depth = 0;
1034
- while (index < sql.length) {
1035
- const skipped = skipNonCode(sql, index);
1036
- if (skipped !== void 0) {
1037
- index = skipped;
1038
- continue;
1039
- }
1040
- const char = sql[index];
1041
- if (char === "(") {
1042
- depth += 1;
1043
- } else if (char === ")" && depth > 0) {
1044
- depth -= 1;
1045
- } else if (depth === 0 && keywordMatches(sql, index, keyword)) {
1046
- return index;
1047
- }
1048
- index += 1;
961
+ function parseMerge(statementSql) {
962
+ const tokens = topLevelTokens(statementSql);
963
+ const into = readRequiredMergeToken(tokens, "INTO", 1);
964
+ const using = readRequiredMergeToken(tokens, "USING", into.index + 1);
965
+ const on = readRequiredMergeToken(tokens, "ON", using.index + 1);
966
+ const target = readQualifiedTarget(statementSql, into.token.end, using.token.start);
967
+ if (target === void 0) {
968
+ throw new BackupRequiredError(
969
+ "MERGE write refused: cannot derive a trustworthy backup target"
970
+ );
1049
971
  }
1050
- return void 0;
1051
- }
1052
- function findTopLevelChar(sql, target, startIndex, endIndex) {
1053
- let index = startIndex;
1054
- let depth = 0;
1055
- while (index < endIndex) {
1056
- const skipped = skipNonCode(sql, index);
1057
- if (skipped !== void 0) {
1058
- index = skipped;
1059
- continue;
1060
- }
1061
- const char = sql[index];
1062
- if (char === target && depth === 0) {
1063
- return index;
1064
- }
1065
- if (char === "(") {
1066
- depth += 1;
1067
- } else if (char === ")" && depth > 0) {
1068
- depth -= 1;
1069
- }
1070
- index += 1;
972
+ const clauseIndexes = mergeClauseIndexes(tokens, on.index + 1);
973
+ if (clauseIndexes.length === 0) {
974
+ throw new BackupRequiredError("MERGE write refused: no supported WHEN clause was found");
1071
975
  }
1072
- return void 0;
976
+ const firstClause = tokens[clauseIndexes[0] ?? 0];
977
+ return {
978
+ tokens,
979
+ into: into.token,
980
+ using: using.token,
981
+ on: on.token,
982
+ target,
983
+ clauseIndexes,
984
+ matchedIndexes: clauseIndexes.filter((index) => tokens[index + 1]?.keyword === "MATCHED"),
985
+ targetClause: statementSql.slice(into.token.end, using.token.start).trim(),
986
+ sourceClause: statementSql.slice(using.token.end, on.token.start).trim(),
987
+ onCondition: statementSql.slice(on.token.end, firstClause?.start).trim()
988
+ };
1073
989
  }
1074
-
1075
- // src/statements.ts
1076
- var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH"]);
1077
- var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
1078
- var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
1079
- function firstKeyword(sql) {
1080
- let index = 0;
1081
- while (index < sql.length) {
1082
- const char = sql.charAt(index);
1083
- if (char.trim().length === 0) {
1084
- index += 1;
1085
- continue;
1086
- }
1087
- if (char === "-" && sql[index + 1] === "-") {
1088
- index += 2;
1089
- while (index < sql.length && sql[index] !== "\n") {
1090
- index += 1;
1091
- }
1092
- continue;
1093
- }
1094
- if (char === "/" && sql[index + 1] === "*") {
1095
- const commentEnd = sql.indexOf("*/", index + 2);
1096
- if (commentEnd === -1) {
1097
- return "";
1098
- }
1099
- index = commentEnd + 2;
1100
- continue;
1101
- }
1102
- break;
990
+ function readMatchedActionTokens(tokens, matchedIndex) {
991
+ const thenIndex = findToken(tokens, "THEN", matchedIndex + 2);
992
+ if (thenIndex === void 0) {
993
+ return void 0;
1103
994
  }
1104
- let keywordEnd = index;
1105
- while (keywordEnd < sql.length) {
1106
- const code = sql.charCodeAt(keywordEnd);
1107
- const isUpperCase = code >= 65 && code <= 90;
1108
- const isLowerCase = code >= 97 && code <= 122;
1109
- if (!isUpperCase && !isLowerCase) {
1110
- break;
1111
- }
1112
- keywordEnd += 1;
995
+ const thenToken = tokens[thenIndex];
996
+ const actionToken = tokens[thenIndex + 1];
997
+ const matchedToken = tokens[matchedIndex + 1];
998
+ if (thenToken === void 0 || actionToken === void 0 || matchedToken === void 0) {
999
+ return void 0;
1113
1000
  }
1114
- return sql.slice(index, keywordEnd).toUpperCase();
1001
+ if (!MERGE_MODIFY_ACTIONS.has(actionToken.keyword)) {
1002
+ return void 0;
1003
+ }
1004
+ const clauseToken = tokens[matchedIndex];
1005
+ return {
1006
+ thenToken,
1007
+ conditionToken: tokens[matchedIndex + 2],
1008
+ clauseStart: clauseToken === void 0 ? 0 : clauseToken.start
1009
+ };
1115
1010
  }
1116
- function classifyStatement(sql) {
1117
- const keyword = firstKeyword(sql);
1118
- if (SELECT_KEYWORDS.has(keyword)) {
1119
- return "select";
1011
+ function parseMatchedClause(statementSql, params, tokens, matchedIndex) {
1012
+ const action = readMatchedActionTokens(tokens, matchedIndex);
1013
+ if (action?.conditionToken === void 0) {
1014
+ return void 0;
1120
1015
  }
1121
- if (DML_KEYWORDS.has(keyword)) {
1122
- return "dml";
1016
+ if (action.conditionToken.keyword === "THEN") {
1017
+ return { clauseStart: action.clauseStart, matchedCondition: "", conditionParams: [] };
1123
1018
  }
1124
- if (DDL_KEYWORDS.has(keyword)) {
1125
- return "ddl";
1019
+ if (action.conditionToken.keyword !== "AND") {
1020
+ return void 0;
1126
1021
  }
1127
- return "unknown";
1022
+ return {
1023
+ clauseStart: action.clauseStart,
1024
+ matchedCondition: statementSql.slice(action.conditionToken.end, action.thenToken.start).trim(),
1025
+ conditionParams: paramsInRange(
1026
+ statementSql,
1027
+ params,
1028
+ action.conditionToken.end,
1029
+ action.thenToken.start
1030
+ )
1031
+ };
1128
1032
  }
1129
- function quoteIdentifier(identifier) {
1130
- if (identifier.length === 0) {
1131
- throw new QueryError("A SQL identifier must not be empty");
1132
- }
1133
- if (identifier.includes("\0")) {
1134
- throw new QueryError("A SQL identifier must not contain a NUL character");
1033
+ function buildExactMergePlan(statementSql, params, merge, matchedIndex) {
1034
+ const targetReference = mergeTargetReference(statementSql, merge.target, merge.using.start);
1035
+ const matchedClause = parseMatchedClause(statementSql, params, merge.tokens, matchedIndex);
1036
+ if (targetReference === void 0 || matchedClause === void 0) {
1037
+ return void 0;
1135
1038
  }
1136
- return `"${identifier.replace(/"/g, '""')}"`;
1137
- }
1138
- function qualifiedName(schema, table) {
1139
- return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
1039
+ const sourceParams = paramsInRange(
1040
+ statementSql,
1041
+ params,
1042
+ merge.using.end,
1043
+ matchedClause.clauseStart
1044
+ );
1045
+ const conditionSql = matchedClause.matchedCondition.length === 0 ? "" : ` AND (${matchedClause.matchedCondition})`;
1046
+ return {
1047
+ operation: "merge",
1048
+ statementSql,
1049
+ selectSql: `SELECT ${targetReference}.* FROM ${merge.targetClause} WHERE EXISTS (SELECT 1 FROM ${merge.sourceClause} WHERE (${merge.onCondition})${conditionSql})`,
1050
+ selectParams: [...sourceParams, ...matchedClause.conditionParams]
1051
+ };
1140
1052
  }
1141
- function countPlaceholders(sql) {
1142
- let count = 0;
1143
- let index = 0;
1144
- const length = sql.length;
1145
- while (index < length) {
1146
- const char = sql[index];
1147
- if (char === "'" || char === '"') {
1148
- const quote = char;
1149
- index += 1;
1150
- while (index < length) {
1151
- if (sql[index] === quote) {
1152
- if (sql[index + 1] === quote) {
1153
- index += 2;
1154
- continue;
1155
- }
1156
- index += 1;
1157
- break;
1158
- }
1159
- index += 1;
1160
- }
1161
- continue;
1162
- }
1163
- if (char === "-" && sql[index + 1] === "-") {
1164
- index += 2;
1165
- while (index < length && sql[index] !== "\n") {
1166
- index += 1;
1167
- }
1168
- continue;
1169
- }
1170
- if (char === "/" && sql[index + 1] === "*") {
1171
- index += 2;
1172
- while (index < length && !(sql[index] === "*" && sql[index + 1] === "/")) {
1173
- index += 1;
1174
- }
1175
- index += 2;
1176
- continue;
1177
- }
1178
- if (char === "?") {
1179
- count += 1;
1180
- }
1181
- index += 1;
1182
- }
1183
- return count;
1184
- }
1185
- function assertParamArity(sql, params) {
1186
- const expected = countPlaceholders(sql);
1187
- if (expected !== params.length) {
1188
- throw new QueryError(
1189
- `SQL expects ${String(expected)} bound parameter(s) but received ${String(params.length)} value(s)`
1190
- );
1191
- }
1192
- }
1193
-
1194
- // src/001-backup-planner.ts
1195
- var WRITE_KEYWORDS = /* @__PURE__ */ new Set([
1196
- "UPDATE",
1197
- "UPSERT",
1198
- "REPLACE",
1199
- "MERGE",
1200
- "DELETE"
1201
- ]);
1202
- var MERGE_MODIFY_ACTIONS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
1203
- function trimStatementSql(sql) {
1204
- const trimmed = sql.trim();
1205
- let end = trimmed.length;
1206
- while (end > 0 && trimmed.charAt(end - 1) === ";") {
1207
- end -= 1;
1208
- }
1209
- return trimmed.slice(0, end).trim();
1210
- }
1211
- function isMergeClauseStart(tokens, index) {
1212
- if (tokens[index]?.keyword !== "WHEN") {
1213
- return false;
1214
- }
1215
- return tokens[index + 1]?.keyword === "MATCHED" || tokens[index + 1]?.keyword === "NOT" && tokens[index + 2]?.keyword === "MATCHED";
1216
- }
1217
- function mergeClauseIndexes(tokens, start) {
1218
- const indexes = [];
1219
- for (let index = start; index < tokens.length; index += 1) {
1220
- if (isMergeClauseStart(tokens, index)) {
1221
- indexes.push(index);
1222
- }
1223
- }
1224
- return indexes;
1225
- }
1226
- function selectParamsAfterWhere(statementSql, whereIndex, params) {
1227
- return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
1228
- }
1229
- function buildSelectPlan(operation, statementSql, targetSql, whereIndex, params) {
1230
- const target = targetSql.trim();
1231
- if (target.length === 0) {
1232
- throw new QueryError(`${operation.toUpperCase()} backup requires a target table`);
1053
+ function buildMergeBackupPlan(statementSql, params) {
1054
+ const merge = parseMerge(statementSql);
1055
+ if (merge.matchedIndexes.length === 0) {
1056
+ return void 0;
1233
1057
  }
1234
- if (whereIndex === void 0) {
1235
- return { operation, statementSql, selectSql: `SELECT * FROM ${target}`, selectParams: [] };
1058
+ if (merge.matchedIndexes.length > 1) {
1059
+ return wholeTargetPlan("merge", statementSql, merge.target);
1236
1060
  }
1237
- const whereSql = statementSql.slice(whereIndex + "WHERE".length).trim();
1238
- if (whereSql.length === 0) {
1239
- throw new QueryError(`${operation.toUpperCase()} backup requires a non-empty WHERE clause`);
1061
+ if (merge.sourceClause.length === 0 || merge.onCondition.length === 0) {
1062
+ return wholeTargetPlan("merge", statementSql, merge.target);
1240
1063
  }
1241
- return {
1242
- operation,
1243
- statementSql,
1244
- selectSql: `SELECT * FROM ${target} WHERE ${whereSql}`,
1245
- selectParams: selectParamsAfterWhere(statementSql, whereIndex, params)
1246
- };
1064
+ const matchedIndex = merge.matchedIndexes[0] ?? 0;
1065
+ return buildExactMergePlan(statementSql, params, merge, matchedIndex) ?? wholeTargetPlan("merge", statementSql, merge.target);
1247
1066
  }
1248
- function buildUpdateBackupPlan(statementSql, params) {
1249
- const updateIndex = findTopLevelKeyword(statementSql, "UPDATE");
1250
- const setIndex = updateIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "SET", updateIndex + "UPDATE".length);
1251
- if (updateIndex === void 0 || setIndex === void 0) {
1252
- throw new QueryError("UPDATE backup requires UPDATE <target> SET syntax");
1067
+ function buildDeleteBackupPlan(statementSql, params) {
1068
+ const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
1069
+ const fromIndex = deleteIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "FROM", deleteIndex + "DELETE".length);
1070
+ if (deleteIndex === void 0 || fromIndex === void 0) {
1071
+ throw new QueryError("DELETE backup requires DELETE FROM <target> syntax");
1253
1072
  }
1254
- const whereIndex = findTopLevelKeyword(statementSql, "WHERE", setIndex + "SET".length);
1073
+ const whereIndex = findTopLevelKeyword(statementSql, "WHERE", fromIndex + "FROM".length);
1074
+ const targetEnd = whereIndex ?? statementSql.length;
1255
1075
  return buildSelectPlan(
1256
- "update",
1076
+ "delete",
1257
1077
  statementSql,
1258
- statementSql.slice(updateIndex + "UPDATE".length, setIndex),
1078
+ statementSql.slice(fromIndex + "FROM".length, targetEnd),
1259
1079
  whereIndex,
1260
1080
  params
1261
1081
  );
1262
1082
  }
1263
- function buildUpsertBackupPlan(statementSql, params, keyword) {
1264
- const operation = keyword === "UPSERT" ? "upsert" : "replace";
1265
- const keywordIndex = findTopLevelKeyword(statementSql, keyword);
1266
- const target = keywordIndex === void 0 ? void 0 : readQualifiedTarget(statementSql, keywordIndex + keyword.length, statementSql.length);
1267
- if (keywordIndex === void 0 || target === void 0) {
1268
- throw new BackupRequiredError(
1269
- `${keyword} write refused: cannot derive a trustworthy backup target`
1270
- );
1083
+ function isWriteKeyword(keyword) {
1084
+ return WRITE_KEYWORDS.has(keyword);
1085
+ }
1086
+ function buildMergeWriteBackupPlan(statementSql, params) {
1087
+ const secondKeyword = topLevelTokens(statementSql)[1]?.keyword;
1088
+ if (secondKeyword === "DELTA") {
1089
+ return void 0;
1271
1090
  }
1272
- const valuesIndex = findTopLevelKeyword(statementSql, "VALUES", target.end);
1273
- if (valuesIndex === void 0) {
1274
- const selectIndex = findTopLevelKeyword(statementSql, "SELECT", target.end);
1275
- const withIndex = findTopLevelKeyword(statementSql, "WITH", target.end);
1276
- if (selectIndex === void 0 && withIndex === void 0) {
1277
- throw new BackupRequiredError(
1278
- `${keyword} write refused: expected VALUES, WITH PRIMARY KEY, or a subquery`
1279
- );
1280
- }
1281
- return buildSelectPlan(operation, statementSql, target.sql, void 0, params);
1091
+ if (secondKeyword !== "INTO") {
1092
+ throw new BackupRequiredError("MERGE write refused: expected MERGE INTO syntax");
1282
1093
  }
1283
- const whereIndex = findTopLevelKeyword(statementSql, "WHERE", valuesIndex + "VALUES".length);
1284
- return buildSelectPlan(operation, statementSql, target.sql, whereIndex, params);
1094
+ return buildMergeBackupPlan(statementSql, params);
1285
1095
  }
1286
- function mergeTargetReference(statementSql, target, usingStart) {
1287
- let suffix = statementSql.slice(target.end, usingStart).trim();
1288
- if (suffix.length === 0) {
1289
- return target.reference;
1096
+ function dispatchWriteBackupPlan(keyword, statementSql, params) {
1097
+ switch (keyword) {
1098
+ case "UPDATE":
1099
+ return buildUpdateBackupPlan(statementSql, params);
1100
+ case "UPSERT":
1101
+ case "REPLACE":
1102
+ return buildUpsertBackupPlan(statementSql, params, keyword);
1103
+ case "MERGE":
1104
+ return buildMergeWriteBackupPlan(statementSql, params);
1105
+ case "DELETE":
1106
+ return buildDeleteBackupPlan(statementSql, params);
1290
1107
  }
1291
- if (/^PARTITION\b/i.test(suffix)) {
1292
- const open = suffix.indexOf("(");
1293
- if (open === -1) {
1294
- return void 0;
1295
- }
1296
- const close = findTopLevelChar(suffix, ")", open + 1, suffix.length);
1297
- if (close === void 0) {
1298
- return void 0;
1299
- }
1300
- suffix = suffix.slice(close + 1).trim();
1301
- if (suffix.length === 0) {
1302
- return target.reference;
1303
- }
1108
+ }
1109
+ function buildWriteBackupPlan(sql, params = []) {
1110
+ const trimmed = trimStatementSql(sql);
1111
+ const leading = firstKeyword(trimmed);
1112
+ const withResolved = leading === "WITH" ? resolveWithStatement(trimmed) : void 0;
1113
+ if (leading === "WITH" && withResolved === void 0) {
1114
+ throw new BackupRequiredError(
1115
+ "WITH write refused: cannot determine the statement's real trailing keyword"
1116
+ );
1304
1117
  }
1305
- suffix = suffix.replace(/^AS\b/i, "").trim();
1306
- const alias = readIdentifierPart(suffix, 0, suffix.length);
1307
- if (alias === void 0 || skipTrivia(suffix, alias.end, suffix.length) !== suffix.length) {
1118
+ const keyword = withResolved?.keyword ?? leading;
1119
+ if (!isWriteKeyword(keyword)) {
1308
1120
  return void 0;
1309
1121
  }
1310
- return alias.sql;
1122
+ const startIndex = withResolved?.index ?? 0;
1123
+ const statementSql = trimmed.slice(startIndex);
1124
+ const slicedParams = params.slice(countPlaceholders(trimmed.slice(0, startIndex)));
1125
+ assertParamArity(statementSql, slicedParams);
1126
+ const plan = dispatchWriteBackupPlan(keyword, statementSql, slicedParams);
1127
+ return plan === void 0 ? void 0 : { ...plan, statementSql: trimmed };
1311
1128
  }
1312
- function paramsInRange(sql, params, start, end) {
1313
- const offset = countPlaceholders(sql.slice(0, start));
1314
- const length = countPlaceholders(sql.slice(start, end));
1315
- return params.slice(offset, offset + length);
1129
+
1130
+ // src/backup.ts
1131
+ var SAPTOOLS_DIR_NAME = ".saptools";
1132
+ var CF_HANA_DIR_NAME = "cf-hana";
1133
+ var BACKUPS_DIR_NAME = "backups";
1134
+ function defaultSaptoolsRoot() {
1135
+ return join(homedir(), SAPTOOLS_DIR_NAME);
1316
1136
  }
1317
- function wholeTargetPlan(operation, statementSql, target) {
1318
- return buildSelectPlan(operation, statementSql, target.sql, void 0, []);
1137
+ function backupTimestamp(now) {
1138
+ return now.toISOString().replace(/:/g, "").replace(".", "");
1319
1139
  }
1320
- function readRequiredMergeToken(tokens, keyword, start) {
1321
- const index = findToken(tokens, keyword, start);
1322
- const token = index === void 0 ? void 0 : tokens[index];
1323
- if (index === void 0 || token === void 0) {
1324
- throw new BackupRequiredError(
1325
- "MERGE write refused: cannot derive a trustworthy backup target"
1326
- );
1327
- }
1328
- return { index, token };
1140
+ function backupMonth(now) {
1141
+ const year = String(now.getUTCFullYear());
1142
+ const month = String(now.getUTCMonth() + 1).padStart(2, "0");
1143
+ return `${year}${month}`;
1329
1144
  }
1330
- function parseMerge(statementSql) {
1331
- const tokens = topLevelTokens(statementSql);
1332
- const into = readRequiredMergeToken(tokens, "INTO", 1);
1333
- const using = readRequiredMergeToken(tokens, "USING", into.index + 1);
1334
- const on = readRequiredMergeToken(tokens, "ON", using.index + 1);
1335
- const target = readQualifiedTarget(statementSql, into.token.end, using.token.start);
1336
- if (target === void 0) {
1145
+ function sanitizePathPart(value) {
1146
+ const trimmed = value?.trim() ?? "";
1147
+ const normalized = trimmed.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1148
+ return normalized.length > 0 ? normalized : "unknown-target";
1149
+ }
1150
+ function backupBaseName(input, now) {
1151
+ return [sanitizePathPart(input.selector), input.operation, backupTimestamp(now)].join("-");
1152
+ }
1153
+ function cfHanaBackupRoot(saptoolsRoot) {
1154
+ return join(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
1155
+ }
1156
+ async function writeSqlBackup(input, options = {}) {
1157
+ const now = options.now ?? /* @__PURE__ */ new Date();
1158
+ const directory = join(cfHanaBackupRoot(options.saptoolsRoot), backupMonth(now));
1159
+ const baseName = backupBaseName(input, now);
1160
+ const statementPath = join(directory, `${baseName}.statement.sql`);
1161
+ const backupPath = join(directory, `${baseName}.sql`);
1162
+ const metadataPath = join(directory, `${baseName}.json`);
1163
+ const metadata = {
1164
+ selector: input.selector ?? null,
1165
+ operation: input.operation,
1166
+ statementPath,
1167
+ backupPath,
1168
+ rowCount: input.result.rowCount,
1169
+ createdAt: now.toISOString()
1170
+ };
1171
+ const csv2 = formatCsv(input.result);
1172
+ if (Buffer.byteLength(csv2) > (options.maxBytes ?? MAX_RESULT_STORE_BYTES)) {
1337
1173
  throw new BackupRequiredError(
1338
- "MERGE write refused: cannot derive a trustworthy backup target"
1174
+ "Write backup exceeds the storage limit; the write was refused"
1339
1175
  );
1340
1176
  }
1341
- const clauseIndexes = mergeClauseIndexes(tokens, on.index + 1);
1342
- if (clauseIndexes.length === 0) {
1343
- throw new BackupRequiredError("MERGE write refused: no supported WHEN clause was found");
1344
- }
1345
- const firstClause = tokens[clauseIndexes[0] ?? 0];
1346
- return {
1347
- tokens,
1348
- into: into.token,
1349
- using: using.token,
1350
- on: on.token,
1351
- target,
1352
- clauseIndexes,
1353
- matchedIndexes: clauseIndexes.filter((index) => tokens[index + 1]?.keyword === "MATCHED"),
1354
- targetClause: statementSql.slice(into.token.end, using.token.start).trim(),
1355
- sourceClause: statementSql.slice(using.token.end, on.token.start).trim(),
1356
- onCondition: statementSql.slice(on.token.end, firstClause?.start).trim()
1357
- };
1358
- }
1359
- function readMatchedActionTokens(tokens, matchedIndex) {
1360
- const thenIndex = findToken(tokens, "THEN", matchedIndex + 2);
1361
- if (thenIndex === void 0) {
1362
- return void 0;
1363
- }
1364
- const thenToken = tokens[thenIndex];
1365
- const actionToken = tokens[thenIndex + 1];
1366
- const matchedToken = tokens[matchedIndex + 1];
1367
- if (thenToken === void 0 || actionToken === void 0 || matchedToken === void 0) {
1368
- return void 0;
1369
- }
1370
- if (!MERGE_MODIFY_ACTIONS.has(actionToken.keyword)) {
1371
- return void 0;
1372
- }
1373
- const clauseToken = tokens[matchedIndex];
1374
- return {
1375
- thenToken,
1376
- conditionToken: tokens[matchedIndex + 2],
1377
- clauseStart: clauseToken === void 0 ? 0 : clauseToken.start
1378
- };
1379
- }
1380
- function parseMatchedClause(statementSql, params, tokens, matchedIndex) {
1381
- const action = readMatchedActionTokens(tokens, matchedIndex);
1382
- if (action?.conditionToken === void 0) {
1383
- return void 0;
1384
- }
1385
- if (action.conditionToken.keyword === "THEN") {
1386
- return { clauseStart: action.clauseStart, matchedCondition: "", conditionParams: [] };
1387
- }
1388
- if (action.conditionToken.keyword !== "AND") {
1389
- return void 0;
1390
- }
1391
- return {
1392
- clauseStart: action.clauseStart,
1393
- matchedCondition: statementSql.slice(action.conditionToken.end, action.thenToken.start).trim(),
1394
- conditionParams: paramsInRange(
1395
- statementSql,
1396
- params,
1397
- action.conditionToken.end,
1398
- action.thenToken.start
1399
- )
1400
- };
1401
- }
1402
- function buildExactMergePlan(statementSql, params, merge, matchedIndex) {
1403
- const targetReference = mergeTargetReference(statementSql, merge.target, merge.using.start);
1404
- const matchedClause = parseMatchedClause(statementSql, params, merge.tokens, matchedIndex);
1405
- if (targetReference === void 0 || matchedClause === void 0) {
1406
- return void 0;
1407
- }
1408
- const sourceParams = paramsInRange(
1409
- statementSql,
1410
- params,
1411
- merge.using.end,
1412
- matchedClause.clauseStart
1413
- );
1414
- const conditionSql = matchedClause.matchedCondition.length === 0 ? "" : ` AND (${matchedClause.matchedCondition})`;
1415
- return {
1416
- operation: "merge",
1417
- statementSql,
1418
- selectSql: `SELECT ${targetReference}.* FROM ${merge.targetClause} WHERE EXISTS (SELECT 1 FROM ${merge.sourceClause} WHERE (${merge.onCondition})${conditionSql})`,
1419
- selectParams: [...sourceParams, ...matchedClause.conditionParams]
1420
- };
1421
- }
1422
- function buildMergeBackupPlan(statementSql, params) {
1423
- const merge = parseMerge(statementSql);
1424
- if (merge.matchedIndexes.length === 0) {
1425
- return void 0;
1426
- }
1427
- if (merge.matchedIndexes.length > 1) {
1428
- return wholeTargetPlan("merge", statementSql, merge.target);
1429
- }
1430
- if (merge.sourceClause.length === 0 || merge.onCondition.length === 0) {
1431
- return wholeTargetPlan("merge", statementSql, merge.target);
1432
- }
1433
- const matchedIndex = merge.matchedIndexes[0] ?? 0;
1434
- return buildExactMergePlan(statementSql, params, merge, matchedIndex) ?? wholeTargetPlan("merge", statementSql, merge.target);
1435
- }
1436
- function buildDeleteBackupPlan(statementSql, params) {
1437
- const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
1438
- const fromIndex = deleteIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "FROM", deleteIndex + "DELETE".length);
1439
- if (deleteIndex === void 0 || fromIndex === void 0) {
1440
- throw new QueryError("DELETE backup requires DELETE FROM <target> syntax");
1441
- }
1442
- const whereIndex = findTopLevelKeyword(statementSql, "WHERE", fromIndex + "FROM".length);
1443
- const targetEnd = whereIndex ?? statementSql.length;
1444
- return buildSelectPlan(
1445
- "delete",
1446
- statementSql,
1447
- statementSql.slice(fromIndex + "FROM".length, targetEnd),
1448
- whereIndex,
1449
- params
1450
- );
1451
- }
1452
- function isWriteKeyword(keyword) {
1453
- return WRITE_KEYWORDS.has(keyword);
1454
- }
1455
- function buildMergeWriteBackupPlan(statementSql, params) {
1456
- const secondKeyword = topLevelTokens(statementSql)[1]?.keyword;
1457
- if (secondKeyword === "DELTA") {
1458
- return void 0;
1459
- }
1460
- if (secondKeyword !== "INTO") {
1461
- throw new BackupRequiredError("MERGE write refused: expected MERGE INTO syntax");
1462
- }
1463
- return buildMergeBackupPlan(statementSql, params);
1464
- }
1465
- function dispatchWriteBackupPlan(keyword, statementSql, params) {
1466
- switch (keyword) {
1467
- case "UPDATE":
1468
- return buildUpdateBackupPlan(statementSql, params);
1469
- case "UPSERT":
1470
- case "REPLACE":
1471
- return buildUpsertBackupPlan(statementSql, params, keyword);
1472
- case "MERGE":
1473
- return buildMergeWriteBackupPlan(statementSql, params);
1474
- case "DELETE":
1475
- return buildDeleteBackupPlan(statementSql, params);
1476
- }
1477
- }
1478
- function buildWriteBackupPlan(sql, params = []) {
1479
- const statementSql = trimStatementSql(sql);
1480
- const keyword = firstKeyword(statementSql);
1481
- if (!isWriteKeyword(keyword)) {
1482
- return void 0;
1483
- }
1484
- assertParamArity(statementSql, params);
1485
- return dispatchWriteBackupPlan(keyword, statementSql, params);
1486
- }
1487
-
1488
- // src/backup.ts
1489
- var SAPTOOLS_DIR_NAME = ".saptools";
1490
- var CF_HANA_DIR_NAME = "cf-hana";
1491
- var BACKUPS_DIR_NAME = "backups";
1492
- function defaultSaptoolsRoot() {
1493
- return join2(homedir2(), SAPTOOLS_DIR_NAME);
1494
- }
1495
- function backupTimestamp(now) {
1496
- return now.toISOString().replace(/:/g, "").replace(".", "");
1497
- }
1498
- function backupMonth(now) {
1499
- const year = String(now.getUTCFullYear());
1500
- const month = String(now.getUTCMonth() + 1).padStart(2, "0");
1501
- return `${year}${month}`;
1502
- }
1503
- function sanitizePathPart(value) {
1504
- const trimmed = value?.trim() ?? "";
1505
- const normalized = trimmed.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1506
- return normalized.length > 0 ? normalized : "unknown-target";
1507
- }
1508
- function backupBaseName(input, now) {
1509
- return [sanitizePathPart(input.selector), input.operation, backupTimestamp(now)].join("-");
1510
- }
1511
- function cfHanaBackupRoot(saptoolsRoot) {
1512
- return join2(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
1513
- }
1514
- async function writeSqlBackup(input, options = {}) {
1515
- const now = options.now ?? /* @__PURE__ */ new Date();
1516
- const directory = join2(cfHanaBackupRoot(options.saptoolsRoot), backupMonth(now));
1517
- const baseName = backupBaseName(input, now);
1518
- const statementPath = join2(directory, `${baseName}.statement.sql`);
1519
- const backupPath = join2(directory, `${baseName}.sql`);
1520
- const metadataPath = join2(directory, `${baseName}.json`);
1521
- const metadata = {
1522
- selector: input.selector ?? null,
1523
- operation: input.operation,
1524
- statementPath,
1525
- backupPath,
1526
- rowCount: input.result.rowCount,
1527
- createdAt: now.toISOString()
1528
- };
1529
- const csv2 = formatCsv(input.result);
1530
- if (Buffer.byteLength(csv2) > (options.maxBytes ?? MAX_RESULT_STORE_BYTES)) {
1531
- throw new BackupRequiredError(
1532
- "Write backup exceeds the storage limit; the write was refused"
1533
- );
1534
- }
1535
- await mkdir2(directory, { recursive: true, mode: 448 });
1177
+ await mkdir(directory, { recursive: true, mode: 448 });
1536
1178
  await Promise.all([
1537
- writeFile2(statementPath, `${input.statementSql}
1179
+ writeFile(statementPath, `${input.statementSql}
1538
1180
  `, { encoding: "utf8", mode: 384 }),
1539
- writeFile2(backupPath, csv2, { encoding: "utf8", mode: 384 }),
1540
- writeFile2(metadataPath, `${JSON.stringify(metadata, null, 2)}
1181
+ writeFile(backupPath, csv2, { encoding: "utf8", mode: 384 }),
1182
+ writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}
1541
1183
  `, {
1542
1184
  encoding: "utf8",
1543
1185
  mode: 384
@@ -1681,9 +1323,9 @@ async function listColumns(connection, schema, table) {
1681
1323
 
1682
1324
  // src/cf.ts
1683
1325
  import { execFile } from "child_process";
1684
- import { mkdtemp, rm as rm2 } from "fs/promises";
1326
+ import { mkdtemp, rm } from "fs/promises";
1685
1327
  import { tmpdir } from "os";
1686
- import { join as join3 } from "path";
1328
+ import { join as join2 } from "path";
1687
1329
  import { promisify } from "util";
1688
1330
  var execFileAsync = promisify(execFile);
1689
1331
  var MAX_BUFFER = 16 * 1024 * 1024;
@@ -1789,12 +1431,12 @@ function getRegionKeyForApi(apiEndpoint) {
1789
1431
  return /^https:\/\/api\.cf\.([a-z]{2}\d{2}(?:-\d{3})?)\.hana\.ondemand\.com$/.exec(normalized)?.[1];
1790
1432
  }
1791
1433
  async function withCfSession(work) {
1792
- const cfHome = await mkdtemp(join3(tmpdir(), "saptools-cf-hana-"));
1434
+ const cfHome = await mkdtemp(join2(tmpdir(), "saptools-cf-hana-"));
1793
1435
  const ctx = { cfHome };
1794
1436
  try {
1795
1437
  return await work(ctx);
1796
1438
  } finally {
1797
- await rm2(cfHome, { recursive: true, force: true });
1439
+ await rm(cfHome, { recursive: true, force: true });
1798
1440
  }
1799
1441
  }
1800
1442
  function resolveCfBin() {
@@ -1811,9 +1453,9 @@ function buildEnv(ctx, overrides = {}) {
1811
1453
  env["CF_HOME"] = ctx.cfHome;
1812
1454
  return env;
1813
1455
  }
1814
- async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
1456
+ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS, maxAttempts = CF_RETRY_ATTEMPTS) {
1815
1457
  let lastErr;
1816
- for (let attempt = 0; attempt < CF_RETRY_ATTEMPTS; attempt++) {
1458
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1817
1459
  try {
1818
1460
  const { stdout } = await execFileAsync(bin, args, {
1819
1461
  env,
@@ -1832,18 +1474,24 @@ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
1832
1474
  if (isEnoent || !isTimeout && !isNetworkFlake) {
1833
1475
  throw err;
1834
1476
  }
1835
- if (attempt < CF_RETRY_ATTEMPTS - 1) {
1477
+ if (attempt < maxAttempts - 1) {
1836
1478
  await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
1837
1479
  }
1838
1480
  }
1839
1481
  }
1840
1482
  throw lastErr;
1841
1483
  }
1842
- async function runCf(args, ctx, overrides = {}) {
1484
+ async function runCf(args, ctx, overrides = {}, execOptions = {}) {
1843
1485
  const { bin, argsPrefix } = resolveCfBin();
1844
1486
  const env = buildEnv(ctx, overrides);
1845
1487
  try {
1846
- return await execWithRetries(bin, [...argsPrefix, ...args], env);
1488
+ return await execWithRetries(
1489
+ bin,
1490
+ [...argsPrefix, ...args],
1491
+ env,
1492
+ execOptions.timeoutMs,
1493
+ execOptions.maxAttempts
1494
+ );
1847
1495
  } catch (lastErr) {
1848
1496
  const e = lastErr;
1849
1497
  const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
@@ -1873,15 +1521,26 @@ async function cfEnvDirect(appName) {
1873
1521
  delete env["SAP_PASSWORD"];
1874
1522
  return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
1875
1523
  }
1876
- async function cfApps(ctx) {
1877
- return await runCf(["apps"], ctx);
1524
+ async function cfApps(ctx, timeoutMs) {
1525
+ return await runCf(
1526
+ ["apps"],
1527
+ ctx,
1528
+ {},
1529
+ timeoutMs === void 0 ? {} : { timeoutMs, maxAttempts: 1 }
1530
+ );
1878
1531
  }
1879
- async function cfAppsDirect() {
1532
+ async function cfAppsDirect(timeoutMs) {
1880
1533
  const { bin, argsPrefix } = resolveCfBin();
1881
1534
  const env = { ...process.env };
1882
1535
  delete env["SAP_EMAIL"];
1883
1536
  delete env["SAP_PASSWORD"];
1884
- return await execWithRetries(bin, [...argsPrefix, "apps"], env);
1537
+ return await execWithRetries(
1538
+ bin,
1539
+ [...argsPrefix, "apps"],
1540
+ env,
1541
+ timeoutMs,
1542
+ timeoutMs === void 0 ? void 0 : 1
1543
+ );
1885
1544
  }
1886
1545
  async function readCurrentCfTarget() {
1887
1546
  const { bin, argsPrefix } = resolveCfBin();
@@ -2025,7 +1684,7 @@ function extractCfEnvApplicationIdentity(stdout) {
2025
1684
  }
2026
1685
  throw error;
2027
1686
  }
2028
- if (!isRecord2(application)) {
1687
+ if (!isRecord(application)) {
2029
1688
  throw new Error("VCAP_APPLICATION must be an object");
2030
1689
  }
2031
1690
  return {
@@ -2045,11 +1704,11 @@ function extractVcapSection(stdout) {
2045
1704
  const block = end === -1 ? after : after.slice(0, end);
2046
1705
  return block.trim();
2047
1706
  }
2048
- function isRecord2(v) {
1707
+ function isRecord(v) {
2049
1708
  return typeof v === "object" && v !== null && !Array.isArray(v);
2050
1709
  }
2051
1710
  function assertCreds(raw) {
2052
- if (!isRecord2(raw)) {
1711
+ if (!isRecord(raw)) {
2053
1712
  throw new Error("HANA credentials must be an object");
2054
1713
  }
2055
1714
  const req = ["host", "port", "user", "password", "schema", "hdi_user", "hdi_password", "url", "database_id", "certificate"];
@@ -2082,7 +1741,7 @@ function extractHanaBindingsFromCfEnv(stdout) {
2082
1741
  } catch {
2083
1742
  throw new Error("VCAP_SERVICES is not valid JSON");
2084
1743
  }
2085
- if (!isRecord2(vcap)) {
1744
+ if (!isRecord(vcap)) {
2086
1745
  throw new Error("VCAP_SERVICES must be an object");
2087
1746
  }
2088
1747
  const hana = vcap["hana"];
@@ -2093,7 +1752,7 @@ function extractHanaBindingsFromCfEnv(stdout) {
2093
1752
  throw new Error("VCAP_SERVICES.hana must be an array when present");
2094
1753
  }
2095
1754
  return hana.map((b) => {
2096
- if (!isRecord2(b)) {
1755
+ if (!isRecord(b)) {
2097
1756
  throw new Error("HANA binding must be an object");
2098
1757
  }
2099
1758
  const credsRaw = assertCreds(b["credentials"]);
@@ -2788,6 +2447,13 @@ async function connectHdb(params) {
2788
2447
  password: params.password,
2789
2448
  ca: params.certificate,
2790
2449
  useTLS: true,
2450
+ // HANA Cloud can reactively redirect a connection mid-auth to an
2451
+ // internal per-node hostname (pod-locality routing). That target is
2452
+ // often unreachable outside SAP's own network, and for a tunneled
2453
+ // connection it would silently abandon the SSH forward for a fresh,
2454
+ // untunneled socket. The original bound host is always a real, working
2455
+ // endpoint, so disabling the redirect is safe on the direct path too.
2456
+ disableCloudRedirect: true,
2791
2457
  ...params.servername === void 0 ? {} : { servername: params.servername }
2792
2458
  });
2793
2459
  await openClient(client, params.connectTimeoutMs);
@@ -2820,16 +2486,16 @@ function createDriver(name) {
2820
2486
  }
2821
2487
 
2822
2488
  // src/history.ts
2823
- import { appendFile as appendFile2, mkdir as mkdir3, readdir, rm as rm3 } from "fs/promises";
2824
- import { homedir as homedir3 } from "os";
2825
- import { join as join4 } from "path";
2489
+ import { appendFile as appendFile2, mkdir as mkdir2, readdir, rm as rm2 } from "fs/promises";
2490
+ import { homedir as homedir2 } from "os";
2491
+ import { join as join3 } from "path";
2826
2492
  var SAPTOOLS_DIR_NAME2 = ".saptools";
2827
2493
  var CF_HANA_DIR_NAME2 = "cf-hana";
2828
2494
  var HISTORIES_DIR_NAME = "histories";
2829
2495
  var HISTORY_RETENTION_DAYS = 5;
2830
2496
  var HISTORY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
2831
2497
  function defaultSaptoolsRoot2() {
2832
- return join4(homedir3(), SAPTOOLS_DIR_NAME2);
2498
+ return join3(homedir2(), SAPTOOLS_DIR_NAME2);
2833
2499
  }
2834
2500
  function padDatePart(value) {
2835
2501
  return value.toString().padStart(2, "0");
@@ -2851,10 +2517,10 @@ function resolveSaptoolsRoot(root) {
2851
2517
  return root ?? defaultSaptoolsRoot2();
2852
2518
  }
2853
2519
  function cfHanaHistoryDirectory(saptoolsRoot) {
2854
- return join4(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
2520
+ return join3(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
2855
2521
  }
2856
2522
  function sqlHistoryFilePath(now = /* @__PURE__ */ new Date(), saptoolsRoot) {
2857
- return join4(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
2523
+ return join3(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
2858
2524
  }
2859
2525
  async function pruneSqlHistory(now, saptoolsRoot) {
2860
2526
  const historyDir = cfHanaHistoryDirectory(saptoolsRoot);
@@ -2870,7 +2536,7 @@ async function pruneSqlHistory(now, saptoolsRoot) {
2870
2536
  const cutoffKey = retentionCutoffKey(now);
2871
2537
  await Promise.all(
2872
2538
  files.filter((file) => HISTORY_FILE_PATTERN.test(file)).filter((file) => file.slice(0, 10) < cutoffKey).map(async (file) => {
2873
- await rm3(join4(historyDir, file), { force: true });
2539
+ await rm2(join3(historyDir, file), { force: true });
2874
2540
  })
2875
2541
  );
2876
2542
  }
@@ -2881,7 +2547,7 @@ async function appendSqlHistory(input, options = {}) {
2881
2547
  at: now.toISOString(),
2882
2548
  ...input
2883
2549
  };
2884
- await mkdir3(historyDir, { recursive: true, mode: 448 });
2550
+ await mkdir2(historyDir, { recursive: true, mode: 448 });
2885
2551
  await appendFile2(sqlHistoryFilePath(now, options.saptoolsRoot), `${JSON.stringify(entry)}
2886
2552
  `, {
2887
2553
  encoding: "utf8",
@@ -2897,112 +2563,35 @@ async function appendSqlHistory(input, options = {}) {
2897
2563
  // src/safety.ts
2898
2564
  var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
2899
2565
  var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
2900
- function skipQuotedText2(sql, start) {
2901
- const quote = sql[start];
2902
- let index = start + 1;
2903
- while (index < sql.length) {
2904
- if (sql[index] === quote) {
2905
- if (sql[index + 1] === quote) {
2906
- index += 2;
2907
- continue;
2908
- }
2909
- index += 1;
2910
- break;
2911
- }
2912
- index += 1;
2913
- }
2914
- return index;
2915
- }
2916
- function skipLineComment3(sql, start) {
2917
- let index = start + 2;
2918
- while (index < sql.length && sql[index] !== "\n") {
2919
- index += 1;
2920
- }
2921
- return index;
2566
+ function isUnconditionalMergeDelete(sql) {
2567
+ return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
2922
2568
  }
2923
- function skipBlockComment3(sql, start) {
2924
- let index = start + 2;
2925
- while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
2926
- index += 1;
2927
- }
2928
- return Math.min(index + 2, sql.length);
2569
+ function isMalformedReplace(sql) {
2570
+ return !hasTopLevelKeyword(sql, "VALUES") && !hasTopLevelKeyword(sql, "SELECT") && !hasTopLevelKeyword(sql, "WITH");
2929
2571
  }
2930
- function maskIgnoredSqlText(sql) {
2931
- let masked = "";
2572
+ function trailingLineCommentIndex(sql) {
2932
2573
  let index = 0;
2933
2574
  while (index < sql.length) {
2934
2575
  const char = sql[index];
2935
2576
  if (char === "'" || char === '"') {
2936
- const end = skipQuotedText2(sql, index);
2937
- masked += " ".repeat(end - index);
2938
- index = end;
2577
+ index = skipQuotedText2(sql, index);
2939
2578
  continue;
2940
2579
  }
2941
- if (char === "-" && sql[index + 1] === "-") {
2942
- const end = skipLineComment3(sql, index);
2943
- masked += " ".repeat(end - index);
2944
- index = end;
2580
+ if (char === "/" && sql[index + 1] === "*") {
2581
+ index = skipBlockComment2(sql, index);
2945
2582
  continue;
2946
2583
  }
2947
- if (char === "/" && sql[index + 1] === "*") {
2948
- const end = skipBlockComment3(sql, index);
2949
- masked += " ".repeat(end - index);
2950
- index = end;
2584
+ if (char === "-" && sql[index + 1] === "-") {
2585
+ const lineEnd = sql.indexOf("\n", index + 2);
2586
+ if (lineEnd === -1 || sql.slice(lineEnd + 1).trim().length === 0) {
2587
+ return index;
2588
+ }
2589
+ index = lineEnd + 1;
2951
2590
  continue;
2952
2591
  }
2953
- masked += char ?? "";
2954
2592
  index += 1;
2955
2593
  }
2956
- return masked;
2957
- }
2958
- function hasTopLevelKeyword(sql, keyword) {
2959
- const masked = maskIgnoredSqlText(sql);
2960
- let depth = 0;
2961
- for (let index = 0; index < masked.length; index += 1) {
2962
- const char = masked[index];
2963
- if (char === "(") {
2964
- depth += 1;
2965
- continue;
2966
- }
2967
- if (char === ")") {
2968
- depth = Math.max(0, depth - 1);
2969
- continue;
2970
- }
2971
- if (depth === 0 && masked.slice(index, index + keyword.length).toUpperCase() === keyword && !/[A-Za-z0-9_$#]/.test(masked.charAt(index - 1)) && !/[A-Za-z0-9_$#]/.test(masked.charAt(index + keyword.length))) {
2972
- return true;
2973
- }
2974
- }
2975
- return false;
2976
- }
2977
- function isUnconditionalMergeDelete(sql) {
2978
- return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
2979
- }
2980
- function isMalformedReplace(sql) {
2981
- return !hasTopLevelKeyword(sql, "VALUES") && !hasTopLevelKeyword(sql, "SELECT") && !hasTopLevelKeyword(sql, "WITH");
2982
- }
2983
- function trailingLineCommentIndex(sql) {
2984
- let index = 0;
2985
- while (index < sql.length) {
2986
- const char = sql[index];
2987
- if (char === "'" || char === '"') {
2988
- index = skipQuotedText2(sql, index);
2989
- continue;
2990
- }
2991
- if (char === "/" && sql[index + 1] === "*") {
2992
- index = skipBlockComment3(sql, index);
2993
- continue;
2994
- }
2995
- if (char === "-" && sql[index + 1] === "-") {
2996
- const lineEnd = sql.indexOf("\n", index + 2);
2997
- if (lineEnd === -1 || sql.slice(lineEnd + 1).trim().length === 0) {
2998
- return index;
2999
- }
3000
- index = lineEnd + 1;
3001
- continue;
3002
- }
3003
- index += 1;
3004
- }
3005
- return void 0;
2594
+ return void 0;
3006
2595
  }
3007
2596
  function appendLimit(sql, limit) {
3008
2597
  const trimmed = sql.replace(/(?<![\s;])[\s;]+$/, "");
@@ -3016,17 +2605,24 @@ function appendLimit(sql, limit) {
3016
2605
  }
3017
2606
  function inspectStatement(sql) {
3018
2607
  const kind = classifyStatement(sql);
3019
- const keyword = firstKeyword(sql);
2608
+ const leading = firstKeyword(sql);
2609
+ const withResolved = leading === "WITH" ? resolveWithStatement(sql) : void 0;
2610
+ const keyword = withResolved?.keyword ?? leading;
2611
+ const tail = withResolved === void 0 ? sql : sql.slice(withResolved.index);
3020
2612
  if (kind === "ddl") {
3021
2613
  return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
3022
2614
  }
3023
2615
  if (kind === "dml") {
3024
- const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(sql, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(sql) || keyword === "REPLACE" && isMalformedReplace(sql);
2616
+ const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(tail, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(tail) || keyword === "REPLACE" && isMalformedReplace(tail);
3025
2617
  return {
3026
2618
  kind,
3027
2619
  destructive
3028
2620
  };
3029
2621
  }
2622
+ if (kind === "unknown") {
2623
+ const unresolvedWith = leading === "WITH" && withResolved === void 0;
2624
+ return { kind, destructive: keyword === "CALL" || unresolvedWith };
2625
+ }
3030
2626
  return { kind, destructive: false };
3031
2627
  }
3032
2628
  function evaluateGuard(sql, config) {
@@ -3070,17 +2666,17 @@ function applyAutoLimit(sql, limit) {
3070
2666
  }
3071
2667
 
3072
2668
  // src/tunnel/cache.ts
3073
- import { createHash as createHash2 } from "crypto";
3074
- import { mkdir as mkdir4, readdir as readdir2, readFile as readFile2, rename as rename2, rm as rm5, writeFile as writeFile3 } from "fs/promises";
3075
- import { homedir as homedir4 } from "os";
3076
- import { join as join6 } from "path";
2669
+ import { createHash } from "crypto";
2670
+ import { mkdir as mkdir3, readdir as readdir2, readFile as readFile2, rename, rm as rm4, writeFile as writeFile2 } from "fs/promises";
2671
+ import { homedir as homedir3 } from "os";
2672
+ import { join as join5 } from "path";
3077
2673
 
3078
2674
  // src/tunnel/process.ts
3079
2675
  import { spawn } from "child_process";
3080
- import { mkdtemp as mkdtemp2, rm as rm4 } from "fs/promises";
2676
+ import { mkdtemp as mkdtemp2, open, readFile, rm as rm3 } from "fs/promises";
3081
2677
  import { connect as netConnect, createServer } from "net";
3082
2678
  import { tmpdir as tmpdir2 } from "os";
3083
- import { join as join5 } from "path";
2679
+ import { join as join4 } from "path";
3084
2680
  var PORT_POLL_INTERVAL_MS = 100;
3085
2681
  var DEFAULT_PORT_PROBE_TIMEOUT_MS = 500;
3086
2682
  function assertPositiveSafeInteger(label, value) {
@@ -3147,7 +2743,7 @@ async function withScopedCfSession(selectorSource, target, sapCredentials, work)
3147
2743
  "SAP credentials are required to open a tunnel session for an explicit selector"
3148
2744
  );
3149
2745
  }
3150
- const cfHome = await mkdtemp2(join5(tmpdir2(), "saptools-cf-hana-tunnel-"));
2746
+ const cfHome = await mkdtemp2(join4(tmpdir2(), "saptools-cf-hana-tunnel-"));
3151
2747
  try {
3152
2748
  const ctx = { cfHome };
3153
2749
  await cfApi(target.apiEndpoint, ctx);
@@ -3155,7 +2751,7 @@ async function withScopedCfSession(selectorSource, target, sapCredentials, work)
3155
2751
  await cfTargetSpace(target.orgName, target.spaceName, ctx);
3156
2752
  return await work(ctx);
3157
2753
  } finally {
3158
- await rm4(cfHome, { recursive: true, force: true });
2754
+ await rm3(cfHome, { recursive: true, force: true });
3159
2755
  }
3160
2756
  }
3161
2757
  function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess) {
@@ -3184,8 +2780,8 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
3184
2780
  child.on("exit", onAbort);
3185
2781
  child.on("error", onAbort);
3186
2782
  timers.poll = setInterval(() => {
3187
- void probePort(localPort).then((open) => {
3188
- if (open && child.pid !== void 0) {
2783
+ void probePort(localPort).then((open2) => {
2784
+ if (open2 && child.pid !== void 0) {
3189
2785
  finish({ localPort, pid: child.pid });
3190
2786
  }
3191
2787
  });
@@ -3193,6 +2789,36 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
3193
2789
  timers.deadline = setTimeout(finish, boundMs);
3194
2790
  });
3195
2791
  }
2792
+ var STDERR_TAIL_MAX_CHARS = 4096;
2793
+ async function openStderrCapture() {
2794
+ try {
2795
+ const dir = await mkdtemp2(join4(tmpdir2(), "saptools-cf-hana-tunnel-log-"));
2796
+ const path = join4(dir, "stderr.log");
2797
+ const handle = await open(path, "w");
2798
+ return {
2799
+ path,
2800
+ fd: handle.fd,
2801
+ close: async () => {
2802
+ await handle.close().catch(() => {
2803
+ });
2804
+ await rm3(dir, { recursive: true, force: true });
2805
+ }
2806
+ };
2807
+ } catch {
2808
+ return void 0;
2809
+ }
2810
+ }
2811
+ async function readStderrTail(path) {
2812
+ try {
2813
+ const content = (await readFile(path, "utf8")).trim().replace(/\s+/g, " ");
2814
+ if (content.length === 0) {
2815
+ return void 0;
2816
+ }
2817
+ return content.length > STDERR_TAIL_MAX_CHARS ? content.slice(-STDERR_TAIL_MAX_CHARS) : content;
2818
+ } catch {
2819
+ return void 0;
2820
+ }
2821
+ }
3196
2822
  async function spawnTunnel(params, deps = {}) {
3197
2823
  assertPositiveSafeInteger("tunnel keepalive seconds", params.keepaliveSeconds);
3198
2824
  const remainingMs = params.deadline - Date.now();
@@ -3217,14 +2843,30 @@ async function spawnTunnel(params, deps = {}) {
3217
2843
  `sleep ${String(params.keepaliveSeconds)}`
3218
2844
  ];
3219
2845
  const env = params.cfHome === void 0 ? { ...process.env } : { ...process.env, CF_HOME: params.cfHome };
3220
- const child = spawnProcess(bin, args, { detached: true, stdio: "ignore", env });
3221
- return await raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess);
2846
+ const openCapture = deps.openStderrCapture ?? openStderrCapture;
2847
+ const capture = await openCapture();
2848
+ const stdio = capture === void 0 ? "ignore" : ["ignore", "ignore", capture.fd];
2849
+ const child = spawnProcess(bin, args, { detached: true, stdio, env });
2850
+ const result = await raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess);
2851
+ if (capture !== void 0) {
2852
+ if (result === void 0) {
2853
+ try {
2854
+ const tail = await readStderrTail(capture.path);
2855
+ if (tail !== void 0) {
2856
+ deps.onFailureDiagnostic?.(tail);
2857
+ }
2858
+ } catch {
2859
+ }
2860
+ }
2861
+ await capture.close();
2862
+ }
2863
+ return result;
3222
2864
  }
3223
2865
 
3224
2866
  // src/tunnel/cache.ts
3225
2867
  var DEFAULT_ESTABLISHING_STALE_MS = 2 * DEFAULT_TUNNEL_FALLBACK_BUDGET_MS;
3226
2868
  var DEFAULT_POLL_INTERVAL_MS = 300;
3227
- function isRecord3(value) {
2869
+ function isRecord2(value) {
3228
2870
  return typeof value === "object" && value !== null;
3229
2871
  }
3230
2872
  function defaultIsProcessAlive(pid) {
@@ -3232,7 +2874,7 @@ function defaultIsProcessAlive(pid) {
3232
2874
  process.kill(pid, 0);
3233
2875
  return true;
3234
2876
  } catch (error) {
3235
- return isRecord3(error) && error["code"] !== "ESRCH";
2877
+ return isRecord2(error) && error["code"] !== "ESRCH";
3236
2878
  }
3237
2879
  }
3238
2880
  function sleep(ms) {
@@ -3241,16 +2883,16 @@ function sleep(ms) {
3241
2883
  });
3242
2884
  }
3243
2885
  function tunnelCacheRoot(saptoolsRoot) {
3244
- return join6(saptoolsRoot ?? join6(homedir4(), ".saptools"), "cf-hana", "tunnel");
2886
+ return join5(saptoolsRoot ?? join5(homedir3(), ".saptools"), "cf-hana", "tunnel");
3245
2887
  }
3246
2888
  function tunnelCacheKey(host) {
3247
- return createHash2("sha256").update(host).digest("hex");
2889
+ return createHash("sha256").update(host).digest("hex");
3248
2890
  }
3249
2891
  function tunnelCachePath(host, saptoolsRoot) {
3250
- return join6(tunnelCacheRoot(saptoolsRoot), `${tunnelCacheKey(host)}.json`);
2892
+ return join5(tunnelCacheRoot(saptoolsRoot), `${tunnelCacheKey(host)}.json`);
3251
2893
  }
3252
2894
  function isOrgKey(value) {
3253
- return isRecord3(value) && typeof value["apiEndpoint"] === "string" && typeof value["orgName"] === "string";
2895
+ return isRecord2(value) && typeof value["apiEndpoint"] === "string" && typeof value["orgName"] === "string";
3254
2896
  }
3255
2897
  function hasEstablishingFields(value) {
3256
2898
  return value["status"] === "establishing" && typeof value["host"] === "string" && typeof value["startedAt"] === "string" && typeof value["ownerPid"] === "number" && isOrgKey(value["orgKey"]);
@@ -3259,7 +2901,7 @@ function hasReadyFields(value) {
3259
2901
  return value["status"] === "ready" && typeof value["host"] === "string" && typeof value["localPort"] === "number" && typeof value["pid"] === "number" && typeof value["app"] === "string" && typeof value["expiresAt"] === "string" && isOrgKey(value["orgKey"]);
3260
2902
  }
3261
2903
  function isTunnelCacheRecord(value) {
3262
- return isRecord3(value) && value["version"] === 1 && (hasEstablishingFields(value) || hasReadyFields(value));
2904
+ return isRecord2(value) && value["version"] === 1 && (hasEstablishingFields(value) || hasReadyFields(value));
3263
2905
  }
3264
2906
  async function parseTunnelCacheFile(path) {
3265
2907
  try {
@@ -3303,7 +2945,7 @@ function isEstablishingStale(entry, options) {
3303
2945
  }
3304
2946
  async function writeEstablishingMarker(host, ownerPid, orgKey, options) {
3305
2947
  const root = tunnelCacheRoot(options.saptoolsRoot);
3306
- await mkdir4(root, { recursive: true, mode: 448 });
2948
+ await mkdir3(root, { recursive: true, mode: 448 });
3307
2949
  const record = {
3308
2950
  version: 1,
3309
2951
  status: "establishing",
@@ -3313,7 +2955,7 @@ async function writeEstablishingMarker(host, ownerPid, orgKey, options) {
3313
2955
  orgKey
3314
2956
  };
3315
2957
  try {
3316
- await writeFile3(tunnelCachePath(host, options.saptoolsRoot), `${JSON.stringify(record)}
2958
+ await writeFile2(tunnelCachePath(host, options.saptoolsRoot), `${JSON.stringify(record)}
3317
2959
  `, {
3318
2960
  encoding: "utf8",
3319
2961
  mode: 384,
@@ -3321,7 +2963,7 @@ async function writeEstablishingMarker(host, ownerPid, orgKey, options) {
3321
2963
  });
3322
2964
  return true;
3323
2965
  } catch (error) {
3324
- if (isRecord3(error) && error["code"] === "EEXIST") {
2966
+ if (isRecord2(error) && error["code"] === "EEXIST") {
3325
2967
  return false;
3326
2968
  }
3327
2969
  throw error;
@@ -3341,7 +2983,7 @@ async function claimEstablishing(host, ownerPid, orgKey, options = {}) {
3341
2983
  if (!isEstablishingStale(existing, options)) {
3342
2984
  return { outcome: "wait" };
3343
2985
  }
3344
- await rm5(tunnelCachePath(host, options.saptoolsRoot), { force: true });
2986
+ await rm4(tunnelCachePath(host, options.saptoolsRoot), { force: true });
3345
2987
  return await writeEstablishingMarker(host, ownerPid, orgKey, options) ? { outcome: "claimed" } : { outcome: "wait" };
3346
2988
  }
3347
2989
  async function waitForEstablishment(host, deadline, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, options = {}) {
@@ -3365,17 +3007,17 @@ async function finalizeEstablishingReady(host, record, options = {}) {
3365
3007
  const path = tunnelCachePath(host, options.saptoolsRoot);
3366
3008
  const tempPath = `${path}.tmp-${String(process.pid)}`;
3367
3009
  const stored = { version: 1, status: "ready", host, ...record };
3368
- await mkdir4(root, { recursive: true, mode: 448 });
3369
- await rm5(tempPath, { force: true });
3370
- await writeFile3(tempPath, `${JSON.stringify(stored)}
3010
+ await mkdir3(root, { recursive: true, mode: 448 });
3011
+ await rm4(tempPath, { force: true });
3012
+ await writeFile2(tempPath, `${JSON.stringify(stored)}
3371
3013
  `, { encoding: "utf8", mode: 384 });
3372
- await rename2(tempPath, path);
3014
+ await rename(tempPath, path);
3373
3015
  }
3374
3016
  async function finalizeEstablishingFailed(host, options = {}) {
3375
- await rm5(tunnelCachePath(host, options.saptoolsRoot), { force: true });
3017
+ await rm4(tunnelCachePath(host, options.saptoolsRoot), { force: true });
3376
3018
  }
3377
3019
  async function evictTunnelCache(host, options = {}) {
3378
- await rm5(tunnelCachePath(host, options.saptoolsRoot), { force: true });
3020
+ await rm4(tunnelCachePath(host, options.saptoolsRoot), { force: true });
3379
3021
  }
3380
3022
  function sameOrg(a, b) {
3381
3023
  return a.apiEndpoint === b.apiEndpoint && a.orgName === b.orgName;
@@ -3388,19 +3030,19 @@ async function reapOneFile(path, currentOrgKey, options) {
3388
3030
  const killProcess = options.killProcess ?? killTunnelProcess;
3389
3031
  if (entry.status === "establishing") {
3390
3032
  if (isEstablishingStale(entry, options)) {
3391
- await rm5(path, { force: true });
3033
+ await rm4(path, { force: true });
3392
3034
  }
3393
3035
  return;
3394
3036
  }
3395
3037
  const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
3396
3038
  if (!isAlive(entry.pid)) {
3397
- await rm5(path, { force: true });
3039
+ await rm4(path, { force: true });
3398
3040
  return;
3399
3041
  }
3400
3042
  const now = options.now?.() ?? /* @__PURE__ */ new Date();
3401
3043
  if (isExpired(entry, now) || !sameOrg(entry.orgKey, currentOrgKey)) {
3402
3044
  killProcess(entry.pid);
3403
- await rm5(path, { force: true });
3045
+ await rm4(path, { force: true });
3404
3046
  }
3405
3047
  }
3406
3048
  async function reapStaleAndCrossOrgTunnels(currentOrgKey, options = {}) {
@@ -3413,7 +3055,7 @@ async function reapStaleAndCrossOrgTunnels(currentOrgKey, options = {}) {
3413
3055
  }
3414
3056
  await Promise.all(
3415
3057
  files.filter((file) => file.endsWith(".json")).map(async (file) => {
3416
- await reapOneFile(join6(root, file), currentOrgKey, options);
3058
+ await reapOneFile(join5(root, file), currentOrgKey, options);
3417
3059
  })
3418
3060
  );
3419
3061
  }
@@ -3481,6 +3123,9 @@ function isConnectivityFailure(error) {
3481
3123
  }
3482
3124
  return hasOpenConnectionCause(error.cause, 0);
3483
3125
  }
3126
+ function isUnattributedQueryFailure(error) {
3127
+ return error instanceof QueryError && error.databaseCode === void 0 && error.sqlState === void 0;
3128
+ }
3484
3129
 
3485
3130
  // src/tunnel/fallback.ts
3486
3131
  var EXPIRY_SAFETY_MARGIN_MS = 3e4;
@@ -3510,30 +3155,40 @@ async function discardQuietly(work) {
3510
3155
  await work.catch(() => {
3511
3156
  });
3512
3157
  }
3158
+ function shouldDiscardTunnel(error) {
3159
+ return isConnectivityFailure(error) || isUnattributedQueryFailure(error);
3160
+ }
3513
3161
  async function tryReadyRecord(record, driver, config, directParams, cacheOptions) {
3514
3162
  try {
3515
3163
  const connection = await driver.connect(tunneledParams(directParams, record.localPort));
3516
3164
  config.onTunnelStatus?.(`connected via SSH tunnel through ${record.app}`);
3517
3165
  return connection;
3518
3166
  } catch (error) {
3519
- if (isConnectivityFailure(error)) {
3167
+ if (shouldDiscardTunnel(error)) {
3520
3168
  await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3521
3169
  return void 0;
3522
3170
  }
3523
3171
  throw error;
3524
3172
  }
3525
3173
  }
3526
- async function discoverAppsStdout(ctx) {
3174
+ function knownCandidates(targetAppName, hintApp) {
3175
+ return hintApp === void 0 || hintApp === targetAppName ? [targetAppName] : [targetAppName, hintApp];
3176
+ }
3177
+ async function discoverAppsStdout(ctx, timeoutMs) {
3527
3178
  try {
3528
- return ctx === void 0 ? await cfAppsDirect() : await cfApps(ctx);
3179
+ return ctx === void 0 ? await cfAppsDirect(timeoutMs) : await cfApps(ctx, timeoutMs);
3529
3180
  } catch {
3530
3181
  return;
3531
3182
  }
3532
3183
  }
3533
- async function buildCandidates(config, ctx, hintApp) {
3534
- const stdout = await discoverAppsStdout(ctx);
3184
+ async function discoverExtraCandidates(config, ctx, alreadyTried, deadline) {
3185
+ const remainingMs = deadline - Date.now();
3186
+ if (remainingMs <= 0) {
3187
+ return [];
3188
+ }
3189
+ const stdout = await discoverAppsStdout(ctx, remainingMs);
3535
3190
  const base = buildCandidateList(config.appName, stdout, DEFAULT_TUNNEL_MAX_CANDIDATES);
3536
- return hintApp === void 0 || base.includes(hintApp) ? base : [hintApp, ...base];
3191
+ return base.filter((app) => !alreadyTried.includes(app));
3537
3192
  }
3538
3193
  async function finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions) {
3539
3194
  const readyRecord = {
@@ -3549,7 +3204,7 @@ async function finalizeCandidateConnection(app, spawned, driver, config, directP
3549
3204
  config.onTunnelStatus?.(`connected via SSH tunnel through ${app}`);
3550
3205
  return connection;
3551
3206
  } catch (error) {
3552
- if (isConnectivityFailure(error)) {
3207
+ if (shouldDiscardTunnel(error)) {
3553
3208
  await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
3554
3209
  killTunnelProcess(spawned.pid);
3555
3210
  return void 0;
@@ -3566,7 +3221,8 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
3566
3221
  return await tryReadyRecord(claim.record, driver, config, directParams, cacheOptions);
3567
3222
  }
3568
3223
  if (claim.outcome === "wait") {
3569
- const ready = await waitForEstablishment(config.host, deadline, void 0, cacheOptions);
3224
+ const waitDeadline = Math.min(deadline, Date.now() + DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS);
3225
+ const ready = await waitForEstablishment(config.host, waitDeadline, void 0, cacheOptions);
3570
3226
  return ready === void 0 ? void 0 : await tryReadyRecord(ready, driver, config, directParams, cacheOptions);
3571
3227
  }
3572
3228
  const spawned = await spawnTunnel(
@@ -3579,7 +3235,12 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
3579
3235
  deadline,
3580
3236
  candidateTimeoutMs: DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS
3581
3237
  },
3582
- overrides.process ?? {}
3238
+ {
3239
+ ...overrides.process,
3240
+ onFailureDiagnostic: (message) => {
3241
+ config.onTunnelStatus?.(`tunnel via ${app} failed: ${message}`);
3242
+ }
3243
+ }
3583
3244
  );
3584
3245
  if (spawned === void 0) {
3585
3246
  await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
@@ -3589,7 +3250,11 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
3589
3250
  }
3590
3251
  async function tryCachedTunnel(driver, config, directParams, cacheOptions) {
3591
3252
  if (config.refreshTunnel) {
3253
+ const superseded = await readTunnelCacheEntry(config.host, cacheOptions);
3592
3254
  await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3255
+ if (superseded?.status === "ready") {
3256
+ (cacheOptions.killProcess ?? killTunnelProcess)(superseded.pid);
3257
+ }
3593
3258
  return { connection: void 0, hintApp: void 0 };
3594
3259
  }
3595
3260
  const entry = await readTunnelCacheEntry(config.host, cacheOptions);
@@ -3607,8 +3272,12 @@ async function tryDirectConnect(driver, config, directParams) {
3607
3272
  if (config.tunnelMode !== "auto") {
3608
3273
  return { connection: void 0, directError: void 0 };
3609
3274
  }
3275
+ const boundedDirectParams = {
3276
+ ...directParams,
3277
+ connectTimeoutMs: Math.min(directParams.connectTimeoutMs, DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS)
3278
+ };
3610
3279
  try {
3611
- return { connection: await driver.connect(directParams), directError: void 0 };
3280
+ return { connection: await driver.connect(boundedDirectParams), directError: void 0 };
3612
3281
  } catch (error) {
3613
3282
  if (!isConnectivityFailure(error)) {
3614
3283
  throw error;
@@ -3625,18 +3294,47 @@ function buildExhaustionError(host, directError, candidates) {
3625
3294
  `Could not establish an SSH tunnel to ${host} through any candidate app (tried: ${candidates.length > 0 ? candidates.join(", ") : "none"})`
3626
3295
  );
3627
3296
  }
3628
- async function runCandidateLoop(ctx, driver, config, directParams, deadline, hintApp, overrides) {
3629
- const candidatesTried = await buildCandidates(config, ctx, hintApp);
3630
- for (const app of candidatesTried) {
3297
+ async function tryCandidateList(apps, ctx, driver, config, directParams, deadline, overrides, tried) {
3298
+ for (const app of apps) {
3631
3299
  if (Date.now() >= deadline) {
3632
- break;
3300
+ return void 0;
3633
3301
  }
3302
+ tried.push(app);
3634
3303
  const connection = await tryCandidate(app, ctx, driver, config, directParams, deadline, overrides);
3635
3304
  if (connection !== void 0) {
3636
- return { connection, candidatesTried };
3305
+ return connection;
3637
3306
  }
3638
3307
  }
3639
- return { connection: void 0, candidatesTried };
3308
+ return void 0;
3309
+ }
3310
+ async function runCandidateLoop(ctx, driver, config, directParams, deadline, hintApp, overrides) {
3311
+ const tried = [];
3312
+ const known = knownCandidates(config.appName, hintApp);
3313
+ const knownConnection = await tryCandidateList(
3314
+ known,
3315
+ ctx,
3316
+ driver,
3317
+ config,
3318
+ directParams,
3319
+ deadline,
3320
+ overrides,
3321
+ tried
3322
+ );
3323
+ if (knownConnection !== void 0) {
3324
+ return { connection: knownConnection, candidatesTried: tried };
3325
+ }
3326
+ const discovered = await discoverExtraCandidates(config, ctx, tried, deadline);
3327
+ const discoveredConnection = await tryCandidateList(
3328
+ discovered,
3329
+ ctx,
3330
+ driver,
3331
+ config,
3332
+ directParams,
3333
+ deadline,
3334
+ overrides,
3335
+ tried
3336
+ );
3337
+ return { connection: discoveredConnection, candidatesTried: tried };
3640
3338
  }
3641
3339
  async function connectWithTunnelFallback(driver, config, overrides = {}) {
3642
3340
  const directParams = directParamsOf(config);
@@ -4164,131 +3862,689 @@ var HanaClient = class _HanaClient {
4164
3862
  const built = buildInsert(schema, table, values);
4165
3863
  return await this.runExecute(built.sql, built.params);
4166
3864
  }
4167
- /** Update rows matching a non-empty `where` filter. */
4168
- async update(schema, table, values, where) {
4169
- const built = buildUpdate(schema, table, values, where);
4170
- return await this.runExecute(built.sql, built.params);
3865
+ /** Update rows matching a non-empty `where` filter. */
3866
+ async update(schema, table, values, where) {
3867
+ const built = buildUpdate(schema, table, values, where);
3868
+ return await this.runExecute(built.sql, built.params);
3869
+ }
3870
+ /** Delete rows matching a non-empty `where` filter. */
3871
+ async deleteFrom(schema, table, where) {
3872
+ const built = buildDelete(schema, table, where);
3873
+ return await this.runExecute(built.sql, built.params);
3874
+ }
3875
+ /** Run `work` inside a transaction, auto-committing on success. */
3876
+ async transaction(work) {
3877
+ return await this.pool.withConnection(async (connection) => {
3878
+ await connection.setAutoCommit(false);
3879
+ const tx = new Transaction(connection);
3880
+ try {
3881
+ const result = await work(tx);
3882
+ if (!tx.isFinished) {
3883
+ await tx.commit();
3884
+ }
3885
+ return result;
3886
+ } catch (error) {
3887
+ if (!tx.isFinished) {
3888
+ try {
3889
+ await tx.rollback();
3890
+ } catch {
3891
+ }
3892
+ }
3893
+ throw error;
3894
+ } finally {
3895
+ await connection.setAutoCommit(true);
3896
+ }
3897
+ });
3898
+ }
3899
+ /** List every schema visible to the connected user. */
3900
+ async listSchemas() {
3901
+ return await this.pool.withConnection((connection) => listSchemas(connection));
3902
+ }
3903
+ /** List the tables in a schema. */
3904
+ async listTables(schema) {
3905
+ return await this.pool.withConnection((connection) => listTables(connection, schema));
3906
+ }
3907
+ /** List table and view names in a schema for typo suggestions. */
3908
+ async listCatalogObjects(schema) {
3909
+ return await this.pool.withConnection((connection) => listCatalogObjects(connection, schema));
3910
+ }
3911
+ /** List the columns of a table. */
3912
+ async listColumns(schema, table) {
3913
+ return await this.pool.withConnection(
3914
+ (connection) => listColumns(connection, schema, table)
3915
+ );
3916
+ }
3917
+ /** Return the HANA execution plan for a statement. */
3918
+ async explain(sql, params) {
3919
+ return await this.pool.withConnection(async (connection) => {
3920
+ connection.assertAllowed(sql);
3921
+ const statementName = nextExplainStatementName();
3922
+ await connection.executeInternal(
3923
+ `EXPLAIN PLAN SET STATEMENT_NAME = '${statementName}' FOR ${sql}`,
3924
+ params
3925
+ );
3926
+ const cleanup = async () => {
3927
+ await connection.executeInternal(
3928
+ "DELETE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ?",
3929
+ [statementName]
3930
+ );
3931
+ };
3932
+ let queryCompleted = false;
3933
+ try {
3934
+ const result = await connection.query(
3935
+ "SELECT OPERATOR_NAME, TABLE_NAME, TABLE_TYPE, EXECUTION_ENGINE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ? ORDER BY OPERATOR_ID",
3936
+ [statementName]
3937
+ );
3938
+ queryCompleted = true;
3939
+ await cleanup();
3940
+ return result;
3941
+ } catch (error) {
3942
+ if (!queryCompleted) {
3943
+ try {
3944
+ await cleanup();
3945
+ } catch {
3946
+ }
3947
+ }
3948
+ throw error;
3949
+ }
3950
+ });
3951
+ }
3952
+ /** Close every pooled connection. The client must not be used afterwards. */
3953
+ async close() {
3954
+ await this.pool.drain();
3955
+ }
3956
+ async runQuery(sql, params, options) {
3957
+ return await this.pool.withConnection(
3958
+ (connection) => connection.query(sql, params, options)
3959
+ );
3960
+ }
3961
+ async runExecute(sql, params, options) {
3962
+ return await this.pool.withConnection(
3963
+ (connection) => connection.execute(sql, params, options)
3964
+ );
3965
+ }
3966
+ async recordSqlHistory(operation, sql, params, result) {
3967
+ try {
3968
+ await appendSqlHistory({
3969
+ version: CLI_VERSION,
3970
+ operation,
3971
+ selector: this.info.selector,
3972
+ appName: this.info.appName,
3973
+ schema: this.info.schema,
3974
+ role: this.info.role,
3975
+ statement: result.statement,
3976
+ sql,
3977
+ paramCount: params.length,
3978
+ rowCount: result.rowCount,
3979
+ truncated: result.truncated,
3980
+ elapsedMs: result.elapsedMs
3981
+ });
3982
+ } catch {
3983
+ }
3984
+ }
3985
+ };
3986
+
3987
+ // src/api.ts
3988
+ async function connect(selector, options) {
3989
+ return await HanaClient.connect(selector, options);
3990
+ }
3991
+
3992
+ // src/metadata-cache.ts
3993
+ import { createHash as createHash2 } from "crypto";
3994
+ import { mkdir as mkdir4, readFile as readFile3, rename as rename2, rm as rm5, writeFile as writeFile3 } from "fs/promises";
3995
+ import { homedir as homedir4 } from "os";
3996
+ import { join as join6 } from "path";
3997
+ var METADATA_CACHE_TTL_MS = 30 * 6e4;
3998
+ function metadataCacheRoot(saptoolsRoot) {
3999
+ return join6(saptoolsRoot ?? join6(homedir4(), ".saptools"), "cf-hana", "metadata");
4000
+ }
4001
+ function toMetadataCacheScope(info) {
4002
+ return {
4003
+ selector: info.selector,
4004
+ appName: info.appName,
4005
+ host: info.host,
4006
+ schema: info.schema,
4007
+ role: info.role,
4008
+ driver: info.driver,
4009
+ ...info.bindingName === void 0 ? {} : { bindingName: info.bindingName },
4010
+ ...info.bindingIndex === void 0 ? {} : { bindingIndex: info.bindingIndex }
4011
+ };
4012
+ }
4013
+ function metadataCacheKey(scope) {
4014
+ return createHash2("sha256").update(JSON.stringify(scope)).digest("hex");
4015
+ }
4016
+ function metadataCachePath(scope, saptoolsRoot) {
4017
+ return join6(metadataCacheRoot(saptoolsRoot), `${metadataCacheKey(scope)}.json`);
4018
+ }
4019
+ function isRecord3(value) {
4020
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4021
+ }
4022
+ function isCatalogObject(value) {
4023
+ return isRecord3(value) && typeof value["schema"] === "string" && typeof value["name"] === "string" && (value["type"] === "TABLE" || value["type"] === "VIEW");
4024
+ }
4025
+ function hasRequiredScopeFields(value) {
4026
+ return typeof value["selector"] === "string" && typeof value["appName"] === "string" && typeof value["host"] === "string" && typeof value["schema"] === "string" && typeof value["role"] === "string" && typeof value["driver"] === "string";
4027
+ }
4028
+ function hasOptionalScopeFields(value) {
4029
+ return (value["bindingName"] === void 0 || typeof value["bindingName"] === "string") && (value["bindingIndex"] === void 0 || typeof value["bindingIndex"] === "number");
4030
+ }
4031
+ function isScope(value) {
4032
+ return isRecord3(value) && hasRequiredScopeFields(value) && hasOptionalScopeFields(value);
4033
+ }
4034
+ function scopesEqual(left, right) {
4035
+ return metadataCacheKey(left) === metadataCacheKey(right);
4036
+ }
4037
+ function isStored(value) {
4038
+ return isRecord3(value) && value["version"] === 1 && typeof value["createdAt"] === "string" && isScope(value["scope"]) && Array.isArray(value["objects"]) && value["objects"].every(isCatalogObject);
4039
+ }
4040
+ async function readMetadataCache(scope, options = {}) {
4041
+ try {
4042
+ const parsed = JSON.parse(
4043
+ await readFile3(metadataCachePath(scope, options.saptoolsRoot), "utf8")
4044
+ );
4045
+ if (!isStored(parsed) || !scopesEqual(parsed.scope, scope)) {
4046
+ return void 0;
4047
+ }
4048
+ const createdAt = Date.parse(parsed.createdAt);
4049
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
4050
+ const ageMs = now.getTime() - createdAt;
4051
+ if (!Number.isFinite(createdAt) || ageMs < 0 || ageMs >= METADATA_CACHE_TTL_MS) {
4052
+ return void 0;
4053
+ }
4054
+ return parsed.objects;
4055
+ } catch {
4056
+ return void 0;
4057
+ }
4058
+ }
4059
+ async function writeMetadataCache(scope, objects, options = {}) {
4060
+ const root = metadataCacheRoot(options.saptoolsRoot);
4061
+ const path = metadataCachePath(scope, options.saptoolsRoot);
4062
+ const tempPath = `${path}.tmp-${process.pid.toString()}`;
4063
+ const stored = {
4064
+ version: 1,
4065
+ createdAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
4066
+ scope,
4067
+ objects
4068
+ };
4069
+ await mkdir4(root, { recursive: true, mode: 448 });
4070
+ await rm5(tempPath, { force: true });
4071
+ await writeFile3(tempPath, `${JSON.stringify(stored)}
4072
+ `, { encoding: "utf8", mode: 384 });
4073
+ await rename2(tempPath, path);
4074
+ }
4075
+ async function loadCatalogObjectsWithCache(scope, refresh, loader, options = {}) {
4076
+ if (!refresh) {
4077
+ const cached = await readMetadataCache(scope, options);
4078
+ if (cached !== void 0) {
4079
+ return cached;
4080
+ }
4081
+ }
4082
+ const objects = await loader();
4083
+ try {
4084
+ await writeMetadataCache(scope, objects, options);
4085
+ } catch {
4086
+ }
4087
+ return objects;
4088
+ }
4089
+
4090
+ // src/suggestions.ts
4091
+ var INVALID_OBJECT_PATTERNS = [
4092
+ /invalid\s+(?:table|view|object)\s+name/i,
4093
+ /(?:table|view|object)\s+[^\n]*does\s+not\s+exist/i,
4094
+ /could\s+not\s+find\s+(?:table|view|object)/i
4095
+ ];
4096
+ var REF_KEYWORDS = /* @__PURE__ */ new Set(["FROM", "JOIN", "UPDATE", "INTO", "TABLE"]);
4097
+ var STOP_WORDS = /* @__PURE__ */ new Set([
4098
+ "AS",
4099
+ "ON",
4100
+ "WHERE",
4101
+ "SET",
4102
+ "VALUES",
4103
+ "USING",
4104
+ "WHEN",
4105
+ "INNER",
4106
+ "LEFT",
4107
+ "RIGHT",
4108
+ "FULL",
4109
+ "OUTER",
4110
+ "CROSS",
4111
+ "JOIN"
4112
+ ]);
4113
+ var CTE_FOLLOWERS = /* @__PURE__ */ new Set(["AS"]);
4114
+ function isInvalidCatalogObjectError(error) {
4115
+ if (!(error instanceof QueryError)) {
4116
+ return false;
4117
+ }
4118
+ if (error.sqlState === "42S02" || error.sqlState === "42S01") {
4119
+ return true;
4120
+ }
4121
+ return INVALID_OBJECT_PATTERNS.some((pattern) => pattern.test(error.message));
4122
+ }
4123
+ function skipLineComment3(sql, index) {
4124
+ let cursor = index + 2;
4125
+ while (cursor < sql.length && sql[cursor] !== "\n") {
4126
+ cursor += 1;
4127
+ }
4128
+ return cursor;
4129
+ }
4130
+ function skipBlockComment3(sql, index) {
4131
+ let cursor = index + 2;
4132
+ while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
4133
+ cursor += 1;
4134
+ }
4135
+ return Math.min(cursor + 2, sql.length);
4136
+ }
4137
+ function skipStringLiteral(sql, index) {
4138
+ let cursor = index + 1;
4139
+ while (cursor < sql.length) {
4140
+ if (sql[cursor] === "'" && sql[cursor + 1] === "'") {
4141
+ cursor += 2;
4142
+ continue;
4143
+ }
4144
+ if (sql[cursor] === "'") {
4145
+ return cursor + 1;
4146
+ }
4147
+ cursor += 1;
4148
+ }
4149
+ return cursor;
4150
+ }
4151
+ function readQuotedIdentifier(sql, index) {
4152
+ let cursor = index + 1;
4153
+ let text = "";
4154
+ while (cursor < sql.length) {
4155
+ if (sql[cursor] === '"' && sql[cursor + 1] === '"') {
4156
+ text += '"';
4157
+ cursor += 2;
4158
+ continue;
4159
+ }
4160
+ if (sql[cursor] === '"') {
4161
+ return { text, next: cursor + 1 };
4162
+ }
4163
+ text += sql.charAt(cursor);
4164
+ cursor += 1;
4165
+ }
4166
+ return { text, next: cursor };
4167
+ }
4168
+ function readBareWord(sql, index) {
4169
+ let cursor = index;
4170
+ let text = "";
4171
+ while (cursor < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(cursor))) {
4172
+ text += sql.charAt(cursor);
4173
+ cursor += 1;
4174
+ }
4175
+ return { text, next: cursor };
4176
+ }
4177
+ function tokenize(sql) {
4178
+ const tokens = [];
4179
+ let index = 0;
4180
+ while (index < sql.length) {
4181
+ const char = sql.charAt(index);
4182
+ if (/\s/.test(char)) {
4183
+ index += 1;
4184
+ continue;
4185
+ }
4186
+ if (char === "-" && sql[index + 1] === "-") {
4187
+ index = skipLineComment3(sql, index);
4188
+ continue;
4189
+ }
4190
+ if (char === "/" && sql[index + 1] === "*") {
4191
+ index = skipBlockComment3(sql, index);
4192
+ continue;
4193
+ }
4194
+ if (char === "'") {
4195
+ index = skipStringLiteral(sql, index);
4196
+ continue;
4197
+ }
4198
+ if (char === '"') {
4199
+ const quoted = readQuotedIdentifier(sql, index);
4200
+ tokens.push({ kind: "quoted", text: quoted.text });
4201
+ index = quoted.next;
4202
+ continue;
4203
+ }
4204
+ if (/[A-Za-z_#$]/.test(char)) {
4205
+ const word = readBareWord(sql, index);
4206
+ tokens.push({ kind: "word", text: word.text });
4207
+ index = word.next;
4208
+ continue;
4209
+ }
4210
+ if (char === ".") {
4211
+ tokens.push({ kind: "dot", text: char });
4212
+ } else if (char === ",") {
4213
+ tokens.push({ kind: "comma", text: char });
4214
+ } else if (char === "(") {
4215
+ tokens.push({ kind: "open", text: char });
4216
+ } else if (char === ")") {
4217
+ tokens.push({ kind: "close", text: char });
4218
+ } else {
4219
+ tokens.push({ kind: "other", text: char });
4220
+ }
4221
+ index += 1;
4222
+ }
4223
+ return tokens;
4224
+ }
4225
+ function upper(token) {
4226
+ return token?.text.toUpperCase() ?? "";
4227
+ }
4228
+ function isIdentifier(token) {
4229
+ return token?.kind === "word" || token?.kind === "quoted";
4230
+ }
4231
+ function readName(tokens, start, allowFollowingParens = false) {
4232
+ const first = tokens[start];
4233
+ if (!isIdentifier(first)) {
4234
+ return void 0;
4235
+ }
4236
+ let next = start + 1;
4237
+ let schema;
4238
+ let name = first.text;
4239
+ const maybeObject = tokens[next + 1];
4240
+ if (tokens[next]?.kind === "dot" && isIdentifier(maybeObject)) {
4241
+ schema = name;
4242
+ name = maybeObject.text;
4243
+ next += 2;
4244
+ }
4245
+ if (!allowFollowingParens && tokens[next]?.kind === "open") {
4246
+ return void 0;
4247
+ }
4248
+ return { name: schema === void 0 ? { name } : { schema, name }, next };
4249
+ }
4250
+ function firstNameFromText(text) {
4251
+ const tokens = tokenize(text);
4252
+ for (let index = 0; index < tokens.length; index += 1) {
4253
+ const read = readName(tokens, index, true);
4254
+ if (read !== void 0) {
4255
+ return read.name;
4256
+ }
4257
+ }
4258
+ return void 0;
4259
+ }
4260
+ function extractMissingObjectNameFromError(error) {
4261
+ if (!(error instanceof QueryError)) {
4262
+ return void 0;
4263
+ }
4264
+ const message = error.message;
4265
+ const invalidName = /invalid\s+(?:table|view|object)\s+name\s*:?\s*(.+)$/i.exec(message);
4266
+ if (invalidName?.[1] !== void 0) {
4267
+ return firstNameFromText(invalidName[1]);
4268
+ }
4269
+ const missingObject = /(?:table|view|object)\s+(.+?)\s+(?:does\s+not\s+exist|not\s+found)/i.exec(message);
4270
+ if (missingObject?.[1] !== void 0) {
4271
+ return firstNameFromText(missingObject[1]);
4272
+ }
4273
+ return void 0;
4274
+ }
4275
+ function cteNames(tokens) {
4276
+ const names = /* @__PURE__ */ new Set();
4277
+ if (upper(tokens[0]) !== "WITH") {
4278
+ return names;
4279
+ }
4280
+ let depth = 0;
4281
+ for (let index = 1; index < tokens.length; index += 1) {
4282
+ const token = tokens[index];
4283
+ if (depth === 0 && upper(token) === "SELECT") {
4284
+ break;
4285
+ }
4286
+ if (depth === 0 && isIdentifier(token) && CTE_FOLLOWERS.has(upper(tokens[index + 1]))) {
4287
+ names.add(token.text.toUpperCase());
4288
+ }
4289
+ if (token?.kind === "open") {
4290
+ depth += 1;
4291
+ } else if (token?.kind === "close" && depth > 0) {
4292
+ depth -= 1;
4293
+ }
4294
+ }
4295
+ return names;
4296
+ }
4297
+ function extractMissingObjectName(sql) {
4298
+ const tokens = tokenize(sql.replace(/^;+|;+$/g, ""));
4299
+ const ctes = cteNames(tokens);
4300
+ let candidate;
4301
+ for (let index = 0; index < tokens.length; index += 1) {
4302
+ const word = upper(tokens[index]);
4303
+ if (!REF_KEYWORDS.has(word)) {
4304
+ continue;
4305
+ }
4306
+ if (word === "TABLE" && upper(tokens[index - 1]) !== "TRUNCATE") {
4307
+ continue;
4308
+ }
4309
+ if (word === "INTO" && !(upper(tokens[index - 1]) === "INSERT" || upper(tokens[index - 1]) === "MERGE")) {
4310
+ continue;
4311
+ }
4312
+ const read = readName(tokens, index + 1, word === "INTO");
4313
+ if (read === void 0) {
4314
+ continue;
4315
+ }
4316
+ if (read.name.schema === void 0 && ctes.has(read.name.name.toUpperCase())) {
4317
+ continue;
4318
+ }
4319
+ if (STOP_WORDS.has(read.name.name.toUpperCase())) {
4320
+ continue;
4321
+ }
4322
+ candidate = read.name;
4323
+ let next = read.next;
4324
+ if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
4325
+ next += 1;
4326
+ }
4327
+ while (tokens[next]?.kind === "comma") {
4328
+ const commaRead = readName(tokens, next + 1);
4329
+ if (commaRead === void 0) {
4330
+ break;
4331
+ }
4332
+ if (!(commaRead.name.schema === void 0 && ctes.has(commaRead.name.name.toUpperCase()))) {
4333
+ candidate = commaRead.name;
4334
+ }
4335
+ next = commaRead.next;
4336
+ if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
4337
+ next += 1;
4338
+ }
4339
+ }
4340
+ }
4341
+ return candidate;
4342
+ }
4343
+ function singular(value) {
4344
+ if (value.endsWith("ES")) {
4345
+ return value.slice(0, -2);
4346
+ }
4347
+ if (value.endsWith("S")) {
4348
+ return value.slice(0, -1);
4349
+ }
4350
+ return value;
4351
+ }
4352
+ function norm(value) {
4353
+ return value.toUpperCase().replace(/[^A-Z0-9]+/g, "");
4354
+ }
4355
+ function distance(a, b) {
4356
+ const previous = Array.from({ length: b.length + 1 }, (_value, index) => index);
4357
+ for (let leftIndex = 1; leftIndex <= a.length; leftIndex += 1) {
4358
+ let last = leftIndex - 1;
4359
+ previous[0] = leftIndex;
4360
+ for (let rightIndex = 1; rightIndex <= b.length; rightIndex += 1) {
4361
+ const old = previous[rightIndex] ?? 0;
4362
+ previous[rightIndex] = Math.min(
4363
+ (previous[rightIndex] ?? 0) + 1,
4364
+ (previous[rightIndex - 1] ?? 0) + 1,
4365
+ last + (a.charAt(leftIndex - 1) === b.charAt(rightIndex - 1) ? 0 : 1)
4366
+ );
4367
+ last = old;
4368
+ }
4369
+ }
4370
+ return previous[b.length] ?? 0;
4371
+ }
4372
+ function score(requested, candidate) {
4373
+ const requestedName = norm(requested.name);
4374
+ const candidateName = norm(candidate.name);
4375
+ if (requested.schema !== void 0 && norm(requested.schema) !== norm(candidate.schema)) {
4376
+ return 0;
4377
+ }
4378
+ if (requestedName === candidateName) {
4379
+ return 100;
4380
+ }
4381
+ if (singular(requestedName) === singular(candidateName)) {
4382
+ return 92;
4383
+ }
4384
+ let value = 0;
4385
+ if (candidateName.startsWith(requestedName) || requestedName.startsWith(candidateName)) {
4386
+ value = Math.max(value, 80 - Math.abs(candidateName.length - requestedName.length));
4387
+ }
4388
+ if (candidateName.endsWith(requestedName) || requestedName.endsWith(candidateName)) {
4389
+ value = Math.max(value, 70 - Math.abs(candidateName.length - requestedName.length));
4171
4390
  }
4172
- /** Delete rows matching a non-empty `where` filter. */
4173
- async deleteFrom(schema, table, where) {
4174
- const built = buildDelete(schema, table, where);
4175
- return await this.runExecute(built.sql, built.params);
4391
+ const editDistance = distance(requestedName, candidateName);
4392
+ const max = Math.max(requestedName.length, candidateName.length, 1);
4393
+ if (editDistance <= Math.max(3, Math.floor(max * 0.35))) {
4394
+ value = Math.max(value, Math.round(75 * (1 - editDistance / max)));
4176
4395
  }
4177
- /** Run `work` inside a transaction, auto-committing on success. */
4178
- async transaction(work) {
4179
- return await this.pool.withConnection(async (connection) => {
4180
- await connection.setAutoCommit(false);
4181
- const tx = new Transaction(connection);
4182
- try {
4183
- const result = await work(tx);
4184
- if (!tx.isFinished) {
4185
- await tx.commit();
4186
- }
4187
- return result;
4188
- } catch (error) {
4189
- if (!tx.isFinished) {
4190
- try {
4191
- await tx.rollback();
4192
- } catch {
4193
- }
4194
- }
4195
- throw error;
4196
- } finally {
4197
- await connection.setAutoCommit(true);
4198
- }
4199
- });
4396
+ return value;
4397
+ }
4398
+ function extractInvalidColumnNameFromError(error) {
4399
+ if (!(error instanceof QueryError)) {
4400
+ return void 0;
4200
4401
  }
4201
- /** List every schema visible to the connected user. */
4202
- async listSchemas() {
4203
- return await this.pool.withConnection((connection) => listSchemas(connection));
4402
+ const match = /invalid column name:\s*([^:]+)/i.exec(error.message);
4403
+ const name = match?.[1]?.trim();
4404
+ return name === void 0 || name.length === 0 ? void 0 : name;
4405
+ }
4406
+ function rankCatalogSuggestions(requested, candidates, limit = 5) {
4407
+ return candidates.map((candidate) => ({ candidate, score: score(requested, candidate) })).filter((item) => item.score >= 45).sort(
4408
+ (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)
4409
+ ).slice(0, limit).map((item) => item.candidate);
4410
+ }
4411
+ function formatSuggestions(suggestions) {
4412
+ if (suggestions.length === 0) {
4413
+ return void 0;
4204
4414
  }
4205
- /** List the tables in a schema. */
4206
- async listTables(schema) {
4207
- return await this.pool.withConnection((connection) => listTables(connection, schema));
4415
+ return [
4416
+ "Did you mean:",
4417
+ ...suggestions.map((item) => ` ${item.schema}.${item.name} (${item.type})`)
4418
+ ].join("\n");
4419
+ }
4420
+ function rankNameSuggestions(requested, candidates, limit = 5) {
4421
+ const requestedName = { name: requested };
4422
+ return candidates.map((candidate) => ({
4423
+ candidate,
4424
+ score: score(requestedName, { schema: "", name: candidate.name, type: "TABLE" })
4425
+ })).filter((item) => item.score >= 45).sort(
4426
+ (left, right) => right.score - left.score || left.candidate.name.localeCompare(right.candidate.name)
4427
+ ).slice(0, limit).map((item) => item.candidate);
4428
+ }
4429
+ function formatColumnSuggestions(suggestions) {
4430
+ if (suggestions.length === 0) {
4431
+ return void 0;
4208
4432
  }
4209
- /** List table and view names in a schema for typo suggestions. */
4210
- async listCatalogObjects(schema) {
4211
- return await this.pool.withConnection((connection) => listCatalogObjects(connection, schema));
4433
+ return [
4434
+ "Did you mean column:",
4435
+ ...suggestions.map((item) => ` ${item.name}`)
4436
+ ].join("\n");
4437
+ }
4438
+
4439
+ // src/cli-query-hints.ts
4440
+ async function loadSuggestionCatalogObjects(client, refresh) {
4441
+ try {
4442
+ return await loadCatalogObjectsWithCache(
4443
+ toMetadataCacheScope(client.info),
4444
+ refresh,
4445
+ async () => await client.listCatalogObjects(client.info.schema)
4446
+ );
4447
+ } catch {
4448
+ return await client.listCatalogObjects(client.info.schema);
4212
4449
  }
4213
- /** List the columns of a table. */
4214
- async listColumns(schema, table) {
4215
- return await this.pool.withConnection(
4216
- (connection) => listColumns(connection, schema, table)
4450
+ }
4451
+ function isLobSortOrGroupError(error) {
4452
+ const code = databaseCode(error);
4453
+ if (code !== 266 && code !== 274) {
4454
+ return false;
4455
+ }
4456
+ return error instanceof QueryError && /LOB type is not allowed in (?:ORDER BY|GROUP BY) clause/i.test(error.message);
4457
+ }
4458
+ function printLobSortOrGroupHint() {
4459
+ const lines = [
4460
+ `${CLI_NAME}: HANA cannot ORDER BY or GROUP BY NCLOB/CLOB/BLOB columns directly.`,
4461
+ `${CLI_NAME}: Remove the LOB column from ORDER BY/GROUP BY or wrap it as TO_VARCHAR(<column>).`
4462
+ ];
4463
+ process.stderr.write(`${lines.join("\n")}
4464
+ `);
4465
+ }
4466
+ function isInsufficientPrivilegeError(error) {
4467
+ return databaseCode(error) === 258 || /\binsufficient privilege\b/i.test(errorMessage(error));
4468
+ }
4469
+ function printInsufficientPrivilegeHint(client, schema) {
4470
+ const binding = client.info.bindingName ?? (client.info.bindingIndex === void 0 ? "unknown" : `#${String(client.info.bindingIndex)}`);
4471
+ const otherBindings = (client.info.availableBindingNames ?? []).filter(
4472
+ (name) => name !== client.info.bindingName
4473
+ );
4474
+ const retryBinding = otherBindings[0];
4475
+ const lines = [
4476
+ `${CLI_NAME}: insufficient privilege for schema ${schema} as database user ${client.databaseUser || "unknown"} (current binding: ${binding}).`
4477
+ ];
4478
+ if (retryBinding !== void 0) {
4479
+ lines.push(
4480
+ `${CLI_NAME}: other HANA bindings on this app: ${otherBindings.join(", ")}; retry with --binding ${retryBinding}.`
4217
4481
  );
4218
4482
  }
4219
- /** Return the HANA execution plan for a statement. */
4220
- async explain(sql, params) {
4221
- return await this.pool.withConnection(async (connection) => {
4222
- connection.assertAllowed(sql);
4223
- const statementName = nextExplainStatementName();
4224
- await connection.executeInternal(
4225
- `EXPLAIN PLAN SET STATEMENT_NAME = '${statementName}' FOR ${sql}`,
4226
- params
4227
- );
4228
- const cleanup = async () => {
4229
- await connection.executeInternal(
4230
- "DELETE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ?",
4231
- [statementName]
4232
- );
4233
- };
4234
- let queryCompleted = false;
4235
- try {
4236
- const result = await connection.query(
4237
- "SELECT OPERATOR_NAME, TABLE_NAME, TABLE_TYPE, EXECUTION_ENGINE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ? ORDER BY OPERATOR_ID",
4238
- [statementName]
4239
- );
4240
- queryCompleted = true;
4241
- await cleanup();
4242
- return result;
4243
- } catch (error) {
4244
- if (!queryCompleted) {
4245
- try {
4246
- await cleanup();
4247
- } catch {
4248
- }
4249
- }
4250
- throw error;
4251
- }
4252
- });
4483
+ lines.push(
4484
+ `${CLI_NAME}: try another app or full selector whose binding has the grant; no automatic retry was attempted.`
4485
+ );
4486
+ process.stderr.write(`${lines.join("\n")}
4487
+ `);
4488
+ }
4489
+ function rethrowWithPrivilegeHint(error, client, schema) {
4490
+ if (isInsufficientPrivilegeError(error)) {
4491
+ printInsufficientPrivilegeHint(client, schema);
4253
4492
  }
4254
- /** Close every pooled connection. The client must not be used afterwards. */
4255
- async close() {
4256
- await this.pool.drain();
4493
+ throw error;
4494
+ }
4495
+ async function printColumnSuggestions(error, client, sql) {
4496
+ if (databaseCode(error) !== 260) {
4497
+ return;
4257
4498
  }
4258
- async runQuery(sql, params, options) {
4259
- return await this.pool.withConnection(
4260
- (connection) => connection.query(sql, params, options)
4261
- );
4499
+ const columnName = extractInvalidColumnNameFromError(error);
4500
+ const tableName = extractMissingObjectName(sql);
4501
+ if (columnName === void 0 || tableName === void 0) {
4502
+ return;
4262
4503
  }
4263
- async runExecute(sql, params, options) {
4264
- return await this.pool.withConnection(
4265
- (connection) => connection.execute(sql, params, options)
4504
+ try {
4505
+ const columns = await client.listColumns(
4506
+ tableName.schema ?? client.info.schema,
4507
+ tableName.name
4266
4508
  );
4509
+ const text = formatColumnSuggestions(rankNameSuggestions(columnName, columns));
4510
+ if (text !== void 0) {
4511
+ process.stderr.write(`${text}
4512
+ `);
4513
+ }
4514
+ } catch {
4267
4515
  }
4268
- async recordSqlHistory(operation, sql, params, result) {
4269
- try {
4270
- await appendSqlHistory({
4271
- version: CLI_VERSION,
4272
- operation,
4273
- selector: this.info.selector,
4274
- appName: this.info.appName,
4275
- schema: this.info.schema,
4276
- role: this.info.role,
4277
- statement: result.statement,
4278
- sql,
4279
- paramCount: params.length,
4280
- rowCount: result.rowCount,
4281
- truncated: result.truncated,
4282
- elapsedMs: result.elapsedMs
4283
- });
4284
- } catch {
4516
+ }
4517
+ async function printCatalogObjectSuggestions(error, client, sql, refresh) {
4518
+ if (!isInvalidCatalogObjectError(error)) {
4519
+ return;
4520
+ }
4521
+ const requested = extractMissingObjectNameFromError(error) ?? extractMissingObjectName(sql);
4522
+ if (requested === void 0) {
4523
+ return;
4524
+ }
4525
+ try {
4526
+ const objects = await loadSuggestionCatalogObjects(client, refresh);
4527
+ const text = formatSuggestions(rankCatalogSuggestions(requested, objects));
4528
+ if (text !== void 0) {
4529
+ process.stderr.write(`${text}
4530
+ `);
4285
4531
  }
4532
+ } catch {
4286
4533
  }
4287
- };
4288
-
4289
- // src/api.ts
4290
- async function connect(selector, options) {
4291
- return await HanaClient.connect(selector, options);
4534
+ }
4535
+ async function enrichAndRethrowQueryError(error, client, sql, refresh) {
4536
+ if (isInsufficientPrivilegeError(error)) {
4537
+ const schema = extractMissingObjectName(sql)?.schema ?? client.info.schema;
4538
+ printInsufficientPrivilegeHint(client, schema);
4539
+ throw error;
4540
+ }
4541
+ if (isLobSortOrGroupError(error)) {
4542
+ printLobSortOrGroupHint();
4543
+ throw error;
4544
+ }
4545
+ await printColumnSuggestions(error, client, sql);
4546
+ await printCatalogObjectSuggestions(error, client, sql, refresh);
4547
+ throw error;
4292
4548
  }
4293
4549
 
4294
4550
  // src/cli-results.ts
@@ -4564,7 +4820,7 @@ function searchResultSession(session, searchTerm, options) {
4564
4820
 
4565
4821
  // src/result-store.ts
4566
4822
  import { randomBytes } from "crypto";
4567
- import { mkdir as mkdir5, readFile as readFile3, readdir as readdir3, rename as rename3, rm as rm6, writeFile as writeFile4 } from "fs/promises";
4823
+ import { mkdir as mkdir5, readFile as readFile4, readdir as readdir3, rename as rename3, rm as rm6, writeFile as writeFile4 } from "fs/promises";
4568
4824
  import { homedir as homedir5 } from "os";
4569
4825
  import { join as join7 } from "path";
4570
4826
  var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
@@ -4687,7 +4943,7 @@ function isStoredSession(value) {
4687
4943
  }
4688
4944
  async function readStoredSession(path) {
4689
4945
  try {
4690
- const parsed = JSON.parse(await readFile3(path, "utf8"));
4946
+ const parsed = JSON.parse(await readFile4(path, "utf8"));
4691
4947
  return isStoredSession(parsed) ? parsed : void 0;
4692
4948
  } catch (error) {
4693
4949
  if (error.code === "ENOENT") {