tina4-nodejs 3.13.86 → 3.13.88

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.
@@ -3415,11 +3415,32 @@ var init_request = __esm({
3415
3415
  var engine_exports = {};
3416
3416
  __export(engine_exports, {
3417
3417
  Frond: () => Frond,
3418
+ TEMPLATE_CACHE_MAX: () => TEMPLATE_CACHE_MAX,
3418
3419
  setFormTokenSessionId: () => setFormTokenSessionId
3419
3420
  });
3420
3421
  import { createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes2 } from "node:crypto";
3421
3422
  import { readFileSync as readFileSync4, existsSync as existsSync6, statSync as statSync4 } from "node:fs";
3422
3423
  import { join as join5, resolve as resolve2 } from "node:path";
3424
+ function jsonText(value) {
3425
+ try {
3426
+ const text = JSON.stringify(value);
3427
+ return text === void 0 ? "null" : text;
3428
+ } catch {
3429
+ const seen = /* @__PURE__ */ new WeakSet();
3430
+ const text = JSON.stringify(value, (_key, v) => {
3431
+ if (typeof v === "bigint") return v.toString();
3432
+ if (typeof v === "object" && v !== null) {
3433
+ if (seen.has(v)) return null;
3434
+ seen.add(v);
3435
+ }
3436
+ return v;
3437
+ });
3438
+ return text === void 0 ? "null" : text;
3439
+ }
3440
+ }
3441
+ function jsonSafe(value) {
3442
+ return new SafeString(jsonText(value).replace(JSON_UNSAFE_RE, (c) => JSON_UNSAFE_MAP[c]));
3443
+ }
3423
3444
  function inspectValue(value, seen = /* @__PURE__ */ new WeakSet(), depth = 0) {
3424
3445
  if (value === null) return "null";
3425
3446
  if (value === void 0) return "undefined";
@@ -3490,6 +3511,14 @@ function renderDump(value) {
3490
3511
  function liveAttr(value) {
3491
3512
  return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3492
3513
  }
3514
+ function capCache(cache, maxEntries) {
3515
+ if (cache.size < maxEntries) return;
3516
+ let drop = Math.floor(maxEntries / 2);
3517
+ for (const key of cache.keys()) {
3518
+ cache.delete(key);
3519
+ if (--drop <= 0) break;
3520
+ }
3521
+ }
3493
3522
  function tokenize(source) {
3494
3523
  const rawBlocks = [];
3495
3524
  source = source.replace(RAW_BLOCK_RE, (_match, content) => {
@@ -3841,6 +3870,9 @@ function evalExpr(expr, context) {
3841
3870
  }).join("");
3842
3871
  }
3843
3872
  }
3873
+ if (expr.startsWith("not ")) {
3874
+ return evalComparison(expr, context);
3875
+ }
3844
3876
  for (const op of [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "]) {
3845
3877
  if (findOutsideQuotes(expr, op) >= 0) {
3846
3878
  return evalComparison(expr, context);
@@ -4396,7 +4428,7 @@ function _generateFormToken(descriptor = "") {
4396
4428
  function _generateFormTokenValue(descriptor = "") {
4397
4429
  return new SafeString(_buildFormTokenJwt(descriptor));
4398
4430
  }
4399
- var SafeString, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
4431
+ var SafeString, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
4400
4432
  var init_engine = __esm({
4401
4433
  "../frond/src/engine.ts"() {
4402
4434
  "use strict";
@@ -4408,6 +4440,15 @@ var init_engine = __esm({
4408
4440
  return this.value;
4409
4441
  }
4410
4442
  };
4443
+ JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
4444
+ JSON_UNSAFE_MAP = {
4445
+ "<": "\\u003c",
4446
+ ">": "\\u003e",
4447
+ "&": "\\u0026",
4448
+ "'": "\\u0027",
4449
+ "\u2028": "\\u2028",
4450
+ "\u2029": "\\u2029"
4451
+ };
4411
4452
  NUMERIC_RE = /^-?\d+(\.\d+)?$/;
4412
4453
  METHOD_CALL_RE = /^(\w+)\s*\(([\s\S]*)?\)$/;
4413
4454
  FN_CALL_RE = /^([\w.]+)\s*\(([\s\S]*)?\)$/;
@@ -4429,6 +4470,7 @@ var init_engine = __esm({
4429
4470
  LIVE_SRC_RE = /src\s+["']([^"']+)["']/;
4430
4471
  filterChainCache = /* @__PURE__ */ new Map();
4431
4472
  pathParseCache = /* @__PURE__ */ new Map();
4473
+ TEMPLATE_CACHE_MAX = 256;
4432
4474
  TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
4433
4475
  RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g;
4434
4476
  VarRef = class {
@@ -4497,7 +4539,7 @@ var init_engine = __esm({
4497
4539
  int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
4498
4540
  float: (v) => v ? parseFloat(String(v)) || 0 : 0,
4499
4541
  string: (v) => String(v),
4500
- json_encode: (v) => JSON.stringify(v),
4542
+ json_encode: (v) => jsonSafe(v),
4501
4543
  json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
4502
4544
  keys: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.keys(v) : [],
4503
4545
  values: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.values(v) : [],
@@ -4642,8 +4684,12 @@ var init_engine = __esm({
4642
4684
  form_token: (v) => _generateFormToken(v != null ? String(v) : ""),
4643
4685
  formTokenValue: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
4644
4686
  form_token_value: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
4645
- tojson: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
4646
- to_json: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
4687
+ // Same serializer as json_encode -- the three names are one behaviour. The
4688
+ // old indent argument is gone: PHP cannot honour an arbitrary indent
4689
+ // (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
4690
+ // broke byte-parity for the one filter whose whole job is a wire format.
4691
+ tojson: (v) => jsonSafe(v),
4692
+ to_json: (v) => jsonSafe(v),
4647
4693
  js_escape: (v) => new SafeString(String(v).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"))
4648
4694
  };
4649
4695
  _formTokenSessionId = "";
@@ -4834,6 +4880,7 @@ var init_engine = __esm({
4834
4880
  const source = readFileSync4(filePath, "utf-8");
4835
4881
  const mtime = statSync4(filePath).mtimeMs;
4836
4882
  const tokens = tokenize(source);
4883
+ capCache(this.compiled, TEMPLATE_CACHE_MAX);
4837
4884
  this.compiled.set(template, { tokens, mtime, cachedAt: Date.now() });
4838
4885
  return this.executeWithSource(source, tokens, context);
4839
4886
  }
@@ -4848,6 +4895,7 @@ var init_engine = __esm({
4848
4895
  }
4849
4896
  }
4850
4897
  const tokens = tokenize(source);
4898
+ capCache(this.compiledStrings, TEMPLATE_CACHE_MAX);
4851
4899
  this.compiledStrings.set(key, { tokens, cachedAt: Date.now() });
4852
4900
  return this.executeCached(tokens, context);
4853
4901
  }
@@ -5063,6 +5111,9 @@ var init_engine = __esm({
5063
5111
  } else if (tag === "macro") {
5064
5112
  const skip = this.handleMacro(tokens, i, context);
5065
5113
  i = skip;
5114
+ } else if (tag === "import") {
5115
+ this.handleImportAs(content, context);
5116
+ i++;
5066
5117
  } else if (tag === "from") {
5067
5118
  this.handleFromImport(content, context);
5068
5119
  i++;
@@ -5378,7 +5429,7 @@ var init_engine = __esm({
5378
5429
  value = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.values(value) : [];
5379
5430
  continue;
5380
5431
  case "json_encode":
5381
- value = JSON.stringify(value);
5432
+ value = jsonSafe(value);
5382
5433
  continue;
5383
5434
  case "dump":
5384
5435
  value = renderDump(value);
@@ -5629,7 +5680,7 @@ var init_engine = __esm({
5629
5680
  return i2;
5630
5681
  }
5631
5682
  const macroName = m[1];
5632
- const paramNames = m[2].split(",").map((p) => p.trim()).filter(Boolean);
5683
+ const params = _Frond.parseMacroParams(m[2]);
5633
5684
  const bodyTokens = [];
5634
5685
  let i = start2 + 1;
5635
5686
  while (i < tokens.length) {
@@ -5644,13 +5695,94 @@ var init_engine = __esm({
5644
5695
  const capturedContext = { ...context };
5645
5696
  context[macroName] = (...args) => {
5646
5697
  const macroCtx = { ...capturedContext };
5647
- for (let pi = 0; pi < paramNames.length; pi++) {
5648
- macroCtx[paramNames[pi]] = pi < args.length ? args[pi] : null;
5698
+ for (let pi = 0; pi < params.length; pi++) {
5699
+ const [pname, pdefault] = params[pi];
5700
+ macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
5649
5701
  }
5650
5702
  return new SafeString(engine.renderTokens([...bodyTokens], macroCtx));
5651
5703
  };
5652
5704
  return i;
5653
5705
  }
5706
+ /**
5707
+ * Parse a macro parameter list into [name, default] pairs.
5708
+ *
5709
+ * Handles: name, name="default", name='default'. Splitting on "," alone left a
5710
+ * defaulted parameter literally NAMED `greeting='Hello'`, so the body's
5711
+ * {{ greeting }} matched nothing (rendered empty) AND the caller's positional
5712
+ * argument was stored under that junk key and lost. Mirrors the Python master's
5713
+ * _parse_macro_params. The default is null when none is declared.
5714
+ */
5715
+ static parseMacroParams(rawParams) {
5716
+ return rawParams.split(",").map((p) => p.trim()).filter(Boolean).map((p) => {
5717
+ const eq = p.indexOf("=");
5718
+ if (eq === -1) return [p, null];
5719
+ const name = p.slice(0, eq).trim();
5720
+ let dflt = p.slice(eq + 1).trim();
5721
+ if (dflt.length >= 2 && (dflt.startsWith('"') && dflt.endsWith('"') || dflt.startsWith("'") && dflt.endsWith("'"))) {
5722
+ dflt = dflt.slice(1, -1);
5723
+ }
5724
+ return [name, dflt];
5725
+ });
5726
+ }
5727
+ /**
5728
+ * {% import "file" as alias %} -- load EVERY macro in a file under one namespace.
5729
+ *
5730
+ * The alias is bound as a plain object of macro functions, so {{ alias.greet(x) }}
5731
+ * resolves through the engine's existing dotted-call path and each macro keeps the
5732
+ * same argument binding, default handling and SafeString output as any other macro.
5733
+ * A namespace object (not a class) is deliberate: a function stored as a class
5734
+ * attribute binds as a method and would inject the namespace as the first argument,
5735
+ * which is exactly the argument-shift bug the Python master carried (fixed there
5736
+ * with types.SimpleNamespace). Both import forms must render identically.
5737
+ */
5738
+ handleImportAs(content, context) {
5739
+ const m = content.match(/^import\s+["'](.+?)["']\s+as\s+(\w+)/);
5740
+ if (!m) return;
5741
+ const filename = m[1];
5742
+ const alias = m[2];
5743
+ const namespace = {};
5744
+ const source = this.load(filename);
5745
+ const tokens = tokenize(source);
5746
+ let i = 0;
5747
+ while (i < tokens.length) {
5748
+ const [ttype, raw] = tokens[i];
5749
+ if (ttype === "BLOCK") {
5750
+ const [tagContent] = stripTag(raw);
5751
+ if ((tagContent.split(/\s+/)[0] || "") === "macro") {
5752
+ const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
5753
+ if (macroM) {
5754
+ const macroName = macroM[1];
5755
+ const params = _Frond.parseMacroParams(macroM[2]);
5756
+ const bodyTokens = [];
5757
+ i++;
5758
+ while (i < tokens.length) {
5759
+ if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
5760
+ i++;
5761
+ break;
5762
+ }
5763
+ bodyTokens.push(tokens[i]);
5764
+ i++;
5765
+ }
5766
+ const capturedBody = [...bodyTokens];
5767
+ const capturedParams = [...params];
5768
+ const capturedCtx = { ...context };
5769
+ const engine = this;
5770
+ namespace[macroName] = (...args) => {
5771
+ const macroCtx = { ...capturedCtx };
5772
+ for (let pi = 0; pi < capturedParams.length; pi++) {
5773
+ const [pname, pdefault] = capturedParams[pi];
5774
+ macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
5775
+ }
5776
+ return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
5777
+ };
5778
+ continue;
5779
+ }
5780
+ }
5781
+ }
5782
+ i++;
5783
+ }
5784
+ context[alias] = namespace;
5785
+ }
5654
5786
  handleFromImport(content, context) {
5655
5787
  const m = content.match(/^from\s+["'](.+?)["']\s+import\s+(.+)/);
5656
5788
  if (!m) return;
@@ -5668,7 +5800,7 @@ var init_engine = __esm({
5668
5800
  const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
5669
5801
  if (macroM && names.includes(macroM[1])) {
5670
5802
  const macroName = macroM[1];
5671
- const paramNames = macroM[2].split(",").map((p) => p.trim()).filter(Boolean);
5803
+ const paramNames = _Frond.parseMacroParams(macroM[2]);
5672
5804
  const bodyTokens = [];
5673
5805
  i++;
5674
5806
  while (i < tokens.length) {
@@ -5686,7 +5818,8 @@ var init_engine = __esm({
5686
5818
  context[macroName] = (...args) => {
5687
5819
  const macroCtx = { ...capturedCtx };
5688
5820
  for (let pi = 0; pi < capturedParams.length; pi++) {
5689
- macroCtx[capturedParams[pi]] = pi < args.length ? args[pi] : null;
5821
+ const [pname, pdefault] = capturedParams[pi];
5822
+ macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
5690
5823
  }
5691
5824
  return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
5692
5825
  };
@@ -8793,7 +8926,14 @@ function fullAnalysis(root = "src") {
8793
8926
  total_functions: allFunctions.length,
8794
8927
  avg_complexity: Math.round(avgCC * 100) / 100,
8795
8928
  avg_maintainability: Math.round(avgMI * 10) / 10,
8929
+ // Display-only: the top-15 for the "most complex functions" report.
8930
+ // Do NOT source offenders / --fail-on from this — capping here silently
8931
+ // hides the 16th+ over-threshold function from the gate. offenders()
8932
+ // reads "all_functions" (below) instead.
8796
8933
  most_complex_functions: allFunctions.slice(0, 15),
8934
+ // Full, uncapped, complexity-sorted list — offenders()/--fail-on use this
8935
+ // so no function over the complexity threshold ever escapes the gate.
8936
+ all_functions: allFunctions,
8797
8937
  file_metrics: fileMetrics,
8798
8938
  violations,
8799
8939
  dependency_graph: importGraph,
@@ -29953,18 +30093,21 @@ var init_postgres = __esm({
29953
30093
  }
29954
30094
  async startTransactionAsync() {
29955
30095
  await this.executeAsync("BEGIN");
30096
+ this._inTransaction = true;
29956
30097
  }
29957
30098
  commit() {
29958
30099
  throw new Error("Use commitAsync() for PostgreSQL.");
29959
30100
  }
29960
30101
  async commitAsync() {
29961
30102
  await this.executeAsync("COMMIT");
30103
+ this._inTransaction = false;
29962
30104
  }
29963
30105
  rollback() {
29964
30106
  throw new Error("Use rollbackAsync() for PostgreSQL.");
29965
30107
  }
29966
30108
  async rollbackAsync() {
29967
30109
  await this.executeAsync("ROLLBACK");
30110
+ this._inTransaction = false;
29968
30111
  }
29969
30112
  tables() {
29970
30113
  throw new Error("Use tablesAsync() for PostgreSQL.");
@@ -30983,10 +31126,35 @@ var init_firebird = __esm({
30983
31126
  translated = SQLTranslator.ilikeToLike(translated);
30984
31127
  return translated;
30985
31128
  }
31129
+ /**
31130
+ * The handle every statement runs on. While an explicit transaction is open
31131
+ * (startTransactionAsync set `this.transaction`), statements MUST run on that
31132
+ * transaction object so they are undone by rollbackAsync() / persisted by
31133
+ * commitAsync() — node-firebird's transaction exposes the same
31134
+ * query()/execute() as the connection. With no transaction open we run on
31135
+ * `this.db`, whose per-statement work auto-commits on the connection.
31136
+ *
31137
+ * This matches the Python master's contract (tina4_python/database/firebird.py):
31138
+ * there, ALL statements run on the single connection and start_transaction()
31139
+ * merely suppresses the per-statement autocommit in execute() so the batch
31140
+ * stays open until commit()/rollback(). node-firebird has no such suppression
31141
+ * hook — its `db.query/execute` always auto-commit — so the equivalent is to
31142
+ * route statements through the transaction object instead. Same observable
31143
+ * behaviour: an open transaction is atomic and rolls back cleanly.
31144
+ *
31145
+ * Previously every statement ran on `this.db` unconditionally, so the
31146
+ * transaction created by startTransactionAsync() never saw a single statement
31147
+ * — rollbackAsync() rolled back an EMPTY transaction and the already
31148
+ * auto-committed write survived (silent no-op). Twin of the PHP pdo_firebird
31149
+ * bug fixed in 3.13.86.
31150
+ */
31151
+ statementHandle() {
31152
+ return this.transaction ?? this.db;
31153
+ }
30986
31154
  queryPromise(sql, params) {
30987
31155
  return new Promise((resolve21, reject) => {
30988
31156
  const translated = this.translateSql(sql);
30989
- this.db.query(translated, params ?? [], (err, result) => {
31157
+ this.statementHandle().query(translated, params ?? [], (err, result) => {
30990
31158
  if (err) reject(err);
30991
31159
  else resolve21(result ?? []);
30992
31160
  });
@@ -30995,7 +31163,7 @@ var init_firebird = __esm({
30995
31163
  executePromise(sql, params) {
30996
31164
  return new Promise((resolve21, reject) => {
30997
31165
  const translated = this.translateSql(sql);
30998
- this.db.execute(translated, params ?? [], (err) => {
31166
+ this.statementHandle().execute(translated, params ?? [], (err) => {
30999
31167
  if (err) reject(err);
31000
31168
  else resolve21();
31001
31169
  });
@@ -32969,14 +33137,15 @@ var init_database = __esm({
32969
33137
  async executeMany(sql, paramSets = []) {
32970
33138
  const adapter = this.getNextAdapter();
32971
33139
  const results = [];
32972
- await adapterStartTransaction(adapter);
33140
+ const owns = !this.inExplicitTransaction();
33141
+ if (owns) await adapterStartTransaction(adapter);
32973
33142
  try {
32974
33143
  for (const params of paramSets) {
32975
33144
  results.push(await adapterExecute(adapter, sql, params));
32976
33145
  }
32977
- await adapterCommit(adapter);
33146
+ if (owns) await adapterCommit(adapter);
32978
33147
  } catch (e) {
32979
- await adapterRollback(adapter);
33148
+ if (owns) await adapterRollback(adapter);
32980
33149
  throw e;
32981
33150
  }
32982
33151
  return results;
@@ -236,10 +236,36 @@ export class FirebirdAdapter implements DatabaseAdapter {
236
236
  return translated;
237
237
  }
238
238
 
239
+ /**
240
+ * The handle every statement runs on. While an explicit transaction is open
241
+ * (startTransactionAsync set `this.transaction`), statements MUST run on that
242
+ * transaction object so they are undone by rollbackAsync() / persisted by
243
+ * commitAsync() — node-firebird's transaction exposes the same
244
+ * query()/execute() as the connection. With no transaction open we run on
245
+ * `this.db`, whose per-statement work auto-commits on the connection.
246
+ *
247
+ * This matches the Python master's contract (tina4_python/database/firebird.py):
248
+ * there, ALL statements run on the single connection and start_transaction()
249
+ * merely suppresses the per-statement autocommit in execute() so the batch
250
+ * stays open until commit()/rollback(). node-firebird has no such suppression
251
+ * hook — its `db.query/execute` always auto-commit — so the equivalent is to
252
+ * route statements through the transaction object instead. Same observable
253
+ * behaviour: an open transaction is atomic and rolls back cleanly.
254
+ *
255
+ * Previously every statement ran on `this.db` unconditionally, so the
256
+ * transaction created by startTransactionAsync() never saw a single statement
257
+ * — rollbackAsync() rolled back an EMPTY transaction and the already
258
+ * auto-committed write survived (silent no-op). Twin of the PHP pdo_firebird
259
+ * bug fixed in 3.13.86.
260
+ */
261
+ private statementHandle(): any {
262
+ return this.transaction ?? this.db;
263
+ }
264
+
239
265
  private queryPromise(sql: string, params?: unknown[]): Promise<any[]> {
240
266
  return new Promise((resolve, reject) => {
241
267
  const translated = this.translateSql(sql);
242
- this.db.query(translated, params ?? [], (err: Error | null, result: any[]) => {
268
+ this.statementHandle().query(translated, params ?? [], (err: Error | null, result: any[]) => {
243
269
  if (err) reject(err);
244
270
  else resolve(result ?? []);
245
271
  });
@@ -249,7 +275,7 @@ export class FirebirdAdapter implements DatabaseAdapter {
249
275
  private executePromise(sql: string, params?: unknown[]): Promise<void> {
250
276
  return new Promise((resolve, reject) => {
251
277
  const translated = this.translateSql(sql);
252
- this.db.execute(translated, params ?? [], (err: Error | null) => {
278
+ this.statementHandle().execute(translated, params ?? [], (err: Error | null) => {
253
279
  if (err) reject(err);
254
280
  else resolve();
255
281
  });
@@ -325,6 +325,12 @@ export class PostgresAdapter implements DatabaseAdapter {
325
325
 
326
326
  async startTransactionAsync(): Promise<void> {
327
327
  await this.executeAsync("BEGIN");
328
+ // Mark the connection as inside an explicit transaction so executeManyAsync's
329
+ // owns-guard (owns = !_inTransaction) joins THIS transaction instead of
330
+ // opening its own inner BEGIN/COMMIT (which would commit this outer
331
+ // transaction early and defeat a later rollback). Mirrors the Python master
332
+ // (tina4_python/database/postgres.py start_transaction -> _in_transaction = True).
333
+ this._inTransaction = true;
328
334
  }
329
335
 
330
336
  commit(): void {
@@ -333,6 +339,9 @@ export class PostgresAdapter implements DatabaseAdapter {
333
339
 
334
340
  async commitAsync(): Promise<void> {
335
341
  await this.executeAsync("COMMIT");
342
+ // Transaction closed — clear the flag so subsequent standalone batches own
343
+ // their own transaction again (parity with Python master commit()).
344
+ this._inTransaction = false;
336
345
  }
337
346
 
338
347
  rollback(): void {
@@ -341,6 +350,8 @@ export class PostgresAdapter implements DatabaseAdapter {
341
350
 
342
351
  async rollbackAsync(): Promise<void> {
343
352
  await this.executeAsync("ROLLBACK");
353
+ // Transaction closed — clear the flag (parity with Python master rollback()).
354
+ this._inTransaction = false;
344
355
  }
345
356
 
346
357
  tables(): string[] {
@@ -962,14 +962,26 @@ export class Database {
962
962
  const adapter = this.getNextAdapter();
963
963
  const results: unknown[] = [];
964
964
 
965
- await adapterStartTransaction(adapter);
965
+ // Own the batch transaction ONLY when not already inside a caller's explicit
966
+ // transaction. inExplicitTransaction() is true when startTransaction() has
967
+ // pinned an adapter to this async context (getNextAdapter() returns that same
968
+ // pinned connection). If we owned a BEGIN/COMMIT here regardless, the inner
969
+ // COMMIT would commit the caller's OUTER transaction early and their later
970
+ // rollback() would undo nothing (the batch rows survive). So: standalone
971
+ // batch -> own BEGIN/COMMIT (atomic, all-or-nothing); nested batch -> join
972
+ // the caller's transaction and let their commit/rollback decide. Mirrors the
973
+ // sibling execute()/insert()/update()/delete() owns-guard, the PostgreSQL
974
+ // adapter's executeManyAsync owns-guard, and the Python master
975
+ // (Database.execute_many delegating to adapter.execute_many's owns_txn guard).
976
+ const owns = !this.inExplicitTransaction();
977
+ if (owns) await adapterStartTransaction(adapter);
966
978
  try {
967
979
  for (const params of paramSets) {
968
980
  results.push(await adapterExecute(adapter, sql, params));
969
981
  }
970
- await adapterCommit(adapter);
982
+ if (owns) await adapterCommit(adapter);
971
983
  } catch (e) {
972
- await adapterRollback(adapter);
984
+ if (owns) await adapterRollback(adapter);
973
985
  throw e;
974
986
  }
975
987