@saptools/cf-hana 0.3.5 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,6 +5,44 @@ import { mkdir, writeFile } from "fs/promises";
5
5
  import { homedir } from "os";
6
6
  import { join } from "path";
7
7
 
8
+ // src/config.ts
9
+ var CLI_VERSION = "0.4.0";
10
+ var ENV_PREFIX = "CF_HANA";
11
+ var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
12
+ var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
13
+ var DEFAULT_POOL_MAX = 4;
14
+ var DEFAULT_POOL_IDLE_MS = 6e4;
15
+ var DEFAULT_AUTO_LIMIT = 100;
16
+ var DEFAULT_MAX_RESULT_STORE_BYTES = 256 * 1024 * 1024;
17
+ var MAX_RESULT_STORE_BYTES = resolveMaxResultStoreBytes();
18
+ function resolveMaxResultStoreBytes() {
19
+ if (readEnv(envName("DRIVER")) !== "fake") {
20
+ return DEFAULT_MAX_RESULT_STORE_BYTES;
21
+ }
22
+ const raw = readEnv(envName("FAKE_MAX_STORE_BYTES"));
23
+ const parsed = raw === void 0 ? Number.NaN : Number(raw);
24
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_RESULT_STORE_BYTES;
25
+ }
26
+ function envName(suffix) {
27
+ return `${ENV_PREFIX}_${suffix}`;
28
+ }
29
+ function readEnv(name) {
30
+ const value = process.env[name];
31
+ if (value === void 0) {
32
+ return void 0;
33
+ }
34
+ const trimmed = value.trim();
35
+ return trimmed.length === 0 ? void 0 : trimmed;
36
+ }
37
+ function readSapCredentials(overrides) {
38
+ const email = overrides?.email ?? readEnv("SAP_EMAIL");
39
+ const password = overrides?.password ?? readEnv("SAP_PASSWORD");
40
+ if (email === void 0 || password === void 0) {
41
+ return void 0;
42
+ }
43
+ return { email, password };
44
+ }
45
+
8
46
  // src/errors.ts
9
47
  var CfHanaError = class extends Error {
10
48
  code;
@@ -31,6 +69,12 @@ var QueryError = class extends CfHanaError {
31
69
  this.databaseCode = options?.databaseCode;
32
70
  }
33
71
  };
72
+ var BackupRequiredError = class extends CfHanaError {
73
+ constructor(message, options) {
74
+ super("BACKUP_REQUIRED", message, options);
75
+ this.name = "BackupRequiredError";
76
+ }
77
+ };
34
78
  function isDatabaseErrorShape(error) {
35
79
  return typeof error === "object" && error !== null && "code" in error;
36
80
  }
@@ -146,6 +190,16 @@ function formatJson(result) {
146
190
  });
147
191
  return JSON.stringify(rows, null, 2);
148
192
  }
193
+ function formatJsonCompact(result, preferredColumn) {
194
+ const singleColumn = result.columns.length === 1 ? result.columns[0]?.name : void 0;
195
+ const columnName = preferredColumn ?? singleColumn;
196
+ if (columnName === void 0) {
197
+ return formatJson(result);
198
+ }
199
+ const column = result.columns.find((item) => item.name === columnName);
200
+ const values = result.rows.map((row) => serializeCell(row[columnName] ?? null, column));
201
+ return JSON.stringify(values, null, 2);
202
+ }
149
203
  function formatCsv(result) {
150
204
  const headers = result.columns.map((column) => column.name);
151
205
  const lines = [headers.map((header) => csvEscape(header)).join(",")];
@@ -156,26 +210,252 @@ function formatCsv(result) {
156
210
  }
157
211
  return lines.join("\r\n");
158
212
  }
159
- function formatResult(result, format) {
213
+ function formatResult(result, format, compactColumn) {
160
214
  switch (format) {
161
215
  case "table":
162
216
  return formatTable(result);
163
217
  case "json":
164
218
  return formatJson(result);
219
+ case "json-compact":
220
+ return formatJsonCompact(result, compactColumn);
165
221
  case "csv":
166
222
  return formatCsv(result);
167
223
  }
168
224
  }
169
225
 
226
+ // src/003-backup-sql-parser.ts
227
+ function isIdentifierChar(char) {
228
+ return char !== void 0 && /[A-Za-z0-9_$#]/.test(char);
229
+ }
230
+ function skipQuotedText(sql, index) {
231
+ const quote = sql[index];
232
+ let cursor = index + 1;
233
+ while (cursor < sql.length) {
234
+ if (sql[cursor] === quote) {
235
+ if (sql[cursor + 1] === quote) {
236
+ cursor += 2;
237
+ continue;
238
+ }
239
+ return cursor + 1;
240
+ }
241
+ cursor += 1;
242
+ }
243
+ return cursor;
244
+ }
245
+ function skipLineComment(sql, index) {
246
+ let cursor = index + 2;
247
+ while (cursor < sql.length && sql[cursor] !== "\n") {
248
+ cursor += 1;
249
+ }
250
+ return cursor;
251
+ }
252
+ function skipBlockComment(sql, index) {
253
+ let cursor = index + 2;
254
+ while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
255
+ cursor += 1;
256
+ }
257
+ return Math.min(cursor + 2, sql.length);
258
+ }
259
+ function skipNonCode(sql, index) {
260
+ const char = sql[index];
261
+ if (char === "'" || char === '"') {
262
+ return skipQuotedText(sql, index);
263
+ }
264
+ if (char === "-" && sql[index + 1] === "-") {
265
+ return skipLineComment(sql, index);
266
+ }
267
+ if (char === "/" && sql[index + 1] === "*") {
268
+ return skipBlockComment(sql, index);
269
+ }
270
+ return void 0;
271
+ }
272
+ function skipTrivia(sql, start, end) {
273
+ let index = start;
274
+ while (index < end) {
275
+ if (/\s/.test(sql.charAt(index))) {
276
+ index += 1;
277
+ continue;
278
+ }
279
+ if (sql[index] === "-" && sql[index + 1] === "-") {
280
+ index = skipLineComment(sql, index);
281
+ continue;
282
+ }
283
+ if (sql[index] === "/" && sql[index + 1] === "*") {
284
+ index = skipBlockComment(sql, index);
285
+ continue;
286
+ }
287
+ break;
288
+ }
289
+ return index;
290
+ }
291
+ function readIdentifierPart(sql, start, end) {
292
+ const index = skipTrivia(sql, start, end);
293
+ if (sql[index] === '"') {
294
+ const next2 = skipQuotedText(sql, index);
295
+ return next2 <= end && sql[next2 - 1] === '"' ? { sql: sql.slice(index, next2), end: next2 } : void 0;
296
+ }
297
+ if (!/[A-Za-z_#$]/.test(sql.charAt(index))) {
298
+ return void 0;
299
+ }
300
+ let next = index + 1;
301
+ while (next < end && /[A-Za-z0-9_#$]/.test(sql.charAt(next))) {
302
+ next += 1;
303
+ }
304
+ return { sql: sql.slice(index, next), end: next };
305
+ }
306
+ function readQualifiedTarget(sql, start, end) {
307
+ const first = readIdentifierPart(sql, start, end);
308
+ if (first === void 0) {
309
+ return void 0;
310
+ }
311
+ const parts = [first.sql];
312
+ let cursor = first.end;
313
+ while (parts.length < 3) {
314
+ const dot = skipTrivia(sql, cursor, end);
315
+ if (sql[dot] !== ".") {
316
+ break;
317
+ }
318
+ const part = readIdentifierPart(sql, dot + 1, end);
319
+ if (part === void 0) {
320
+ return void 0;
321
+ }
322
+ parts.push(part.sql);
323
+ cursor = part.end;
324
+ }
325
+ return { sql: parts.join("."), reference: parts.at(-1) ?? first.sql, end: cursor };
326
+ }
327
+ function topLevelTokens(sql) {
328
+ const tokens = [];
329
+ let index = 0;
330
+ let depth = 0;
331
+ while (index < sql.length) {
332
+ const skipped = skipNonCode(sql, index);
333
+ if (skipped !== void 0) {
334
+ index = skipped;
335
+ continue;
336
+ }
337
+ const char = sql.charAt(index);
338
+ if (char === "(") {
339
+ depth += 1;
340
+ index += 1;
341
+ continue;
342
+ }
343
+ if (char === ")") {
344
+ depth = Math.max(0, depth - 1);
345
+ index += 1;
346
+ continue;
347
+ }
348
+ if (!/[A-Za-z_#$]/.test(char)) {
349
+ index += 1;
350
+ continue;
351
+ }
352
+ let end = index + 1;
353
+ while (end < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(end))) {
354
+ end += 1;
355
+ }
356
+ if (depth === 0) {
357
+ tokens.push({ keyword: sql.slice(index, end).toUpperCase(), start: index, end });
358
+ }
359
+ index = end;
360
+ }
361
+ return tokens;
362
+ }
363
+ function findToken(tokens, keyword, start = 0) {
364
+ for (let index = start; index < tokens.length; index += 1) {
365
+ if (tokens[index]?.keyword === keyword) {
366
+ return index;
367
+ }
368
+ }
369
+ return void 0;
370
+ }
371
+ function keywordMatches(sql, index, keyword) {
372
+ const end = index + keyword.length;
373
+ return sql.slice(index, end).toUpperCase() === keyword && !isIdentifierChar(sql[index - 1]) && !isIdentifierChar(sql[end]);
374
+ }
375
+ function findTopLevelKeyword(sql, keyword, startIndex = 0) {
376
+ let index = startIndex;
377
+ let depth = 0;
378
+ while (index < sql.length) {
379
+ const skipped = skipNonCode(sql, index);
380
+ if (skipped !== void 0) {
381
+ index = skipped;
382
+ continue;
383
+ }
384
+ const char = sql[index];
385
+ if (char === "(") {
386
+ depth += 1;
387
+ } else if (char === ")" && depth > 0) {
388
+ depth -= 1;
389
+ } else if (depth === 0 && keywordMatches(sql, index, keyword)) {
390
+ return index;
391
+ }
392
+ index += 1;
393
+ }
394
+ return void 0;
395
+ }
396
+ function findTopLevelChar(sql, target, startIndex, endIndex) {
397
+ let index = startIndex;
398
+ let depth = 0;
399
+ while (index < endIndex) {
400
+ const skipped = skipNonCode(sql, index);
401
+ if (skipped !== void 0) {
402
+ index = skipped;
403
+ continue;
404
+ }
405
+ const char = sql[index];
406
+ if (char === target && depth === 0) {
407
+ return index;
408
+ }
409
+ if (char === "(") {
410
+ depth += 1;
411
+ } else if (char === ")" && depth > 0) {
412
+ depth -= 1;
413
+ }
414
+ index += 1;
415
+ }
416
+ return void 0;
417
+ }
418
+
170
419
  // src/statements.ts
171
- var LEADING_NOISE = /^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)+/;
172
420
  var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH"]);
173
421
  var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
174
422
  var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
175
423
  function firstKeyword(sql) {
176
- const stripped = sql.replace(LEADING_NOISE, "");
177
- const match = /^[A-Za-z]+/.exec(stripped);
178
- return (match?.[0] ?? "").toUpperCase();
424
+ let index = 0;
425
+ while (index < sql.length) {
426
+ const char = sql.charAt(index);
427
+ if (char.trim().length === 0) {
428
+ index += 1;
429
+ continue;
430
+ }
431
+ if (char === "-" && sql[index + 1] === "-") {
432
+ index += 2;
433
+ while (index < sql.length && sql[index] !== "\n") {
434
+ index += 1;
435
+ }
436
+ continue;
437
+ }
438
+ if (char === "/" && sql[index + 1] === "*") {
439
+ const commentEnd = sql.indexOf("*/", index + 2);
440
+ if (commentEnd === -1) {
441
+ return "";
442
+ }
443
+ index = commentEnd + 2;
444
+ continue;
445
+ }
446
+ break;
447
+ }
448
+ let keywordEnd = index;
449
+ while (keywordEnd < sql.length) {
450
+ const code = sql.charCodeAt(keywordEnd);
451
+ const isUpperCase = code >= 65 && code <= 90;
452
+ const isLowerCase = code >= 97 && code <= 122;
453
+ if (!isUpperCase && !isLowerCase) {
454
+ break;
455
+ }
456
+ keywordEnd += 1;
457
+ }
458
+ return sql.slice(index, keywordEnd).toUpperCase();
179
459
  }
180
460
  function classifyStatement(sql) {
181
461
  const keyword = firstKeyword(sql);
@@ -255,162 +535,247 @@ function assertParamArity(sql, params) {
255
535
  }
256
536
  }
257
537
 
258
- // src/backup.ts
259
- var SAPTOOLS_DIR_NAME = ".saptools";
260
- var CF_HANA_DIR_NAME = "cf-hana";
261
- var BACKUPS_DIR_NAME = "backups";
262
- function defaultSaptoolsRoot() {
263
- return join(homedir(), SAPTOOLS_DIR_NAME);
264
- }
538
+ // src/001-backup-planner.ts
539
+ var WRITE_KEYWORDS = /* @__PURE__ */ new Set([
540
+ "UPDATE",
541
+ "UPSERT",
542
+ "REPLACE",
543
+ "MERGE",
544
+ "DELETE"
545
+ ]);
546
+ var MERGE_MODIFY_ACTIONS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
265
547
  function trimStatementSql(sql) {
266
- return sql.trim().replace(/;+\s*$/, "").trim();
267
- }
268
- function isIdentifierChar(char) {
269
- return char !== void 0 && /[A-Za-z0-9_$#]/.test(char);
270
- }
271
- function skipQuotedText(sql, index) {
272
- const quote = sql[index];
273
- let cursor = index + 1;
274
- while (cursor < sql.length) {
275
- if (sql[cursor] === quote) {
276
- if (sql[cursor + 1] === quote) {
277
- cursor += 2;
278
- continue;
279
- }
280
- return cursor + 1;
281
- }
282
- cursor += 1;
548
+ const trimmed = sql.trim();
549
+ let end = trimmed.length;
550
+ while (end > 0 && trimmed.charAt(end - 1) === ";") {
551
+ end -= 1;
283
552
  }
284
- return cursor;
553
+ return trimmed.slice(0, end).trim();
285
554
  }
286
- function skipLineComment(sql, index) {
287
- let cursor = index + 2;
288
- while (cursor < sql.length && sql[cursor] !== "\n") {
289
- cursor += 1;
555
+ function isMergeClauseStart(tokens, index) {
556
+ if (tokens[index]?.keyword !== "WHEN") {
557
+ return false;
290
558
  }
291
- return cursor;
559
+ return tokens[index + 1]?.keyword === "MATCHED" || tokens[index + 1]?.keyword === "NOT" && tokens[index + 2]?.keyword === "MATCHED";
292
560
  }
293
- function skipBlockComment(sql, index) {
294
- let cursor = index + 2;
295
- while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
296
- cursor += 1;
561
+ function mergeClauseIndexes(tokens, start) {
562
+ const indexes = [];
563
+ for (let index = start; index < tokens.length; index += 1) {
564
+ if (isMergeClauseStart(tokens, index)) {
565
+ indexes.push(index);
566
+ }
297
567
  }
298
- return Math.min(cursor + 2, sql.length);
568
+ return indexes;
299
569
  }
300
- function skipNonCode(sql, index) {
301
- const char = sql[index];
302
- if (char === "'" || char === '"') {
303
- return skipQuotedText(sql, index);
570
+ function selectParamsAfterWhere(statementSql, whereIndex, params) {
571
+ return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
572
+ }
573
+ function buildSelectPlan(operation, statementSql, targetSql, whereIndex, params) {
574
+ const target = targetSql.trim();
575
+ if (target.length === 0) {
576
+ throw new QueryError(`${operation.toUpperCase()} backup requires a target table`);
304
577
  }
305
- if (char === "-" && sql[index + 1] === "-") {
306
- return skipLineComment(sql, index);
578
+ if (whereIndex === void 0) {
579
+ return { operation, statementSql, selectSql: `SELECT * FROM ${target}`, selectParams: [] };
307
580
  }
308
- if (char === "/" && sql[index + 1] === "*") {
309
- return skipBlockComment(sql, index);
581
+ const whereSql = statementSql.slice(whereIndex + "WHERE".length).trim();
582
+ if (whereSql.length === 0) {
583
+ throw new QueryError(`${operation.toUpperCase()} backup requires a non-empty WHERE clause`);
310
584
  }
311
- return void 0;
585
+ return {
586
+ operation,
587
+ statementSql,
588
+ selectSql: `SELECT * FROM ${target} WHERE ${whereSql}`,
589
+ selectParams: selectParamsAfterWhere(statementSql, whereIndex, params)
590
+ };
312
591
  }
313
- function keywordMatches(sql, index, keyword) {
314
- const end = index + keyword.length;
315
- return sql.slice(index, end).toUpperCase() === keyword && !isIdentifierChar(sql[index - 1]) && !isIdentifierChar(sql[end]);
592
+ function buildUpdateBackupPlan(statementSql, params) {
593
+ const updateIndex = findTopLevelKeyword(statementSql, "UPDATE");
594
+ const setIndex = updateIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "SET", updateIndex + "UPDATE".length);
595
+ if (updateIndex === void 0 || setIndex === void 0) {
596
+ throw new QueryError("UPDATE backup requires UPDATE <target> SET syntax");
597
+ }
598
+ const whereIndex = findTopLevelKeyword(statementSql, "WHERE", setIndex + "SET".length);
599
+ return buildSelectPlan(
600
+ "update",
601
+ statementSql,
602
+ statementSql.slice(updateIndex + "UPDATE".length, setIndex),
603
+ whereIndex,
604
+ params
605
+ );
316
606
  }
317
- function findTopLevelKeyword(sql, keyword, startIndex = 0) {
318
- let index = startIndex;
319
- let depth = 0;
320
- while (index < sql.length) {
321
- const skipped = skipNonCode(sql, index);
322
- if (skipped !== void 0) {
323
- index = skipped;
324
- continue;
325
- }
326
- const char = sql[index];
327
- if (char === "(") {
328
- depth += 1;
329
- } else if (char === ")" && depth > 0) {
330
- depth -= 1;
331
- } else if (depth === 0 && keywordMatches(sql, index, keyword)) {
332
- return index;
607
+ function buildUpsertBackupPlan(statementSql, params, keyword) {
608
+ const operation = keyword === "UPSERT" ? "upsert" : "replace";
609
+ const keywordIndex = findTopLevelKeyword(statementSql, keyword);
610
+ const target = keywordIndex === void 0 ? void 0 : readQualifiedTarget(statementSql, keywordIndex + keyword.length, statementSql.length);
611
+ if (keywordIndex === void 0 || target === void 0) {
612
+ throw new BackupRequiredError(
613
+ `${keyword} write refused: cannot derive a trustworthy backup target`
614
+ );
615
+ }
616
+ const valuesIndex = findTopLevelKeyword(statementSql, "VALUES", target.end);
617
+ if (valuesIndex === void 0) {
618
+ const selectIndex = findTopLevelKeyword(statementSql, "SELECT", target.end);
619
+ const withIndex = findTopLevelKeyword(statementSql, "WITH", target.end);
620
+ if (selectIndex === void 0 && withIndex === void 0) {
621
+ throw new BackupRequiredError(
622
+ `${keyword} write refused: expected VALUES, WITH PRIMARY KEY, or a subquery`
623
+ );
333
624
  }
334
- index += 1;
625
+ return buildSelectPlan(operation, statementSql, target.sql, void 0, params);
335
626
  }
336
- return void 0;
627
+ const whereIndex = findTopLevelKeyword(statementSql, "WHERE", valuesIndex + "VALUES".length);
628
+ return buildSelectPlan(operation, statementSql, target.sql, whereIndex, params);
337
629
  }
338
- function findTopLevelChar(sql, target, startIndex, endIndex) {
339
- let index = startIndex;
340
- let depth = 0;
341
- while (index < endIndex) {
342
- const skipped = skipNonCode(sql, index);
343
- if (skipped !== void 0) {
344
- index = skipped;
345
- continue;
630
+ function mergeTargetReference(statementSql, target, usingStart) {
631
+ let suffix = statementSql.slice(target.end, usingStart).trim();
632
+ if (suffix.length === 0) {
633
+ return target.reference;
634
+ }
635
+ if (/^PARTITION\b/i.test(suffix)) {
636
+ const open = suffix.indexOf("(");
637
+ if (open === -1) {
638
+ return void 0;
346
639
  }
347
- const char = sql[index];
348
- if (char === target && depth === 0) {
349
- return index;
640
+ const close = findTopLevelChar(suffix, ")", open + 1, suffix.length);
641
+ if (close === void 0) {
642
+ return void 0;
350
643
  }
351
- if (char === "(") {
352
- depth += 1;
353
- } else if (char === ")" && depth > 0) {
354
- depth -= 1;
644
+ suffix = suffix.slice(close + 1).trim();
645
+ if (suffix.length === 0) {
646
+ return target.reference;
355
647
  }
356
- index += 1;
357
648
  }
358
- return void 0;
649
+ suffix = suffix.replace(/^AS\b/i, "").trim();
650
+ const alias = readIdentifierPart(suffix, 0, suffix.length);
651
+ if (alias === void 0 || skipTrivia(suffix, alias.end, suffix.length) !== suffix.length) {
652
+ return void 0;
653
+ }
654
+ return alias.sql;
359
655
  }
360
- function selectParamsAfterWhere(statementSql, whereIndex, params) {
361
- return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
656
+ function paramsInRange(sql, params, start, end) {
657
+ const offset = countPlaceholders(sql.slice(0, start));
658
+ const length = countPlaceholders(sql.slice(start, end));
659
+ return params.slice(offset, offset + length);
362
660
  }
363
- function buildSelectPlan(operation, statementSql, targetSql, whereIndex, params) {
364
- const target = targetSql.trim();
365
- if (target.length === 0) {
366
- throw new QueryError(`${operation.toUpperCase()} backup requires a target table`);
661
+ function wholeTargetPlan(operation, statementSql, target) {
662
+ return buildSelectPlan(operation, statementSql, target.sql, void 0, []);
663
+ }
664
+ function readRequiredMergeToken(tokens, keyword, start) {
665
+ const index = findToken(tokens, keyword, start);
666
+ const token = index === void 0 ? void 0 : tokens[index];
667
+ if (index === void 0 || token === void 0) {
668
+ throw new BackupRequiredError(
669
+ "MERGE write refused: cannot derive a trustworthy backup target"
670
+ );
367
671
  }
368
- if (whereIndex === void 0) {
369
- return { operation, statementSql, selectSql: `SELECT * FROM ${target}`, selectParams: [] };
672
+ return { index, token };
673
+ }
674
+ function parseMerge(statementSql) {
675
+ const tokens = topLevelTokens(statementSql);
676
+ const into = readRequiredMergeToken(tokens, "INTO", 1);
677
+ const using = readRequiredMergeToken(tokens, "USING", into.index + 1);
678
+ const on = readRequiredMergeToken(tokens, "ON", using.index + 1);
679
+ const target = readQualifiedTarget(statementSql, into.token.end, using.token.start);
680
+ if (target === void 0) {
681
+ throw new BackupRequiredError(
682
+ "MERGE write refused: cannot derive a trustworthy backup target"
683
+ );
370
684
  }
371
- const whereSql = statementSql.slice(whereIndex + "WHERE".length).trim();
372
- if (whereSql.length === 0) {
373
- throw new QueryError(`${operation.toUpperCase()} backup requires a non-empty WHERE clause`);
685
+ const clauseIndexes = mergeClauseIndexes(tokens, on.index + 1);
686
+ if (clauseIndexes.length === 0) {
687
+ throw new BackupRequiredError("MERGE write refused: no supported WHEN clause was found");
688
+ }
689
+ const firstClause = tokens[clauseIndexes[0] ?? 0];
690
+ return {
691
+ tokens,
692
+ into: into.token,
693
+ using: using.token,
694
+ on: on.token,
695
+ target,
696
+ clauseIndexes,
697
+ matchedIndexes: clauseIndexes.filter((index) => tokens[index + 1]?.keyword === "MATCHED"),
698
+ targetClause: statementSql.slice(into.token.end, using.token.start).trim(),
699
+ sourceClause: statementSql.slice(using.token.end, on.token.start).trim(),
700
+ onCondition: statementSql.slice(on.token.end, firstClause?.start).trim()
701
+ };
702
+ }
703
+ function readMatchedActionTokens(tokens, matchedIndex) {
704
+ const thenIndex = findToken(tokens, "THEN", matchedIndex + 2);
705
+ if (thenIndex === void 0) {
706
+ return void 0;
707
+ }
708
+ const thenToken = tokens[thenIndex];
709
+ const actionToken = tokens[thenIndex + 1];
710
+ const matchedToken = tokens[matchedIndex + 1];
711
+ if (thenToken === void 0 || actionToken === void 0 || matchedToken === void 0) {
712
+ return void 0;
713
+ }
714
+ if (!MERGE_MODIFY_ACTIONS.has(actionToken.keyword)) {
715
+ return void 0;
716
+ }
717
+ const clauseToken = tokens[matchedIndex];
718
+ return {
719
+ thenToken,
720
+ conditionToken: tokens[matchedIndex + 2],
721
+ clauseStart: clauseToken === void 0 ? 0 : clauseToken.start
722
+ };
723
+ }
724
+ function parseMatchedClause(statementSql, params, tokens, matchedIndex) {
725
+ const action = readMatchedActionTokens(tokens, matchedIndex);
726
+ if (action?.conditionToken === void 0) {
727
+ return void 0;
728
+ }
729
+ if (action.conditionToken.keyword === "THEN") {
730
+ return { clauseStart: action.clauseStart, matchedCondition: "", conditionParams: [] };
731
+ }
732
+ if (action.conditionToken.keyword !== "AND") {
733
+ return void 0;
374
734
  }
375
735
  return {
376
- operation,
377
- statementSql,
378
- selectSql: `SELECT * FROM ${target} WHERE ${whereSql}`,
379
- selectParams: selectParamsAfterWhere(statementSql, whereIndex, params)
736
+ clauseStart: action.clauseStart,
737
+ matchedCondition: statementSql.slice(action.conditionToken.end, action.thenToken.start).trim(),
738
+ conditionParams: paramsInRange(
739
+ statementSql,
740
+ params,
741
+ action.conditionToken.end,
742
+ action.thenToken.start
743
+ )
380
744
  };
381
745
  }
382
- function buildUpdateBackupPlan(statementSql, params) {
383
- const updateIndex = findTopLevelKeyword(statementSql, "UPDATE");
384
- const setIndex = updateIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "SET", updateIndex + "UPDATE".length);
385
- if (updateIndex === void 0 || setIndex === void 0) {
386
- throw new QueryError("UPDATE backup requires UPDATE <target> SET syntax");
746
+ function buildExactMergePlan(statementSql, params, merge, matchedIndex) {
747
+ const targetReference = mergeTargetReference(statementSql, merge.target, merge.using.start);
748
+ const matchedClause = parseMatchedClause(statementSql, params, merge.tokens, matchedIndex);
749
+ if (targetReference === void 0 || matchedClause === void 0) {
750
+ return void 0;
387
751
  }
388
- const whereIndex = findTopLevelKeyword(statementSql, "WHERE", setIndex + "SET".length);
389
- return buildSelectPlan(
390
- "update",
752
+ const sourceParams = paramsInRange(
391
753
  statementSql,
392
- statementSql.slice(updateIndex + "UPDATE".length, setIndex),
393
- whereIndex,
394
- params
754
+ params,
755
+ merge.using.end,
756
+ matchedClause.clauseStart
395
757
  );
758
+ const conditionSql = matchedClause.matchedCondition.length === 0 ? "" : ` AND (${matchedClause.matchedCondition})`;
759
+ return {
760
+ operation: "merge",
761
+ statementSql,
762
+ selectSql: `SELECT ${targetReference}.* FROM ${merge.targetClause} WHERE EXISTS (SELECT 1 FROM ${merge.sourceClause} WHERE (${merge.onCondition})${conditionSql})`,
763
+ selectParams: [...sourceParams, ...matchedClause.conditionParams]
764
+ };
396
765
  }
397
- function buildUpsertBackupPlan(statementSql, params) {
398
- const upsertIndex = findTopLevelKeyword(statementSql, "UPSERT");
399
- const valuesIndex = upsertIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "VALUES", upsertIndex + "UPSERT".length);
400
- if (upsertIndex === void 0 || valuesIndex === void 0) {
401
- throw new QueryError("UPSERT backup requires UPSERT <target> VALUES syntax");
766
+ function buildMergeBackupPlan(statementSql, params) {
767
+ const merge = parseMerge(statementSql);
768
+ if (merge.matchedIndexes.length === 0) {
769
+ return void 0;
402
770
  }
403
- const targetStart = upsertIndex + "UPSERT".length;
404
- const columnListIndex = findTopLevelChar(statementSql, "(", targetStart, valuesIndex);
405
- const targetEnd = columnListIndex ?? valuesIndex;
406
- const whereIndex = findTopLevelKeyword(statementSql, "WHERE", valuesIndex + "VALUES".length);
407
- return buildSelectPlan(
408
- "upsert",
409
- statementSql,
410
- statementSql.slice(targetStart, targetEnd),
411
- whereIndex,
412
- params
413
- );
771
+ if (merge.matchedIndexes.length > 1) {
772
+ return wholeTargetPlan("merge", statementSql, merge.target);
773
+ }
774
+ if (merge.sourceClause.length === 0 || merge.onCondition.length === 0) {
775
+ return wholeTargetPlan("merge", statementSql, merge.target);
776
+ }
777
+ const matchedIndex = merge.matchedIndexes[0] ?? 0;
778
+ return buildExactMergePlan(statementSql, params, merge, matchedIndex) ?? wholeTargetPlan("merge", statementSql, merge.target);
414
779
  }
415
780
  function buildDeleteBackupPlan(statementSql, params) {
416
781
  const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
@@ -428,6 +793,49 @@ function buildDeleteBackupPlan(statementSql, params) {
428
793
  params
429
794
  );
430
795
  }
796
+ function isWriteKeyword(keyword) {
797
+ return WRITE_KEYWORDS.has(keyword);
798
+ }
799
+ function buildMergeWriteBackupPlan(statementSql, params) {
800
+ const secondKeyword = topLevelTokens(statementSql)[1]?.keyword;
801
+ if (secondKeyword === "DELTA") {
802
+ return void 0;
803
+ }
804
+ if (secondKeyword !== "INTO") {
805
+ throw new BackupRequiredError("MERGE write refused: expected MERGE INTO syntax");
806
+ }
807
+ return buildMergeBackupPlan(statementSql, params);
808
+ }
809
+ function dispatchWriteBackupPlan(keyword, statementSql, params) {
810
+ switch (keyword) {
811
+ case "UPDATE":
812
+ return buildUpdateBackupPlan(statementSql, params);
813
+ case "UPSERT":
814
+ case "REPLACE":
815
+ return buildUpsertBackupPlan(statementSql, params, keyword);
816
+ case "MERGE":
817
+ return buildMergeWriteBackupPlan(statementSql, params);
818
+ case "DELETE":
819
+ return buildDeleteBackupPlan(statementSql, params);
820
+ }
821
+ }
822
+ function buildWriteBackupPlan(sql, params = []) {
823
+ const statementSql = trimStatementSql(sql);
824
+ const keyword = firstKeyword(statementSql);
825
+ if (!isWriteKeyword(keyword)) {
826
+ return void 0;
827
+ }
828
+ assertParamArity(statementSql, params);
829
+ return dispatchWriteBackupPlan(keyword, statementSql, params);
830
+ }
831
+
832
+ // src/backup.ts
833
+ var SAPTOOLS_DIR_NAME = ".saptools";
834
+ var CF_HANA_DIR_NAME = "cf-hana";
835
+ var BACKUPS_DIR_NAME = "backups";
836
+ function defaultSaptoolsRoot() {
837
+ return join(homedir(), SAPTOOLS_DIR_NAME);
838
+ }
431
839
  function backupTimestamp(now) {
432
840
  return now.toISOString().replace(/:/g, "").replace(".", "");
433
841
  }
@@ -447,21 +855,6 @@ function backupBaseName(input, now) {
447
855
  function cfHanaBackupRoot(saptoolsRoot) {
448
856
  return join(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
449
857
  }
450
- function buildWriteBackupPlan(sql, params = []) {
451
- const statementSql = trimStatementSql(sql);
452
- const keyword = firstKeyword(statementSql);
453
- if (keyword !== "UPDATE" && keyword !== "UPSERT" && keyword !== "DELETE") {
454
- return void 0;
455
- }
456
- assertParamArity(statementSql, params);
457
- if (keyword === "UPDATE") {
458
- return buildUpdateBackupPlan(statementSql, params);
459
- }
460
- if (keyword === "UPSERT") {
461
- return buildUpsertBackupPlan(statementSql, params);
462
- }
463
- return buildDeleteBackupPlan(statementSql, params);
464
- }
465
858
  async function writeSqlBackup(input, options = {}) {
466
859
  const now = options.now ?? /* @__PURE__ */ new Date();
467
860
  const directory = join(cfHanaBackupRoot(options.saptoolsRoot), backupMonth(now));
@@ -477,11 +870,17 @@ async function writeSqlBackup(input, options = {}) {
477
870
  rowCount: input.result.rowCount,
478
871
  createdAt: now.toISOString()
479
872
  };
873
+ const csv = formatCsv(input.result);
874
+ if (Buffer.byteLength(csv) > (options.maxBytes ?? MAX_RESULT_STORE_BYTES)) {
875
+ throw new BackupRequiredError(
876
+ "Write backup exceeds the storage limit; the write was refused"
877
+ );
878
+ }
480
879
  await mkdir(directory, { recursive: true, mode: 448 });
481
880
  await Promise.all([
482
881
  writeFile(statementPath, `${input.statementSql}
483
882
  `, { encoding: "utf8", mode: 384 }),
484
- writeFile(backupPath, formatCsv(input.result), { encoding: "utf8", mode: 384 }),
883
+ writeFile(backupPath, csv, { encoding: "utf8", mode: 384 }),
485
884
  writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}
486
885
  `, {
487
886
  encoding: "utf8",
@@ -624,35 +1023,6 @@ async function listColumns(connection, schema, table) {
624
1023
  }));
625
1024
  }
626
1025
 
627
- // src/config.ts
628
- var CLI_VERSION = "0.3.5";
629
- var ENV_PREFIX = "CF_HANA";
630
- var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
631
- var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
632
- var DEFAULT_POOL_MAX = 4;
633
- var DEFAULT_POOL_IDLE_MS = 6e4;
634
- var DEFAULT_AUTO_LIMIT = 100;
635
- var MAX_RESULT_STORE_BYTES = 256 * 1024 * 1024;
636
- function envName(suffix) {
637
- return `${ENV_PREFIX}_${suffix}`;
638
- }
639
- function readEnv(name) {
640
- const value = process.env[name];
641
- if (value === void 0) {
642
- return void 0;
643
- }
644
- const trimmed = value.trim();
645
- return trimmed.length === 0 ? void 0 : trimmed;
646
- }
647
- function readSapCredentials(overrides) {
648
- const email = overrides?.email ?? readEnv("SAP_EMAIL");
649
- const password = overrides?.password ?? readEnv("SAP_PASSWORD");
650
- if (email === void 0 || password === void 0) {
651
- return void 0;
652
- }
653
- return { email, password };
654
- }
655
-
656
1026
  // src/cf.ts
657
1027
  import { execFile } from "child_process";
658
1028
  import { mkdtemp, rm } from "fs/promises";
@@ -735,7 +1105,7 @@ function normalizeSapCfApiEndpoint(apiEndpoint) {
735
1105
  if (/^https:\/\/[^/]*:\d+(?:[/?#]|$)/i.test(trimmed)) {
736
1106
  throw new Error(`Invalid or untrusted CF API endpoint "${apiEndpoint}".`);
737
1107
  }
738
- if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "" || parsed.port !== "" || parsed.pathname.replace(/\/+$/, "") !== "" || parsed.search !== "" || parsed.hash !== "") {
1108
+ if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "" || parsed.port !== "" || parsed.pathname.replace(/(?<!\/)\/+$/, "") !== "" || parsed.search !== "" || parsed.hash !== "") {
739
1109
  throw new Error(`Invalid or untrusted CF API endpoint "${apiEndpoint}".`);
740
1110
  }
741
1111
  const hostname = parsed.hostname.toLowerCase();
@@ -904,6 +1274,81 @@ function formatCurrentCfAppSelector(target, appName) {
904
1274
  const region = target.regionKey ?? "current";
905
1275
  return `${region}/${target.orgName}/${target.spaceName}/${name}`;
906
1276
  }
1277
+ function findJsonObjectEnd(source, startIndex) {
1278
+ let depth = 0;
1279
+ let inString = false;
1280
+ let escaped = false;
1281
+ for (let index = startIndex; index < source.length; index++) {
1282
+ const character = source[index];
1283
+ if (escaped) {
1284
+ escaped = false;
1285
+ continue;
1286
+ }
1287
+ if (inString && character === "\\") {
1288
+ escaped = true;
1289
+ continue;
1290
+ }
1291
+ if (character === '"') {
1292
+ inString = !inString;
1293
+ continue;
1294
+ }
1295
+ if (inString) {
1296
+ continue;
1297
+ }
1298
+ if (character === "{") {
1299
+ depth++;
1300
+ continue;
1301
+ }
1302
+ if (character === "}" && --depth === 0) {
1303
+ return index;
1304
+ }
1305
+ }
1306
+ return -1;
1307
+ }
1308
+ function extractVcapApplicationJson(stdout) {
1309
+ const marker = "VCAP_APPLICATION:";
1310
+ const markerIndex = stdout.indexOf(marker);
1311
+ if (markerIndex === -1) {
1312
+ throw new Error("VCAP_APPLICATION section not found in cf env output");
1313
+ }
1314
+ const afterMarker = stdout.slice(markerIndex + marker.length);
1315
+ const openIndex = afterMarker.indexOf("{");
1316
+ if (openIndex === -1) {
1317
+ throw new Error("VCAP_APPLICATION JSON payload not found in cf env output");
1318
+ }
1319
+ const closeIndex = findJsonObjectEnd(afterMarker, openIndex);
1320
+ if (closeIndex === -1) {
1321
+ throw new Error("Malformed VCAP_APPLICATION JSON in cf env output");
1322
+ }
1323
+ return afterMarker.slice(openIndex, closeIndex + 1);
1324
+ }
1325
+ function requireApplicationField(application, field) {
1326
+ const value = application[field];
1327
+ if (typeof value !== "string" || value.trim().length === 0) {
1328
+ throw new Error(`VCAP_APPLICATION.${field} must be a non-empty string`);
1329
+ }
1330
+ return value.trim();
1331
+ }
1332
+ function extractCfEnvApplicationIdentity(stdout) {
1333
+ let application;
1334
+ try {
1335
+ application = JSON.parse(extractVcapApplicationJson(stdout));
1336
+ } catch (error) {
1337
+ if (error instanceof SyntaxError) {
1338
+ throw new Error("VCAP_APPLICATION is not valid JSON", { cause: error });
1339
+ }
1340
+ throw error;
1341
+ }
1342
+ if (!isRecord(application)) {
1343
+ throw new Error("VCAP_APPLICATION must be an object");
1344
+ }
1345
+ return {
1346
+ appName: requireApplicationField(application, "application_name"),
1347
+ apiEndpoint: normalizeSapCfApiEndpoint(requireApplicationField(application, "cf_api")),
1348
+ orgName: requireApplicationField(application, "organization_name"),
1349
+ spaceName: requireApplicationField(application, "space_name")
1350
+ };
1351
+ }
907
1352
  function extractVcapSection(stdout) {
908
1353
  const start = stdout.indexOf("VCAP_SERVICES:");
909
1354
  if (start === -1) {
@@ -985,107 +1430,143 @@ function classifyCfError(stderr = "", stdout = "") {
985
1430
  function errorMessageFromUnknown(error) {
986
1431
  return error instanceof Error ? error.message : "Invalid or untrusted CF API endpoint.";
987
1432
  }
988
- async function resolveAppBindings(rawSelector, options) {
989
- const selector = rawSelector.trim();
990
- if (!selector) {
991
- throw new CfHanaError("CONFIG", "App selector is required");
1433
+ function parseExplicitTarget(selector) {
1434
+ const [regionKey, orgName, spaceName, appName, extra] = selector.split("/").map((part) => part.trim());
1435
+ if (extra !== void 0 || !regionKey || !orgName || !spaceName || !appName) {
1436
+ throw new CfHanaError(
1437
+ "CONFIG",
1438
+ `Invalid selector "${selector}". Use region/org/space/app or a bare app name.`
1439
+ );
992
1440
  }
993
- let target;
994
- const isBare = !selector.includes("/");
995
- if (isBare) {
996
- const current = await readCurrentCfTarget();
997
- if (!current) {
998
- throw new CfHanaError(
999
- "CONFIG",
1000
- "No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector."
1001
- );
1002
- }
1003
- const displaySelector = current.regionKey ? formatCurrentCfAppSelector(current, selector) : `current/${current.orgName}/${current.spaceName}/${selector}`;
1004
- target = {
1005
- selector: displaySelector,
1006
- apiEndpoint: current.apiEndpoint,
1007
- orgName: current.orgName,
1008
- spaceName: current.spaceName,
1009
- appName: selector
1441
+ const apiEndpoint = getApiEndpointForRegion(regionKey);
1442
+ if (apiEndpoint === void 0) {
1443
+ throw new CfHanaError(
1444
+ "CONFIG",
1445
+ `Unknown SAP CF region "${regionKey}". Verify the current SAP region list or use the current CF target.`
1446
+ );
1447
+ }
1448
+ try {
1449
+ return {
1450
+ selector,
1451
+ apiEndpoint: normalizeSapCfApiEndpoint(apiEndpoint),
1452
+ orgName,
1453
+ spaceName,
1454
+ appName,
1455
+ selectorSource: "explicit",
1456
+ regionConfirmed: true,
1457
+ selectorCanBePinned: true
1010
1458
  };
1011
- } else {
1012
- const parts = selector.split("/").map((p) => p.trim());
1013
- if (parts.length !== 4 || !parts[0] || !parts[1] || !parts[2] || !parts[3]) {
1014
- throw new CfHanaError(
1015
- "CONFIG",
1016
- `Invalid selector "${selector}". Use region/org/space/app or a bare app name.`
1017
- );
1018
- }
1019
- const [regionKey, orgName, spaceName, appName] = parts;
1020
- const apiEndpoint = getApiEndpointForRegion(regionKey);
1021
- if (!apiEndpoint) {
1459
+ } catch (error) {
1460
+ throw new CfHanaError("CONFIG", errorMessageFromUnknown(error), { cause: error });
1461
+ }
1462
+ }
1463
+ async function resolveTarget(selector) {
1464
+ if (selector.includes("/")) {
1465
+ return parseExplicitTarget(selector);
1466
+ }
1467
+ const current = await readCurrentCfTarget();
1468
+ if (current === void 0) {
1469
+ throw new CfHanaError(
1470
+ "CONFIG",
1471
+ "No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector."
1472
+ );
1473
+ }
1474
+ return {
1475
+ selector: formatCurrentCfAppSelector(current, selector),
1476
+ apiEndpoint: current.apiEndpoint,
1477
+ orgName: current.orgName,
1478
+ spaceName: current.spaceName,
1479
+ appName: selector,
1480
+ selectorSource: "ambient",
1481
+ regionConfirmed: current.regionKey !== void 0,
1482
+ selectorCanBePinned: current.regionKey !== void 0 && getApiEndpointForRegion(current.regionKey) !== void 0
1483
+ };
1484
+ }
1485
+ function commandErrorText(error) {
1486
+ if (typeof error !== "object" || error === null) {
1487
+ return "";
1488
+ }
1489
+ if ("stderr" in error && typeof error.stderr === "string" && error.stderr.length > 0) {
1490
+ return error.stderr;
1491
+ }
1492
+ return "message" in error && typeof error.message === "string" ? error.message : "";
1493
+ }
1494
+ function isSameTarget(target, current) {
1495
+ return current?.apiEndpoint === target.apiEndpoint && current.orgName === target.orgName && current.spaceName === target.spaceName;
1496
+ }
1497
+ async function assertAmbientTargetUnchanged(target) {
1498
+ const current = await readCurrentCfTarget();
1499
+ if (!isSameTarget(target, current)) {
1500
+ throw new CfHanaError(
1501
+ "CONFIG",
1502
+ "CF target changed during binding discovery; no database connection was opened. Retry or use an explicit region/org/space/app selector."
1503
+ );
1504
+ }
1505
+ }
1506
+ function readCfEnvApplicationIdentity(stdout) {
1507
+ try {
1508
+ return extractCfEnvApplicationIdentity(stdout);
1509
+ } catch (error) {
1510
+ throw new CfHanaError(
1511
+ "CONFIG",
1512
+ "Could not verify cf env application identity for the ambient target; no database connection was opened. Retry or use an explicit region/org/space/app selector.",
1513
+ { cause: error }
1514
+ );
1515
+ }
1516
+ }
1517
+ function assertCfEnvApplicationMatchesTarget(target, application) {
1518
+ const matches = application.apiEndpoint === target.apiEndpoint && application.orgName === target.orgName && application.spaceName === target.spaceName && application.appName === target.appName;
1519
+ if (matches) {
1520
+ return;
1521
+ }
1522
+ throw new CfHanaError(
1523
+ "CONFIG",
1524
+ "CF env application identity did not match the resolved ambient target; no database connection was opened. Retry or use an explicit region/org/space/app selector."
1525
+ );
1526
+ }
1527
+ async function readIsolatedBindings(target, sap) {
1528
+ return await withCfSession(async (ctx) => {
1529
+ await cfApi(target.apiEndpoint, ctx);
1530
+ await cfAuth(sap.email, sap.password, ctx);
1531
+ await cfTargetSpace(target.orgName, target.spaceName, ctx);
1532
+ return extractHanaBindingsFromCfEnv(await cfEnv(target.appName, ctx));
1533
+ });
1534
+ }
1535
+ async function readBareBindings(target, options) {
1536
+ let stdout;
1537
+ try {
1538
+ stdout = await cfEnvDirect(target.appName);
1539
+ } catch (directError) {
1540
+ const classified = classifyCfError(commandErrorText(directError));
1541
+ if (!classified.isAuthError) {
1022
1542
  throw new CfHanaError(
1023
1543
  "CONFIG",
1024
- `Unknown SAP CF region "${regionKey}". Verify the current SAP region list or use the current CF target.`
1544
+ `Failed to get HANA bindings for bare app "${target.appName}" using current target (org=${target.orgName}, space=${target.spaceName}). Verify with "cf target" and "cf env ${target.appName}".`,
1545
+ { cause: directError }
1025
1546
  );
1026
1547
  }
1027
- let normalizedApiEndpoint;
1028
- try {
1029
- normalizedApiEndpoint = normalizeSapCfApiEndpoint(apiEndpoint);
1030
- } catch (error) {
1031
- throw new CfHanaError("CONFIG", errorMessageFromUnknown(error), { cause: error });
1032
- }
1033
- target = { selector, apiEndpoint: normalizedApiEndpoint, orgName, spaceName, appName };
1034
- }
1035
- let bindings;
1036
- const source = "live";
1037
- if (isBare) {
1038
- try {
1039
- const stdout = await cfEnvDirect(target.appName);
1040
- bindings = extractHanaBindingsFromCfEnv(stdout);
1041
- } catch (directError) {
1042
- let stderr = "";
1043
- if (directError && typeof directError === "object") {
1044
- const e = directError;
1045
- stderr = (typeof e.stderr === "string" ? e.stderr : "") || (typeof e.message === "string" ? e.message : "");
1046
- }
1047
- const classified = classifyCfError(stderr);
1048
- if (classified.isAuthError) {
1049
- const sap = readSapCredentials({ email: options.email, password: options.password });
1050
- if (!sap) {
1051
- throw new CredentialsNotFoundError(
1052
- `Current CF session problem for bare app "${target.appName}" (${classified.reason}).
1053
- Run "cf login" + "cf target", or provide SAP_EMAIL + SAP_PASSWORD.`,
1054
- { cause: directError }
1055
- );
1056
- }
1057
- const api = target.apiEndpoint;
1058
- bindings = await withCfSession(async (ctx) => {
1059
- await cfApi(api, ctx);
1060
- await cfAuth(sap.email, sap.password, ctx);
1061
- await cfTargetSpace(target.orgName, target.spaceName, ctx);
1062
- const stdout = await cfEnv(target.appName, ctx);
1063
- return extractHanaBindingsFromCfEnv(stdout);
1064
- });
1065
- } else {
1066
- throw new CfHanaError(
1067
- "CONFIG",
1068
- `Failed to get HANA bindings for bare app "${target.appName}" using current target (org=${target.orgName}, space=${target.spaceName}). Verify with "cf target" and "cf env ${target.appName}". ${stderr ? `Details: ${stderr}` : ""}`,
1069
- { cause: directError }
1070
- );
1071
- }
1072
- }
1073
- } else {
1074
1548
  const sap = readSapCredentials({ email: options.email, password: options.password });
1075
- if (!sap) {
1549
+ if (sap === void 0) {
1076
1550
  throw new CredentialsNotFoundError(
1077
- `SAP_EMAIL and SAP_PASSWORD are required to fetch HANA bindings for explicit selector "${selector}".`
1551
+ `Current CF session problem for bare app "${target.appName}" (${classified.reason}).
1552
+ Run "cf login" + "cf target", or provide SAP_EMAIL + SAP_PASSWORD.`,
1553
+ { cause: directError }
1078
1554
  );
1079
1555
  }
1080
- const api = target.apiEndpoint;
1081
- bindings = await withCfSession(async (ctx) => {
1082
- await cfApi(api, ctx);
1083
- await cfAuth(sap.email, sap.password, ctx);
1084
- await cfTargetSpace(target.orgName, target.spaceName, ctx);
1085
- const stdout = await cfEnv(target.appName, ctx);
1086
- return extractHanaBindingsFromCfEnv(stdout);
1087
- });
1556
+ return await readIsolatedBindings(target, sap);
1557
+ }
1558
+ const application = readCfEnvApplicationIdentity(stdout);
1559
+ await assertAmbientTargetUnchanged(target);
1560
+ assertCfEnvApplicationMatchesTarget(target, application);
1561
+ return extractHanaBindingsFromCfEnv(stdout);
1562
+ }
1563
+ async function resolveAppBindings(rawSelector, options) {
1564
+ const selector = rawSelector.trim();
1565
+ if (selector.length === 0) {
1566
+ throw new CfHanaError("CONFIG", "App selector is required");
1088
1567
  }
1568
+ const target = await resolveTarget(selector);
1569
+ const bindings = target.selectorSource === "ambient" ? await readBareBindings(target, options) : await readExplicitBindings(target, options);
1089
1570
  if (bindings.length === 0) {
1090
1571
  throw new CredentialsNotFoundError(`App "${target.selector}" has no HANA service binding.`);
1091
1572
  }
@@ -1093,9 +1574,21 @@ Run "cf login" + "cf target", or provide SAP_EMAIL + SAP_PASSWORD.`,
1093
1574
  selector: target.selector,
1094
1575
  appName: target.appName,
1095
1576
  bindings,
1096
- source
1577
+ source: "live",
1578
+ selectorSource: target.selectorSource,
1579
+ regionConfirmed: target.regionConfirmed,
1580
+ selectorCanBePinned: target.selectorCanBePinned
1097
1581
  };
1098
1582
  }
1583
+ async function readExplicitBindings(target, options) {
1584
+ const sap = readSapCredentials({ email: options.email, password: options.password });
1585
+ if (sap === void 0) {
1586
+ throw new CredentialsNotFoundError(
1587
+ `SAP_EMAIL and SAP_PASSWORD are required to fetch HANA bindings for explicit selector "${target.selector}".`
1588
+ );
1589
+ }
1590
+ return await readIsolatedBindings(target, sap);
1591
+ }
1099
1592
  function selectBinding(bindings, selector) {
1100
1593
  if (selector.bindingName !== void 0) {
1101
1594
  const match = bindings.find((binding) => binding.name === selector.bindingName);
@@ -1166,42 +1659,82 @@ function tableColumns() {
1166
1659
  { COLUMN_NAME: "SCOPE_NAME", DATA_TYPE_NAME: "NVARCHAR", LENGTH: 255, SCALE: null, IS_NULLABLE: "TRUE", POSITION: 3 }
1167
1660
  ];
1168
1661
  }
1169
- function fakeExec(sql) {
1170
- const kind = classifyStatement(sql);
1662
+ function throwForcedFailure(kind) {
1171
1663
  const forcedFailure = readEnv(envName("FAKE_FAIL_STATEMENT"))?.toLowerCase();
1172
1664
  if (forcedFailure === kind) {
1173
1665
  throw new Error(`fake driver forced ${kind.toUpperCase()} failure`);
1174
1666
  }
1175
- const upperSql = sql.toUpperCase();
1667
+ }
1668
+ function throwEarlyFixtureError(upperSql) {
1669
+ if (upperSql.includes("PRIVILEGE_ERROR_CODE")) {
1670
+ throw new QueryError("insufficient privilege: not authorized", { databaseCode: 258 });
1671
+ }
1672
+ if (upperSql.includes("PRIVILEGE_ERROR_MESSAGE")) {
1673
+ throw new QueryError("insufficient privilege: grant is missing");
1674
+ }
1675
+ if (upperSql.includes("NON_PRIVILEGE_ERROR")) {
1676
+ throw new QueryError("fake unrelated query failure", { databaseCode: 999 });
1677
+ }
1678
+ if (readEnv(envName("FAKE_PRIVILEGE_CATALOG")) === "1" && (upperSql.includes("SYS.TABLES") || upperSql.includes("SYS.TABLE_COLUMNS"))) {
1679
+ throw new QueryError("insufficient privilege: catalog access denied", { databaseCode: 258 });
1680
+ }
1681
+ }
1682
+ function catalogObjectsResult() {
1683
+ return {
1684
+ rows: catalogObjects(),
1685
+ columns: [
1686
+ { name: "SCHEMA_NAME", typeName: "NVARCHAR" },
1687
+ { name: "OBJECT_NAME", typeName: "NVARCHAR" },
1688
+ { name: "OBJECT_TYPE", typeName: "NVARCHAR" }
1689
+ ],
1690
+ affectedRows: 0
1691
+ };
1692
+ }
1693
+ function tablesResult() {
1694
+ return {
1695
+ rows: [
1696
+ { SCHEMA_NAME: "APP_SCHEMA", TABLE_NAME: "EXISTING_TABLE", TABLE_TYPE: "COLUMN TABLE" },
1697
+ { SCHEMA_NAME: "APP_SCHEMA", TABLE_NAME: "STATUS_ITEMS", TABLE_TYPE: "ROW TABLE" }
1698
+ ],
1699
+ columns: [
1700
+ { name: "SCHEMA_NAME", typeName: "NVARCHAR" },
1701
+ { name: "TABLE_NAME", typeName: "NVARCHAR" },
1702
+ { name: "TABLE_TYPE", typeName: "NVARCHAR" }
1703
+ ],
1704
+ affectedRows: 0
1705
+ };
1706
+ }
1707
+ function tableColumnsResult() {
1708
+ return {
1709
+ rows: tableColumns(),
1710
+ columns: [
1711
+ { name: "COLUMN_NAME", typeName: "NVARCHAR" },
1712
+ { name: "DATA_TYPE_NAME", typeName: "NVARCHAR" },
1713
+ { name: "LENGTH", typeName: "INTEGER" },
1714
+ { name: "SCALE", typeName: "INTEGER" },
1715
+ { name: "IS_NULLABLE", typeName: "NVARCHAR" },
1716
+ { name: "POSITION", typeName: "INTEGER" }
1717
+ ],
1718
+ affectedRows: 0
1719
+ };
1720
+ }
1721
+ function matchCatalogFixture(upperSql) {
1176
1722
  if (upperSql.includes("SYS.TABLES") && upperSql.includes("SYS.VIEWS")) {
1177
1723
  if (readEnv(envName("FAKE_FAIL_CATALOG_ONCE")) === "1" && !catalogFailureInjected) {
1178
1724
  catalogFailureInjected = true;
1179
1725
  throw new QueryError("fake transient catalog metadata failure");
1180
1726
  }
1181
- return {
1182
- rows: catalogObjects(),
1183
- columns: [
1184
- { name: "SCHEMA_NAME", typeName: "NVARCHAR" },
1185
- { name: "OBJECT_NAME", typeName: "NVARCHAR" },
1186
- { name: "OBJECT_TYPE", typeName: "NVARCHAR" }
1187
- ],
1188
- affectedRows: 0
1189
- };
1727
+ return catalogObjectsResult();
1728
+ }
1729
+ if (upperSql.includes("SYS.TABLES")) {
1730
+ return tablesResult();
1190
1731
  }
1191
1732
  if (upperSql.includes("SYS.TABLE_COLUMNS")) {
1192
- return {
1193
- rows: tableColumns(),
1194
- columns: [
1195
- { name: "COLUMN_NAME", typeName: "NVARCHAR" },
1196
- { name: "DATA_TYPE_NAME", typeName: "NVARCHAR" },
1197
- { name: "LENGTH", typeName: "INTEGER" },
1198
- { name: "SCALE", typeName: "INTEGER" },
1199
- { name: "IS_NULLABLE", typeName: "NVARCHAR" },
1200
- { name: "POSITION", typeName: "INTEGER" }
1201
- ],
1202
- affectedRows: 0
1203
- };
1733
+ return tableColumnsResult();
1204
1734
  }
1735
+ return void 0;
1736
+ }
1737
+ function throwLateFixtureError(upperSql) {
1205
1738
  if (upperSql.includes("MISSING_TABLE") || upperSql.includes("MISSING_TABLES")) {
1206
1739
  throw new QueryError("invalid table name: MISSING_TABLE", { sqlState: "42S02" });
1207
1740
  }
@@ -1214,30 +1747,45 @@ function fakeExec(sql) {
1214
1747
  if (upperSql.includes("LOB_GROUP_ERROR")) {
1215
1748
  throw new QueryError("inconsistent datatype: LOB type is not allowed in GROUP BY clause", { databaseCode: 274 });
1216
1749
  }
1217
- if (sql.toUpperCase().includes("LOB_FIXTURE")) {
1750
+ }
1751
+ function lobFixtureResult() {
1752
+ return {
1753
+ rows: [
1754
+ {
1755
+ LOG_CONTENT: Buffer.from("Example log entry", "utf8"),
1756
+ CLOB_CONTENT: Buffer.from("Clob log entry", "utf8"),
1757
+ PAYLOAD: Buffer.from([0, 1, 2, 255])
1758
+ }
1759
+ ],
1760
+ columns: [
1761
+ { name: "LOG_CONTENT", typeName: "NCLOB" },
1762
+ { name: "CLOB_CONTENT", typeName: "CLOB" },
1763
+ { name: "PAYLOAD", typeName: "BLOB" }
1764
+ ],
1765
+ affectedRows: 0
1766
+ };
1767
+ }
1768
+ function matchDataFixture(upperSql) {
1769
+ if (upperSql.includes("LOB_FIXTURE")) {
1770
+ return lobFixtureResult();
1771
+ }
1772
+ if (upperSql.includes("SINGLE_COLUMN_FIXTURE")) {
1218
1773
  return {
1219
- rows: [
1220
- {
1221
- LOG_CONTENT: Buffer.from("Example log entry", "utf8"),
1222
- CLOB_CONTENT: Buffer.from("Clob log entry", "utf8"),
1223
- PAYLOAD: Buffer.from([0, 1, 2, 255])
1224
- }
1225
- ],
1226
- columns: [
1227
- { name: "LOG_CONTENT", typeName: "NCLOB" },
1228
- { name: "CLOB_CONTENT", typeName: "CLOB" },
1229
- { name: "PAYLOAD", typeName: "BLOB" }
1230
- ],
1774
+ rows: [{ VALUE: "alpha" }, { VALUE: "beta" }],
1775
+ columns: [{ name: "VALUE", typeName: "NVARCHAR" }],
1231
1776
  affectedRows: 0
1232
1777
  };
1233
1778
  }
1234
- if (sql.toUpperCase().includes("DUMMY")) {
1779
+ if (upperSql.includes("DUMMY")) {
1235
1780
  return {
1236
1781
  rows: [{ "1": 1 }],
1237
1782
  columns: [{ name: "1", typeName: "INTEGER" }],
1238
1783
  affectedRows: 0
1239
1784
  };
1240
1785
  }
1786
+ return void 0;
1787
+ }
1788
+ function defaultResult(kind) {
1241
1789
  if (kind === "select") {
1242
1790
  return {
1243
1791
  rows: [
@@ -1256,6 +1804,18 @@ function fakeExec(sql) {
1256
1804
  }
1257
1805
  return { rows: [], columns: [], affectedRows: 0 };
1258
1806
  }
1807
+ function fakeExec(sql) {
1808
+ const kind = classifyStatement(sql);
1809
+ throwForcedFailure(kind);
1810
+ const upperSql = sql.toUpperCase();
1811
+ throwEarlyFixtureError(upperSql);
1812
+ const catalogFixture = matchCatalogFixture(upperSql);
1813
+ if (catalogFixture !== void 0) {
1814
+ return catalogFixture;
1815
+ }
1816
+ throwLateFixtureError(upperSql);
1817
+ return matchDataFixture(upperSql) ?? defaultResult(kind);
1818
+ }
1259
1819
  async function traceFakeExec(sql, params) {
1260
1820
  const tracePath = readEnv(envName("FAKE_TRACE_FILE"));
1261
1821
  if (tracePath === void 0) {
@@ -1686,8 +2246,30 @@ function maskIgnoredSqlText(sql) {
1686
2246
  }
1687
2247
  return masked;
1688
2248
  }
1689
- function hasWhereClause(sql) {
1690
- return /\bwhere\b/i.test(maskIgnoredSqlText(sql));
2249
+ function hasTopLevelKeyword(sql, keyword) {
2250
+ const masked = maskIgnoredSqlText(sql);
2251
+ let depth = 0;
2252
+ for (let index = 0; index < masked.length; index += 1) {
2253
+ const char = masked[index];
2254
+ if (char === "(") {
2255
+ depth += 1;
2256
+ continue;
2257
+ }
2258
+ if (char === ")") {
2259
+ depth = Math.max(0, depth - 1);
2260
+ continue;
2261
+ }
2262
+ 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))) {
2263
+ return true;
2264
+ }
2265
+ }
2266
+ return false;
2267
+ }
2268
+ function isUnconditionalMergeDelete(sql) {
2269
+ return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
2270
+ }
2271
+ function isMalformedReplace(sql) {
2272
+ return !hasTopLevelKeyword(sql, "VALUES") && !hasTopLevelKeyword(sql, "SELECT") && !hasTopLevelKeyword(sql, "WITH");
1691
2273
  }
1692
2274
  function trailingLineCommentIndex(sql) {
1693
2275
  let index = 0;
@@ -1714,12 +2296,12 @@ function trailingLineCommentIndex(sql) {
1714
2296
  return void 0;
1715
2297
  }
1716
2298
  function appendLimit(sql, limit) {
1717
- const trimmed = sql.replace(/[\s;]+$/, "");
2299
+ const trimmed = sql.replace(/(?<![\s;])[\s;]+$/, "");
1718
2300
  const commentIndex = trailingLineCommentIndex(trimmed);
1719
2301
  if (commentIndex === void 0) {
1720
2302
  return `${trimmed} LIMIT ${String(limit)}`;
1721
2303
  }
1722
- const beforeComment = trimmed.slice(0, commentIndex).replace(/[\s;]+$/, "");
2304
+ const beforeComment = trimmed.slice(0, commentIndex).replace(/(?<![\s;])[\s;]+$/, "");
1723
2305
  const comment = trimmed.slice(commentIndex);
1724
2306
  return `${beforeComment} LIMIT ${String(limit)} ${comment}`;
1725
2307
  }
@@ -1730,9 +2312,10 @@ function inspectStatement(sql) {
1730
2312
  return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
1731
2313
  }
1732
2314
  if (kind === "dml") {
2315
+ const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(sql, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(sql) || keyword === "REPLACE" && isMalformedReplace(sql);
1733
2316
  return {
1734
2317
  kind,
1735
- destructive: UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasWhereClause(sql)
2318
+ destructive
1736
2319
  };
1737
2320
  }
1738
2321
  return { kind, destructive: false };
@@ -1752,7 +2335,7 @@ function evaluateGuard(sql, config) {
1752
2335
  allowed: false,
1753
2336
  destructive: true,
1754
2337
  violation: "destructive",
1755
- reason: "destructive statement blocked (DROP/TRUNCATE/ALTER or unscoped UPDATE/DELETE); allow it explicitly to proceed"
2338
+ reason: "destructive statement blocked (DROP/TRUNCATE/ALTER, unscoped UPDATE/DELETE, or unconditional matched MERGE DELETE); allow it explicitly to proceed"
1756
2339
  };
1757
2340
  }
1758
2341
  return {
@@ -2154,18 +2737,37 @@ function nextExplainStatementName() {
2154
2737
  explainStatementCounter = (explainStatementCounter + 1) % Number.MAX_SAFE_INTEGER;
2155
2738
  return `cf_hana_${String(process.pid)}_${String(Date.now())}_${String(explainStatementCounter)}`;
2156
2739
  }
2740
+ function toPoolOptions(options) {
2741
+ if (options.pool === false) {
2742
+ return { max: 1 };
2743
+ }
2744
+ return options.pool ?? {};
2745
+ }
2746
+ function toSelectedBindingInfo(bindings, selected) {
2747
+ const availableBindingNames = bindings.flatMap(
2748
+ (binding) => binding.name === void 0 ? [] : [binding.name]
2749
+ );
2750
+ return {
2751
+ ...selected.name === void 0 ? {} : { bindingName: selected.name },
2752
+ bindingIndex: bindings.indexOf(selected),
2753
+ availableBindingNames
2754
+ };
2755
+ }
2157
2756
  var HanaClient = class _HanaClient {
2158
- constructor(pool, info) {
2757
+ constructor(pool, info, databaseUser = "") {
2159
2758
  this.pool = pool;
2160
2759
  this.info = info;
2760
+ this.databaseUser = databaseUser;
2161
2761
  }
2162
2762
  pool;
2163
2763
  info;
2764
+ databaseUser;
2164
2765
  /** Open a client for a `region/org/space/app` selector (or a bare app name). */
2165
2766
  static async connect(selector, options = {}) {
2166
2767
  const resolved = await resolveAppBindings(selector, options);
2167
2768
  const role = options.role ?? "runtime";
2168
2769
  const binding = selectBinding(resolved.bindings, options);
2770
+ const bindingInfo = toSelectedBindingInfo(resolved.bindings, binding);
2169
2771
  const target = toConnectionTarget(binding, role);
2170
2772
  const driver = createDriver();
2171
2773
  const config = {
@@ -2181,7 +2783,7 @@ var HanaClient = class _HanaClient {
2181
2783
  allowDestructive: options.allowDestructive ?? false,
2182
2784
  autoLimit: options.autoLimit ?? DEFAULT_AUTO_LIMIT
2183
2785
  };
2184
- const poolOptions = options.pool === false ? { max: 1 } : options.pool ?? {};
2786
+ const poolOptions = toPoolOptions(options);
2185
2787
  const info = {
2186
2788
  selector: resolved.selector,
2187
2789
  appName: resolved.appName,
@@ -2189,9 +2791,13 @@ var HanaClient = class _HanaClient {
2189
2791
  schema: target.schema,
2190
2792
  role,
2191
2793
  driver: driver.name,
2192
- credentialSource: resolved.source
2794
+ credentialSource: resolved.source,
2795
+ selectorSource: resolved.selectorSource,
2796
+ regionConfirmed: resolved.regionConfirmed,
2797
+ selectorCanBePinned: resolved.selectorCanBePinned,
2798
+ ...bindingInfo
2193
2799
  };
2194
- return new _HanaClient(new ConnectionPool(driver, config, poolOptions), info);
2800
+ return new _HanaClient(new ConnectionPool(driver, config, poolOptions), info, target.user);
2195
2801
  }
2196
2802
  /** Run a SELECT (or any read) statement and return typed rows. */
2197
2803
  async query(sql, params, options) {
@@ -2207,7 +2813,7 @@ var HanaClient = class _HanaClient {
2207
2813
  await this.recordSqlHistory("execute", sql, resolvedParams, result);
2208
2814
  return result;
2209
2815
  }
2210
- /** Back up rows matched by an UPDATE or DELETE before the caller runs it. */
2816
+ /** Back up pre-image rows required by a supported write before the caller runs it. */
2211
2817
  async backupWriteStatement(sql, params, options) {
2212
2818
  const resolvedParams = params ?? [];
2213
2819
  const plan = buildWriteBackupPlan(sql, resolvedParams);
@@ -2388,6 +2994,7 @@ async function withConnection(selector, work, options) {
2388
2994
  }
2389
2995
  }
2390
2996
  export {
2997
+ BackupRequiredError,
2391
2998
  CfHanaError,
2392
2999
  CredentialsNotFoundError,
2393
3000
  DestructiveStatementError,