@saptools/cf-hana 0.1.1 → 0.1.3

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
@@ -243,6 +243,7 @@ async function listColumns(connection, schema, table) {
243
243
  }
244
244
 
245
245
  // src/config.ts
246
+ var CLI_VERSION = "0.1.3";
246
247
  var ENV_PREFIX = "CF_HANA";
247
248
  var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
248
249
  var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
@@ -493,7 +494,7 @@ var HdbConnection = class {
493
494
  const raw = await this.executeStatement(statement, params);
494
495
  return toExecResult(statement, raw);
495
496
  } finally {
496
- statement.drop();
497
+ dropStatementQuietly(statement);
497
498
  }
498
499
  }
499
500
  setAutoCommit(enabled) {
@@ -560,6 +561,12 @@ var HdbConnection = class {
560
561
  });
561
562
  }
562
563
  };
564
+ function dropStatementQuietly(statement) {
565
+ try {
566
+ statement.drop();
567
+ } catch {
568
+ }
569
+ }
563
570
  function openClient(client, timeoutMs) {
564
571
  return new Promise((resolve, reject) => {
565
572
  let settled = false;
@@ -595,6 +602,20 @@ function openClient(client, timeoutMs) {
595
602
  });
596
603
  });
597
604
  }
605
+ async function closeClientQuietly(client) {
606
+ try {
607
+ await new Promise((resolve) => {
608
+ client.disconnect(() => {
609
+ resolve();
610
+ });
611
+ });
612
+ } catch {
613
+ }
614
+ try {
615
+ client.close();
616
+ } catch {
617
+ }
618
+ }
598
619
  function setCurrentSchema(client, schema) {
599
620
  return new Promise((resolve, reject) => {
600
621
  client.exec(`SET SCHEMA ${quoteIdentifier(schema)}`, (error) => {
@@ -616,7 +637,12 @@ async function connectHdb(params) {
616
637
  useTLS: true
617
638
  });
618
639
  await openClient(client, params.connectTimeoutMs);
619
- await setCurrentSchema(client, params.schema);
640
+ try {
641
+ await setCurrentSchema(client, params.schema);
642
+ } catch (error) {
643
+ await closeClientQuietly(client);
644
+ throw error;
645
+ }
620
646
  return new HdbConnection(client);
621
647
  }
622
648
  function createHdbDriver() {
@@ -639,14 +665,178 @@ function createDriver(name) {
639
665
  }
640
666
  }
641
667
 
668
+ // src/history.ts
669
+ import { appendFile, mkdir, readdir, rm } from "fs/promises";
670
+ import { homedir } from "os";
671
+ import { join } from "path";
672
+ var SAPTOOLS_DIR_NAME = ".saptools";
673
+ var CF_HANA_DIR_NAME = "cf-hana";
674
+ var HISTORIES_DIR_NAME = "histories";
675
+ var HISTORY_RETENTION_DAYS = 5;
676
+ var HISTORY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
677
+ function defaultSaptoolsRoot() {
678
+ return join(homedir(), SAPTOOLS_DIR_NAME);
679
+ }
680
+ function padDatePart(value) {
681
+ return value.toString().padStart(2, "0");
682
+ }
683
+ function dateKey(date) {
684
+ return [
685
+ date.getFullYear().toString(),
686
+ padDatePart(date.getMonth() + 1),
687
+ padDatePart(date.getDate())
688
+ ].join("-");
689
+ }
690
+ function retentionCutoffKey(now) {
691
+ const cutoff = new Date(now);
692
+ cutoff.setHours(0, 0, 0, 0);
693
+ cutoff.setDate(cutoff.getDate() - HISTORY_RETENTION_DAYS);
694
+ return dateKey(cutoff);
695
+ }
696
+ function resolveSaptoolsRoot(root) {
697
+ return root ?? defaultSaptoolsRoot();
698
+ }
699
+ function cfHanaHistoryDirectory(saptoolsRoot) {
700
+ return join(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME, HISTORIES_DIR_NAME);
701
+ }
702
+ function sqlHistoryFilePath(now = /* @__PURE__ */ new Date(), saptoolsRoot) {
703
+ return join(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
704
+ }
705
+ async function pruneSqlHistory(now, saptoolsRoot) {
706
+ const historyDir = cfHanaHistoryDirectory(saptoolsRoot);
707
+ let files;
708
+ try {
709
+ files = await readdir(historyDir);
710
+ } catch (error) {
711
+ if (error.code === "ENOENT") {
712
+ return;
713
+ }
714
+ throw error;
715
+ }
716
+ const cutoffKey = retentionCutoffKey(now);
717
+ await Promise.all(
718
+ files.filter((file) => HISTORY_FILE_PATTERN.test(file)).filter((file) => file.slice(0, 10) < cutoffKey).map(async (file) => {
719
+ await rm(join(historyDir, file), { force: true });
720
+ })
721
+ );
722
+ }
723
+ async function appendSqlHistory(input, options = {}) {
724
+ const now = options.now ?? /* @__PURE__ */ new Date();
725
+ const historyDir = cfHanaHistoryDirectory(options.saptoolsRoot);
726
+ const entry = {
727
+ at: now.toISOString(),
728
+ ...input
729
+ };
730
+ await mkdir(historyDir, { recursive: true, mode: 448 });
731
+ await appendFile(sqlHistoryFilePath(now, options.saptoolsRoot), `${JSON.stringify(entry)}
732
+ `, {
733
+ encoding: "utf8",
734
+ mode: 384
735
+ });
736
+ try {
737
+ await pruneSqlHistory(now, options.saptoolsRoot);
738
+ } catch {
739
+ }
740
+ return entry;
741
+ }
742
+
642
743
  // src/safety.ts
643
744
  var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
644
745
  var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
645
- function stripStringLiterals(sql) {
646
- return sql.replace(/'(?:[^']|'')*'/g, "''");
746
+ function skipQuotedText(sql, start) {
747
+ const quote = sql[start];
748
+ let index = start + 1;
749
+ while (index < sql.length) {
750
+ if (sql[index] === quote) {
751
+ if (sql[index + 1] === quote) {
752
+ index += 2;
753
+ continue;
754
+ }
755
+ index += 1;
756
+ break;
757
+ }
758
+ index += 1;
759
+ }
760
+ return index;
761
+ }
762
+ function skipLineComment(sql, start) {
763
+ let index = start + 2;
764
+ while (index < sql.length && sql[index] !== "\n") {
765
+ index += 1;
766
+ }
767
+ return index;
768
+ }
769
+ function skipBlockComment(sql, start) {
770
+ let index = start + 2;
771
+ while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
772
+ index += 1;
773
+ }
774
+ return Math.min(index + 2, sql.length);
775
+ }
776
+ function maskIgnoredSqlText(sql) {
777
+ let masked = "";
778
+ let index = 0;
779
+ while (index < sql.length) {
780
+ const char = sql[index];
781
+ if (char === "'" || char === '"') {
782
+ const end = skipQuotedText(sql, index);
783
+ masked += " ".repeat(end - index);
784
+ index = end;
785
+ continue;
786
+ }
787
+ if (char === "-" && sql[index + 1] === "-") {
788
+ const end = skipLineComment(sql, index);
789
+ masked += " ".repeat(end - index);
790
+ index = end;
791
+ continue;
792
+ }
793
+ if (char === "/" && sql[index + 1] === "*") {
794
+ const end = skipBlockComment(sql, index);
795
+ masked += " ".repeat(end - index);
796
+ index = end;
797
+ continue;
798
+ }
799
+ masked += char ?? "";
800
+ index += 1;
801
+ }
802
+ return masked;
647
803
  }
648
804
  function hasWhereClause(sql) {
649
- return /\bwhere\b/i.test(stripStringLiterals(sql));
805
+ return /\bwhere\b/i.test(maskIgnoredSqlText(sql));
806
+ }
807
+ function trailingLineCommentIndex(sql) {
808
+ let index = 0;
809
+ while (index < sql.length) {
810
+ const char = sql[index];
811
+ if (char === "'" || char === '"') {
812
+ index = skipQuotedText(sql, index);
813
+ continue;
814
+ }
815
+ if (char === "/" && sql[index + 1] === "*") {
816
+ index = skipBlockComment(sql, index);
817
+ continue;
818
+ }
819
+ if (char === "-" && sql[index + 1] === "-") {
820
+ const lineEnd = sql.indexOf("\n", index + 2);
821
+ if (lineEnd === -1 || sql.slice(lineEnd + 1).trim().length === 0) {
822
+ return index;
823
+ }
824
+ index = lineEnd + 1;
825
+ continue;
826
+ }
827
+ index += 1;
828
+ }
829
+ return void 0;
830
+ }
831
+ function appendLimit(sql, limit) {
832
+ const trimmed = sql.replace(/[\s;]+$/, "");
833
+ const commentIndex = trailingLineCommentIndex(trimmed);
834
+ if (commentIndex === void 0) {
835
+ return `${trimmed} LIMIT ${String(limit)}`;
836
+ }
837
+ const beforeComment = trimmed.slice(0, commentIndex).replace(/[\s;]+$/, "");
838
+ const comment = trimmed.slice(commentIndex);
839
+ return `${beforeComment} LIMIT ${String(limit)} ${comment}`;
650
840
  }
651
841
  function inspectStatement(sql) {
652
842
  const kind = classifyStatement(sql);
@@ -664,12 +854,12 @@ function inspectStatement(sql) {
664
854
  }
665
855
  function evaluateGuard(sql, config) {
666
856
  const inspection = inspectStatement(sql);
667
- if (config.readOnly && (inspection.kind === "dml" || inspection.kind === "ddl")) {
857
+ if (config.readOnly && inspection.kind !== "select") {
668
858
  return {
669
859
  allowed: false,
670
860
  destructive: inspection.destructive,
671
861
  violation: "read-only",
672
- reason: `read-only mode blocks ${inspection.kind.toUpperCase()} statements`
862
+ reason: inspection.kind === "unknown" ? "read-only mode only permits SELECT/WITH statements" : `read-only mode blocks ${inspection.kind.toUpperCase()} statements`
673
863
  };
674
864
  }
675
865
  if (inspection.destructive && !config.allowDestructive) {
@@ -691,12 +881,11 @@ function applyAutoLimit(sql, limit) {
691
881
  if (limit === false || classifyStatement(sql) !== "select") {
692
882
  return { sql, applied: false };
693
883
  }
694
- const stripped = stripStringLiterals(sql);
884
+ const stripped = maskIgnoredSqlText(sql);
695
885
  if (/\blimit\b/i.test(stripped) || /\btop\s+\d/i.test(stripped)) {
696
886
  return { sql, applied: false };
697
887
  }
698
- const trimmed = sql.replace(/[\s;]+$/, "");
699
- return { sql: `${trimmed} LIMIT ${String(limit)}`, applied: true };
888
+ return { sql: appendLimit(sql, limit), applied: true };
700
889
  }
701
890
 
702
891
  // src/connection.ts
@@ -745,6 +934,12 @@ var Connection = class _Connection {
745
934
  async execute(sql, params, options) {
746
935
  return await this.run(sql, params ?? [], options ?? {});
747
936
  }
937
+ assertAllowed(sql, options) {
938
+ this.evaluateSafety(sql, options ?? {});
939
+ }
940
+ async executeInternal(sql, params, options) {
941
+ return await this.run(sql, params ?? [], options ?? {}, { bypassSafety: true });
942
+ }
748
943
  async setAutoCommit(enabled) {
749
944
  await this.driverConnection.setAutoCommit(enabled);
750
945
  }
@@ -760,21 +955,10 @@ var Connection = class _Connection {
760
955
  get isClosed() {
761
956
  return this.driverConnection.isClosed();
762
957
  }
763
- async run(sql, params, options) {
958
+ async run(sql, params, options, runOptions = {}) {
764
959
  assertParamArity(sql, params);
765
- const decision = evaluateGuard(sql, {
766
- readOnly: this.config.readOnly,
767
- allowDestructive: options.allowDestructive ?? this.config.allowDestructive
768
- });
769
- if (!decision.allowed) {
770
- if (decision.violation === "read-only") {
771
- throw new ReadOnlyViolationError(
772
- decision.reason ?? "read-only mode blocks this statement"
773
- );
774
- }
775
- throw new DestructiveStatementError(
776
- decision.reason ?? "destructive statement blocked"
777
- );
960
+ if (runOptions.bypassSafety !== true) {
961
+ this.evaluateSafety(sql, options);
778
962
  }
779
963
  const kind = classifyStatement(sql);
780
964
  const autoLimit = options.autoLimit ?? this.config.autoLimit;
@@ -804,6 +988,23 @@ var Connection = class _Connection {
804
988
  } catch {
805
989
  }
806
990
  }
991
+ evaluateSafety(sql, options) {
992
+ const decision = evaluateGuard(sql, {
993
+ readOnly: this.config.readOnly,
994
+ allowDestructive: options.allowDestructive ?? this.config.allowDestructive
995
+ });
996
+ if (decision.allowed) {
997
+ return;
998
+ }
999
+ if (decision.violation === "read-only") {
1000
+ throw new ReadOnlyViolationError(
1001
+ decision.reason ?? "read-only mode blocks this statement"
1002
+ );
1003
+ }
1004
+ throw new DestructiveStatementError(
1005
+ decision.reason ?? "destructive statement blocked"
1006
+ );
1007
+ }
807
1008
  };
808
1009
 
809
1010
  // src/pool.ts
@@ -944,6 +1145,7 @@ var ConnectionPool = class {
944
1145
  (error) => {
945
1146
  this.created -= 1;
946
1147
  waiter.reject(error);
1148
+ this.servePendingWaiters();
947
1149
  }
948
1150
  );
949
1151
  }
@@ -990,6 +1192,11 @@ var Transaction = class {
990
1192
  };
991
1193
 
992
1194
  // src/client.ts
1195
+ var explainStatementCounter = 0;
1196
+ function nextExplainStatementName() {
1197
+ explainStatementCounter = (explainStatementCounter + 1) % Number.MAX_SAFE_INTEGER;
1198
+ return `cf_hana_${String(process.pid)}_${String(Date.now())}_${String(explainStatementCounter)}`;
1199
+ }
993
1200
  var HanaClient = class _HanaClient {
994
1201
  constructor(pool, info) {
995
1202
  this.pool = pool;
@@ -1031,41 +1238,43 @@ var HanaClient = class _HanaClient {
1031
1238
  }
1032
1239
  /** Run a SELECT (or any read) statement and return typed rows. */
1033
1240
  async query(sql, params, options) {
1034
- return await this.pool.withConnection(
1035
- (connection) => connection.query(sql, params, options)
1036
- );
1241
+ const resolvedParams = params ?? [];
1242
+ const result = await this.runQuery(sql, resolvedParams, options);
1243
+ await this.recordSqlHistory("query", sql, resolvedParams, result);
1244
+ return result;
1037
1245
  }
1038
1246
  /** Run a DML/DDL statement and return its affected-row count. */
1039
1247
  async execute(sql, params, options) {
1040
- return await this.pool.withConnection(
1041
- (connection) => connection.execute(sql, params, options)
1042
- );
1248
+ const resolvedParams = params ?? [];
1249
+ const result = await this.runExecute(sql, resolvedParams, options);
1250
+ await this.recordSqlHistory("execute", sql, resolvedParams, result);
1251
+ return result;
1043
1252
  }
1044
1253
  /** Run a typed `SELECT` built from a spec. */
1045
1254
  async selectFrom(spec) {
1046
1255
  const built = buildSelect(spec);
1047
- return await this.query(built.sql, built.params);
1256
+ return await this.runQuery(built.sql, built.params);
1048
1257
  }
1049
1258
  /** Count rows in a table, optionally filtered. */
1050
1259
  async count(spec) {
1051
1260
  const built = buildCount(spec);
1052
- const result = await this.query(built.sql, built.params);
1261
+ const result = await this.runQuery(built.sql, built.params);
1053
1262
  return result.rows[0]?.COUNT ?? 0;
1054
1263
  }
1055
1264
  /** Insert a single row. */
1056
1265
  async insertInto(schema, table, values) {
1057
1266
  const built = buildInsert(schema, table, values);
1058
- return await this.execute(built.sql, built.params);
1267
+ return await this.runExecute(built.sql, built.params);
1059
1268
  }
1060
1269
  /** Update rows matching a non-empty `where` filter. */
1061
1270
  async update(schema, table, values, where) {
1062
1271
  const built = buildUpdate(schema, table, values, where);
1063
- return await this.execute(built.sql, built.params);
1272
+ return await this.runExecute(built.sql, built.params);
1064
1273
  }
1065
1274
  /** Delete rows matching a non-empty `where` filter. */
1066
1275
  async deleteFrom(schema, table, where) {
1067
1276
  const built = buildDelete(schema, table, where);
1068
- return await this.execute(built.sql, built.params);
1277
+ return await this.runExecute(built.sql, built.params);
1069
1278
  }
1070
1279
  /** Run `work` inside a transaction, auto-committing on success. */
1071
1280
  async transaction(work) {
@@ -1108,21 +1317,35 @@ var HanaClient = class _HanaClient {
1108
1317
  /** Return the HANA execution plan for a statement. */
1109
1318
  async explain(sql, params) {
1110
1319
  return await this.pool.withConnection(async (connection) => {
1111
- const statementName = `cf_hana_${String(Date.now())}`;
1112
- await connection.execute(
1320
+ connection.assertAllowed(sql);
1321
+ const statementName = nextExplainStatementName();
1322
+ await connection.executeInternal(
1113
1323
  `EXPLAIN PLAN SET STATEMENT_NAME = '${statementName}' FOR ${sql}`,
1114
1324
  params
1115
1325
  );
1116
- try {
1117
- return await connection.query(
1118
- "SELECT OPERATOR_NAME, TABLE_NAME, TABLE_TYPE, EXECUTION_ENGINE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ? ORDER BY OPERATOR_ID",
1326
+ const cleanup = async () => {
1327
+ await connection.executeInternal(
1328
+ "DELETE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ?",
1119
1329
  [statementName]
1120
1330
  );
1121
- } finally {
1122
- await connection.execute(
1123
- "DELETE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ?",
1331
+ };
1332
+ let queryCompleted = false;
1333
+ try {
1334
+ const result = await connection.query(
1335
+ "SELECT OPERATOR_NAME, TABLE_NAME, TABLE_TYPE, EXECUTION_ENGINE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ? ORDER BY OPERATOR_ID",
1124
1336
  [statementName]
1125
1337
  );
1338
+ queryCompleted = true;
1339
+ await cleanup();
1340
+ return result;
1341
+ } catch (error) {
1342
+ if (!queryCompleted) {
1343
+ try {
1344
+ await cleanup();
1345
+ } catch {
1346
+ }
1347
+ }
1348
+ throw error;
1126
1349
  }
1127
1350
  });
1128
1351
  }
@@ -1130,6 +1353,35 @@ var HanaClient = class _HanaClient {
1130
1353
  async close() {
1131
1354
  await this.pool.drain();
1132
1355
  }
1356
+ async runQuery(sql, params, options) {
1357
+ return await this.pool.withConnection(
1358
+ (connection) => connection.query(sql, params, options)
1359
+ );
1360
+ }
1361
+ async runExecute(sql, params, options) {
1362
+ return await this.pool.withConnection(
1363
+ (connection) => connection.execute(sql, params, options)
1364
+ );
1365
+ }
1366
+ async recordSqlHistory(operation, sql, params, result) {
1367
+ try {
1368
+ await appendSqlHistory({
1369
+ version: CLI_VERSION,
1370
+ operation,
1371
+ selector: this.info.selector,
1372
+ appName: this.info.appName,
1373
+ schema: this.info.schema,
1374
+ role: this.info.role,
1375
+ statement: result.statement,
1376
+ sql,
1377
+ paramCount: params.length,
1378
+ rowCount: result.rowCount,
1379
+ truncated: result.truncated,
1380
+ elapsedMs: result.elapsedMs
1381
+ });
1382
+ } catch {
1383
+ }
1384
+ }
1133
1385
  };
1134
1386
 
1135
1387
  // src/api.ts