turbine-orm 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -783,7 +783,12 @@ class TurbineClient {
783
783
  try {
784
784
  await client.query(this.dialect.beginStatement());
785
785
  began = true;
786
- const result = await fn(client);
786
+ // Engine seam: single-writer engines scope their transaction re-entrancy
787
+ // marker to the callback's async subtree (see
788
+ // PgCompatPoolClient.wrapTransactionCallback). Absent everywhere else.
789
+ const wrap = client.wrapTransactionCallback;
790
+ // `.call` erases the generic, so the callback's Promise<T> is re-asserted.
791
+ const result = wrap ? (await wrap.call(client, () => fn(client))) : await fn(client);
787
792
  await client.query(this.dialect.commitStatement());
788
793
  return result;
789
794
  }
@@ -876,6 +881,12 @@ class TurbineClient {
876
881
  }
877
882
  }
878
883
  let result;
884
+ // Engine seam: when the checked-out connection exposes
885
+ // wrapTransactionCallback (single-writer engines such as PowDB), run the user
886
+ // callback through it so the engine can scope its re-entrancy marker to
887
+ // the callback's async subtree. All other drivers: plain fn(tx).
888
+ const wrap = client.wrapTransactionCallback;
889
+ const runCallback = () => (wrap ? wrap.call(client, () => fn(tx)) : fn(tx));
879
890
  if (timeout) {
880
891
  // Race between the function and a timeout. If the timeout fires we
881
892
  // need to actually abort the in-flight query — otherwise the backend
@@ -897,14 +908,14 @@ class TurbineClient {
897
908
  }, timeout);
898
909
  });
899
910
  try {
900
- result = await Promise.race([fn(tx), timeoutPromise]);
911
+ result = await Promise.race([runCallback(), timeoutPromise]);
901
912
  }
902
913
  finally {
903
914
  clearTimeout(timer);
904
915
  }
905
916
  }
906
917
  else {
907
- result = await fn(tx);
918
+ result = await runCallback();
908
919
  }
909
920
  await client.query(this.dialect.commitStatement());
910
921
  if (this.logging) {
package/dist/cjs/mssql.js CHANGED
@@ -756,11 +756,32 @@ function buildForJsonSubquery(dialect, ctx) {
756
756
  if (hasOrder) {
757
757
  const orderBy = orderEntries
758
758
  .map(([k, dir]) => {
759
- const col = (0, schema_js_1.camelToSnake)(k);
759
+ // FOR JSON nested ordering supports plain directions and { sort }
760
+ // specs only. Object shapes the core builder compiles with params
761
+ // (JSON-path / vector / relation ordering) must throw here: the
762
+ // shared param-collect mirror is gated on the native path, so a
763
+ // silently-ignored object would desync SQL text from params.
764
+ let rawDir = dir;
765
+ if (typeof dir === 'object' && dir !== null) {
766
+ const sortValue = dir.sort;
767
+ if (typeof sortValue !== 'string') {
768
+ throw new errors_js_1.ValidationError(`[turbine] Nested orderBy on "${k}" (table "${targetTable}"): only plain directions and ` +
769
+ `{ sort } specs are supported inside a relation orderBy on SQL Server.`);
770
+ }
771
+ if (dir.nulls !== undefined) {
772
+ throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST/LAST ordering', 'sqlserver', 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
773
+ }
774
+ rawDir = sortValue;
775
+ }
776
+ // columnMap-first resolution (camelToSnake fallback): matches the
777
+ // core builder's nested orderBy path so camelCase-named DB columns
778
+ // resolve on SQL Server too.
779
+ const col = targetMeta.columnMap[k] ?? (0, schema_js_1.camelToSnake)(k);
760
780
  if (!targetMeta.allColumns.includes(col)) {
761
- throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
781
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${k}" in orderBy on table "${targetTable}". ` +
782
+ `Known fields: ${Object.keys(targetMeta.columnMap).join(', ') || '(none)'}.`);
762
783
  }
763
- const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
784
+ const safeDir = String(rawDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
764
785
  return `${a}.${q(col)} ${safeDir}`;
765
786
  })
766
787
  .join(', ');
package/dist/cjs/powdb.js CHANGED
@@ -518,6 +518,11 @@ function wrapPowdbError(err) {
518
518
  const m = /column ['"]?(\w+)['"]?/i.exec(msg);
519
519
  return new errors_js_1.NotNullViolationError({ column: m?.[1], cause: err });
520
520
  }
521
+ // Driver pool lifecycle errors (acquire after close, acquire timeout) carry
522
+ // no .code — classify by message so both transports surface E004.
523
+ if (/pool closed|pool acquire timeout/i.test(msg)) {
524
+ return new errors_js_1.ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`);
525
+ }
521
526
  // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
522
527
  // connection held the single global write lock past the server's
523
528
  // --tx-wait-timeout-ms. Retryable timeout, not a query defect.
@@ -595,6 +600,13 @@ function reentrantTransactionError() {
595
600
  * waits without limit.
596
601
  */
597
602
  exports.DEFAULT_TX_QUEUE_TIMEOUT_MS = 30_000;
603
+ /**
604
+ * Upper bound on the best-effort `rollback` a release-with-open-hold fires
605
+ * before handing the gate to the next queued transaction. Keeps a dead socket
606
+ * from wedging the FIFO queue while still letting the engine drop its global
607
+ * write lock cleanly in the normal case.
608
+ */
609
+ const RELEASE_ROLLBACK_TIMEOUT_MS = 2_000;
598
610
  const powdbTxStorage = new node_async_hooks_1.AsyncLocalStorage();
599
611
  /**
600
612
  * FIFO gate serializing transactions across a whole pool. PowDB holds one
@@ -612,25 +624,35 @@ const powdbTxStorage = new node_async_hooks_1.AsyncLocalStorage();
612
624
  * timeout, then returns a {@link PowdbTxHold} the caller finishes on
613
625
  * commit / rollback / connection release.
614
626
  *
615
- * Context propagation relies on `AsyncLocalStorage.enterWith()` running in the
616
- * caller's *synchronous* execution: the pools call `acquire()` from the
617
- * synchronous prologue of `query('begin')`, so when `TurbineClient.$transaction`
618
- * awaits that begin, its continuation and therefore the user callback — runs
619
- * with the marker set, while sibling contexts (concurrent transactions, the
620
- * caller after `$transaction` resolves) captured their snapshots earlier and
621
- * never see it. Markers form a chain ({@link PowdbTxContext.parent}) so
622
- * transactions nested across DIFFERENT pools cannot shadow an outer marker on
623
- * this gate the re-entrancy check walks every live ancestor.
627
+ * Context propagation: `acquire()` only READS the async context (the
628
+ * chain-walking check in its prologue); it never writes it. The marker is
629
+ * planted by the pool's `wrapTransactionCallback` (`TurbineClient` invokes
630
+ * the user callback as `powdbTxStorage.run(hold.ctx, fn)`), so it exists
631
+ * exclusively inside the transaction CALLBACK's async subtree. Everything
632
+ * launched from inside the callback (table ops on the `tx` client, a
633
+ * fire-and-forget `db.$transaction`, nested-write implicit transactions)
634
+ * inherits it; the CALLER's context stays unmarked. This is load-bearing: the
635
+ * pre-0.31 implementation used `enterWith()` in acquire's prologue, which
636
+ * mutates the caller's shared context. On a cold client the FIRST same-tick
637
+ * burst of `db.$transaction` calls saw call #1's live marker from every
638
+ * sibling and falsely threw re-entrant E017 (9/10 rejected in production;
639
+ * one warm-up transaction masked it because its pruned `done` marker changed
640
+ * the propagation shape). With `run()` the sibling contexts are unmarked by
641
+ * construction, so they queue FIFO as intended. Markers form a chain
642
+ * ({@link PowdbTxContext.parent}) so transactions nested across DIFFERENT
643
+ * pools cannot shadow an outer marker on this gate; the re-entrancy check
644
+ * walks every live ancestor.
624
645
  *
625
- * **Residual limitation:** a re-entrant begin issued from an async context
626
- * created BEFORE the transaction opened (e.g. a job-queue worker loop whose
627
- * continuations captured their ALS snapshot up front) carries no marker, so it
628
- * cannot be told apart from a legitimate independent concurrent transaction.
629
- * It queues FIFO and because the open transaction is awaiting it times out
630
- * after `transactionQueueTimeoutMs` with a typed {@link TimeoutError} rather
631
- * than throwing E017 instantly. The 30s default is the backstop for exactly
632
- * this case: do not set `transactionQueueTimeoutMs: 0` (wait forever) in code
633
- * paths that may start transactions from pre-existing async contexts.
646
+ * **Residual limitation:** a transaction begun OUTSIDE a `$transaction`
647
+ * callback plants no marker (a manual raw `begin` span, or a worker loop
648
+ * whose continuations captured their context before the transaction opened).
649
+ * A deadlocking re-entrant begin from such a context cannot be told apart
650
+ * from a legitimate independent concurrent transaction: it queues FIFO and,
651
+ * because the open transaction is awaiting it, times out after
652
+ * `transactionQueueTimeoutMs` with a typed {@link TimeoutError} rather than
653
+ * throwing E017 instantly. The 30s default is the backstop for exactly this
654
+ * case: do not set `transactionQueueTimeoutMs: 0` (wait forever) in code
655
+ * paths that may start transactions from unmarked contexts.
634
656
  */
635
657
  class PowdbTxGate {
636
658
  queueTimeoutMs;
@@ -640,12 +662,14 @@ class PowdbTxGate {
640
662
  this.queueTimeoutMs = queueTimeoutMs;
641
663
  }
642
664
  /**
643
- * Take a place in the transaction queue. MUST be invoked in the same
644
- * synchronous execution as the caller's `begin` (no `await` before it) so
645
- * the re-entrancy marker propagates into the transaction's async scope.
665
+ * Take a place in the transaction queue. The prologue walks the caller's
666
+ * marker chain (planted around transaction callbacks by the pool's
667
+ * `wrapTransactionCallback`) and throws re-entrant E017 when any live
668
+ * ancestor holds THIS gate. It never marks the caller's context itself;
669
+ * the returned hold carries the fresh marker (`hold.ctx`) for
670
+ * `wrapTransactionCallback` to scope around the user callback.
646
671
  */
647
672
  async acquire() {
648
- // --- synchronous section (runs before the caller's first await) ---
649
673
  // Walk the WHOLE marker chain, not just the innermost marker: with two
650
674
  // pools, dbA-tx → dbB-tx → dbA-begin leaves dbB's marker innermost, but
651
675
  // the dbA ancestor is still open — queueing the inner dbA begin behind it
@@ -662,7 +686,6 @@ class PowdbTxGate {
662
686
  }
663
687
  }
664
688
  const ctx = { gate: this, done: false, parent };
665
- powdbTxStorage.enterWith(ctx);
666
689
  let handOff;
667
690
  const finished = new Promise((resolve) => {
668
691
  handOff = resolve;
@@ -670,6 +693,7 @@ class PowdbTxGate {
670
693
  const ahead = this.tail;
671
694
  this.tail = ahead.then(() => finished);
672
695
  const hold = {
696
+ ctx,
673
697
  finish: () => {
674
698
  if (ctx.done)
675
699
  return;
@@ -747,6 +771,14 @@ class PowdbPool {
747
771
  txGate;
748
772
  /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
749
773
  poolHold = null;
774
+ /**
775
+ * Clients currently checked out via {@link connect}. The driver pool's
776
+ * `close()` only closes IDLE clients (checked-out ones are documented as the
777
+ * caller's responsibility), so {@link end} destroys these explicitly;
778
+ * otherwise a `disconnect()` racing an unreleased connection would leave a
779
+ * live socket holding the process open until the server's idle timeout.
780
+ */
781
+ checkedOut = new Set();
750
782
  constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
751
783
  this.pool = pool;
752
784
  this.toParam = toParam;
@@ -754,12 +786,13 @@ class PowdbPool {
754
786
  }
755
787
  // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
756
788
  async query(text, values) {
789
+ this.assertOpen();
757
790
  const { text: powql, params } = normalizeQueryArgs(text, values);
758
791
  const ctl = txControl(powql);
759
792
  if (ctl === 'begin') {
760
- // Gate BEFORE touching the engine: a re-entrant begin throws fast, an
761
- // independent concurrent one waits its FIFO turn. (acquire()'s
762
- // re-entrancy check + context mark run synchronously right here.)
793
+ // Gate BEFORE touching the engine: a begin from inside an active
794
+ // transaction callback throws re-entrant E017 fast; an independent
795
+ // concurrent one waits its FIFO turn.
763
796
  this.poolHold = await this.txGate.acquire();
764
797
  }
765
798
  if ((ctl === 'commit' || ctl === 'rollback') && this.poolHold === null) {
@@ -787,8 +820,26 @@ class PowdbPool {
787
820
  }
788
821
  }
789
822
  }
823
+ /**
824
+ * Typed guard mirroring {@link PowdbEmbeddedPool}: after `end()` the driver
825
+ * pool throws a raw `Error('pool closed')` that {@link wrapPowdbError}
826
+ * cannot classify — surface the same ConnectionError on both transports.
827
+ */
828
+ assertOpen() {
829
+ if (this.closed) {
830
+ throw new errors_js_1.ConnectionError('[turbine] The PowDB pool is closed — disconnect() was already called on this client.');
831
+ }
832
+ }
790
833
  async connect() {
791
- const client = await this.pool.acquire();
834
+ this.assertOpen();
835
+ let client;
836
+ try {
837
+ client = await this.pool.acquire();
838
+ }
839
+ catch (err) {
840
+ throw wrapPowdbError(err);
841
+ }
842
+ this.checkedOut.add(client);
792
843
  let broken = false;
793
844
  /** The gate hold of the transaction begun through THIS connection (if any). */
794
845
  let hold = null;
@@ -810,9 +861,10 @@ class PowdbPool {
810
861
  const { text: powql, params } = normalizeQueryArgs(text, values);
811
862
  const ctl = txControl(powql);
812
863
  if (ctl === 'begin') {
813
- // Gate BEFORE hitting the engine a re-entrant begin throws fast
814
- // instead of blocking on the global write lock the open tx holds;
815
- // an independent concurrent begin queues FIFO.
864
+ // Gate BEFORE hitting the engine: a begin from inside an active
865
+ // transaction callback throws re-entrant E017 fast instead of
866
+ // blocking on the global write lock the open tx holds; an
867
+ // independent concurrent begin queues FIFO.
816
868
  hold = await this.txGate.acquire();
817
869
  }
818
870
  if ((ctl === 'commit' || ctl === 'rollback') && hold === null) {
@@ -840,16 +892,60 @@ class PowdbPool {
840
892
  }
841
893
  }
842
894
  },
843
- release: () => {
844
- // Releasing this connection ends its transaction scope. Finish the
845
- // hold as a safety net so a tx torn down without an explicit
846
- // commit/rollback (e.g. a timeout that destroys the connection) hands
847
- // the gate to the next queued transaction instead of wedging the
848
- // queue. Only THIS connection's hold releasing an unrelated (read)
849
- // connection never touches another transaction's slot.
850
- hold?.finish();
895
+ // Single-writer re-entrancy scoping: TurbineClient runs the user's
896
+ // transaction callback through this, so the gate's marker lives ONLY
897
+ // in the callback's async subtree (everything inside it, tx table ops
898
+ // and fire-and-forget db.$transaction alike, inherits it; the caller's
899
+ // context stays unmarked). `hold.ctx` is the same object `finish()` flips, so
900
+ // done-pruning keeps working for contexts that outlive the callback.
901
+ wrapTransactionCallback: (fn) => {
902
+ const ctx = hold?.ctx;
903
+ return ctx ? powdbTxStorage.run(ctx, fn) : fn();
904
+ },
905
+ release: (err) => {
906
+ // Releasing this connection ends its transaction scope. pg semantics:
907
+ // a truthy `err` means "destroy, don't re-idle" — client.ts's
908
+ // $transaction timeout path relies on that to keep an abandoned
909
+ // callback's connection out of the pool. Additionally, an OPEN hold
910
+ // here means the tx begun on this connection never saw commit/rollback
911
+ // (timeout teardown or caller bug): fire a best-effort bounded
912
+ // `rollback` FIRST so the engine drops its global write lock and the
913
+ // server-side transaction ends (destroying the socket alone leaves it
914
+ // open until the server's idle timeout), THEN hand the gate to the
915
+ // next queued transaction. If the rollback fails or times out the
916
+ // connection is treated as broken and destroyed. Never throws — a
917
+ // teardown error must not mask the transaction's real outcome.
918
+ const openHold = hold;
851
919
  hold = null;
852
- return broken ? this.pool.destroy(client) : this.pool.release(client);
920
+ this.checkedOut.delete(client);
921
+ const teardown = async () => {
922
+ let rolledBack = false;
923
+ if (openHold) {
924
+ try {
925
+ await Promise.race([
926
+ client.query('rollback', []).then(() => {
927
+ rolledBack = true;
928
+ }),
929
+ new Promise((resolve) => {
930
+ const t = setTimeout(resolve, RELEASE_ROLLBACK_TIMEOUT_MS);
931
+ t.unref?.();
932
+ }),
933
+ ]);
934
+ }
935
+ catch {
936
+ /* best-effort */
937
+ }
938
+ openHold.finish();
939
+ }
940
+ const mustDestroy = broken || Boolean(err) || (openHold !== null && !rolledBack);
941
+ try {
942
+ await (mustDestroy ? this.pool.destroy(client) : this.pool.release(client));
943
+ }
944
+ catch {
945
+ /* the pool may already be closed */
946
+ }
947
+ };
948
+ void teardown();
853
949
  },
854
950
  };
855
951
  }
@@ -857,7 +953,16 @@ class PowdbPool {
857
953
  if (this.closed)
858
954
  return;
859
955
  this.closed = true;
956
+ // close() rejects pending waiters and closes every IDLE client…
860
957
  await this.pool.close();
958
+ // …but NOT checked-out ones (documented in @zvndev/powdb-client: "callers
959
+ // that still hold one when close() is called are responsible for closing
960
+ // it themselves"). Destroy any stragglers so end() never leaves a live
961
+ // socket keeping the process alive until the server's idle timeout.
962
+ for (const client of this.checkedOut) {
963
+ this.pool.destroy(client);
964
+ }
965
+ this.checkedOut.clear();
861
966
  }
862
967
  }
863
968
  exports.PowdbPool = PowdbPool;
@@ -990,11 +1095,14 @@ class PowdbEmbeddedPool {
990
1095
  * transaction is holding.
991
1096
  */
992
1097
  async run(powql, params, holdRef) {
1098
+ if (this.closed) {
1099
+ throw new errors_js_1.ConnectionError('[turbine] The PowDB embedded pool is closed: disconnect() was already called on this client.');
1100
+ }
993
1101
  const ctl = txControl(powql);
994
1102
  if (ctl === 'begin') {
995
- // Gate BEFORE hitting the engine re-entrant begins throw fast,
996
- // independent concurrent ones wait their FIFO turn. (acquire()'s
997
- // re-entrancy check + context mark run synchronously right here.)
1103
+ // Gate BEFORE hitting the engine: a begin from inside an active
1104
+ // transaction callback throws re-entrant E017 fast; independent
1105
+ // concurrent ones wait their FIFO turn.
998
1106
  holdRef.hold = await this.txGate.acquire();
999
1107
  }
1000
1108
  if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
@@ -1040,11 +1148,31 @@ class PowdbEmbeddedPool {
1040
1148
  const { text: powql, params } = normalizeQueryArgs(text, values);
1041
1149
  return this.run(powql, params, holdRef);
1042
1150
  },
1151
+ // Scope the gate's re-entrancy marker to the user callback's async
1152
+ // subtree (see PowdbPool.connect(), identical contract).
1153
+ wrapTransactionCallback: (fn) => {
1154
+ const ctx = holdRef.hold?.ctx;
1155
+ return ctx ? powdbTxStorage.run(ctx, fn) : fn();
1156
+ },
1043
1157
  release: () => {
1044
1158
  // End-of-scope safety net (see PowdbPool.connect()): a tx torn down
1045
- // without an explicit commit/rollback must not wedge the queue.
1046
- holdRef.hold?.finish();
1047
- holdRef.hold = null;
1159
+ // without an explicit commit/rollback must not wedge the queue — and
1160
+ // on the ONE shared embedded handle its open engine transaction must
1161
+ // actually be rolled back before the gate moves on, or the next
1162
+ // transaction's work interleaves into it. run() owns the
1163
+ // finish/null-out in its commit/rollback finally; the .finally here
1164
+ // is the fallback when run() itself rejects.
1165
+ const h = holdRef.hold;
1166
+ if (!h)
1167
+ return;
1168
+ void this.run('rollback', [], holdRef)
1169
+ .catch(() => {
1170
+ /* best-effort */
1171
+ })
1172
+ .finally(() => {
1173
+ h.finish();
1174
+ holdRef.hold = null;
1175
+ });
1048
1176
  },
1049
1177
  };
1050
1178
  }
@@ -1052,8 +1180,11 @@ class PowdbEmbeddedPool {
1052
1180
  if (this.closed)
1053
1181
  return;
1054
1182
  // The addon exposes no explicit close — drop the reference and let GC /
1055
- // the engine's checkpoint flush. Caveat: durability is checkpoint-bound, so
1056
- // hold the process open long enough for the final WAL flush in short scripts.
1183
+ // the engine's checkpoint flush. Marking the pool closed makes later
1184
+ // queries fail with a typed ConnectionError instead of silently running
1185
+ // against a handle the caller believes is gone. Caveat: durability is
1186
+ // checkpoint-bound, so hold the process open long enough for the final
1187
+ // WAL flush in short scripts.
1057
1188
  this.closed = true;
1058
1189
  }
1059
1190
  }
@@ -1160,7 +1291,7 @@ async function turbinePowDB(target, schema, options = {}) {
1160
1291
  let owns = false;
1161
1292
  const poolOptions = { transactionQueueTimeoutMs: options.transactionQueueTimeoutMs };
1162
1293
  if (typeof target === 'string') {
1163
- const mod = await loadPowdb();
1294
+ const mod = options.powdbClientModule ?? (await loadPowdb());
1164
1295
  const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max: options.connectionLimit ?? 10 });
1165
1296
  await assertNetworkedVersion(clientPool);
1166
1297
  pool = new PowdbPool(clientPool, undefined, poolOptions);
@@ -1178,7 +1309,7 @@ async function turbinePowDB(target, schema, options = {}) {
1178
1309
  pool = new PowdbPool(target, undefined, poolOptions);
1179
1310
  }
1180
1311
  else {
1181
- const mod = await loadPowdb();
1312
+ const mod = options.powdbClientModule ?? (await loadPowdb());
1182
1313
  const clientPool = new mod.Pool({ ...target, max: options.connectionLimit ?? 10 });
1183
1314
  await assertNetworkedVersion(clientPool);
1184
1315
  pool = new PowdbPool(clientPool, undefined, poolOptions);
@@ -1196,7 +1327,23 @@ async function turbinePowDB(target, schema, options = {}) {
1196
1327
  warnOnUnlimited: options.warnOnUnlimited,
1197
1328
  queryInterfaceFactory,
1198
1329
  }, schema);
1199
- if (!owns) {
1330
+ if (owns) {
1331
+ // Turbine built this pool / embedded handle, so disconnect()/end() must
1332
+ // close it. client.ts sees TurbineConfig.pool as EXTERNAL (ownsPool =
1333
+ // false) and skips pool.end(); before this patch an owned networked
1334
+ // client leaked its live socket(s) on disconnect(), holding the process
1335
+ // open until powdb-server's idle timeout (~300s) closed them. Consistent
1336
+ // with turbineMssql's owned-pool patch.
1337
+ const baseDisconnect = client.disconnect.bind(client);
1338
+ const close = async () => {
1339
+ await baseDisconnect();
1340
+ await pool.end();
1341
+ };
1342
+ const patch = client;
1343
+ patch.disconnect = close;
1344
+ patch.end = close;
1345
+ }
1346
+ else {
1200
1347
  // Injected pool — the caller owns its lifecycle.
1201
1348
  client.disconnect = async () => { };
1202
1349
  }
package/dist/cjs/powql.js CHANGED
@@ -985,7 +985,13 @@ class PowqlInterface {
985
985
  const { TransactionClient } = await Promise.resolve().then(() => __importStar(require('./client.js')));
986
986
  const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
987
987
  const ctx = { schema: this.schema, tx: tx };
988
- const result = await fn(ctx);
988
+ // Plant the single-writer re-entrancy marker for the implicit tx's
989
+ // subtree (same seam TurbineClient.$transaction uses) — user code that
990
+ // fires db.$transaction from inside (e.g. $use middleware around a
991
+ // nested-write child op) must fast-fail E017, not queue into deadlock.
992
+ const wrap = client
993
+ .wrapTransactionCallback;
994
+ const result = await (wrap ? wrap(() => fn(ctx)) : fn(ctx));
989
995
  await client.query(d?.commitStatement?.() ?? 'commit');
990
996
  return result;
991
997
  }