turbine-orm 0.29.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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/cli/index.js +5 -0
  3. package/dist/cjs/cli/mcp.js +22 -92
  4. package/dist/cjs/client.js +47 -6
  5. package/dist/cjs/generate.js +71 -25
  6. package/dist/cjs/index.js +4 -1
  7. package/dist/cjs/introspect.js +350 -120
  8. package/dist/cjs/mssql.js +42 -136
  9. package/dist/cjs/mysql.js +16 -129
  10. package/dist/cjs/optional-peer-import.cjs +122 -0
  11. package/dist/cjs/powdb.js +579 -89
  12. package/dist/cjs/powql.js +56 -26
  13. package/dist/cjs/query/builder.js +601 -86
  14. package/dist/cjs/query/filters.js +80 -2
  15. package/dist/cjs/schema-metadata.js +316 -0
  16. package/dist/cjs/sqlite.js +8 -89
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +5 -0
  19. package/dist/cli/mcp.d.ts +18 -0
  20. package/dist/cli/mcp.js +22 -93
  21. package/dist/client.d.ts +19 -2
  22. package/dist/client.js +47 -6
  23. package/dist/generate.d.ts +16 -4
  24. package/dist/generate.js +71 -25
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.js +2 -0
  27. package/dist/introspect.d.ts +94 -1
  28. package/dist/introspect.js +345 -120
  29. package/dist/mssql.js +40 -104
  30. package/dist/mysql.js +14 -97
  31. package/dist/optional-peer-import.cjs +89 -0
  32. package/dist/optional-peer-import.d.cts +53 -0
  33. package/dist/powdb.d.ts +118 -23
  34. package/dist/powdb.js +574 -88
  35. package/dist/powql.d.ts +6 -0
  36. package/dist/powql.js +58 -28
  37. package/dist/query/builder.d.ts +145 -8
  38. package/dist/query/builder.js +602 -87
  39. package/dist/query/deferred.d.ts +7 -2
  40. package/dist/query/filters.d.ts +46 -1
  41. package/dist/query/filters.js +76 -1
  42. package/dist/query/index.d.ts +1 -1
  43. package/dist/query/types.d.ts +85 -11
  44. package/dist/schema-metadata.d.ts +77 -0
  45. package/dist/schema-metadata.js +313 -0
  46. package/dist/schema.d.ts +10 -0
  47. package/dist/sqlite.js +9 -90
  48. package/package.json +3 -3
package/dist/cjs/powdb.js CHANGED
@@ -25,6 +25,11 @@
25
25
  * impossible → it degrades to batched N+1 loaders (Phase B).
26
26
  * - **Single global write lock; no savepoints/isolation** — nested
27
27
  * transactions / isolation / vector / LISTEN-NOTIFY / RLS throw.
28
+ * Independent concurrent `db.$transaction` calls do NOT throw: they queue
29
+ * FIFO on a pool-level gate and run one at a time (see {@link PowdbTxGate}).
30
+ * Only a *re-entrant* transaction — a `db.$transaction` opened from inside
31
+ * an active transaction callback's async context, which queueing would
32
+ * deadlock — fails fast with E017.
28
33
  * - **The wire protocol pipelines** — `@zvndev/powdb-client` writes each
29
34
  * request frame immediately and matches replies FIFO, so multiple queries
30
35
  * may be in flight on one connection. {@link PowdbPool}'s checked-out
@@ -81,11 +86,15 @@ var __importStar = (this && this.__importStar) || (function () {
81
86
  return result;
82
87
  };
83
88
  })();
89
+ var __importDefault = (this && this.__importDefault) || function (mod) {
90
+ return (mod && mod.__esModule) ? mod : { "default": mod };
91
+ };
84
92
  Object.defineProperty(exports, "__esModule", { value: true });
85
- exports.PowqlInterface = exports.PowdbEmbeddedPool = exports.PowdbPool = exports.MIN_POWDB_VERSION = exports.PowdbFloatParam = exports.powdbDialect = void 0;
93
+ exports.PowqlInterface = exports.PowdbEmbeddedPool = exports.PowdbPool = exports.DEFAULT_TX_QUEUE_TIMEOUT_MS = exports.POWQL_KEYWORDS = exports.MIN_POWDB_VERSION = exports.PowdbFloatParam = exports.powdbDialect = void 0;
86
94
  exports.parsePowdbUrl = parsePowdbUrl;
87
95
  exports.assertSupportedPowdbVersion = assertSupportedPowdbVersion;
88
96
  exports.powqlColumnType = powqlColumnType;
97
+ exports.quotePowqlIdent = quotePowqlIdent;
89
98
  exports.powqlSchemaDDL = powqlSchemaDDL;
90
99
  exports.coerceValue = coerceValue;
91
100
  exports.rowToEntity = rowToEntity;
@@ -93,9 +102,11 @@ exports.wrapPowdbError = wrapPowdbError;
93
102
  exports.encodePowqlLiteral = encodePowqlLiteral;
94
103
  exports.materializePowql = materializePowql;
95
104
  exports.turbinePowDB = turbinePowDB;
105
+ const node_async_hooks_1 = require("node:async_hooks");
96
106
  const client_js_1 = require("./client.js");
97
107
  const dialect_js_1 = require("./dialect.js");
98
108
  const errors_js_1 = require("./errors.js");
109
+ const optional_peer_import_cjs_1 = __importDefault(require("./optional-peer-import.cjs"));
99
110
  /**
100
111
  * Capability descriptor for PowDB. PowQL generation is owned by
101
112
  * {@link PowqlInterface} (not the SQL `Dialect`), so this dialect exists only to
@@ -111,9 +122,11 @@ const errors_js_1 = require("./errors.js");
111
122
  * {@link UnsupportedFeatureError} (E017): a nested `tx.$transaction` emits a
112
123
  * savepoint synchronously (before any DB call) and so fails fast with a
113
124
  * clear typed error instead of leaking PowDB's cryptic `Parse(... 'sp_1')`.
114
- * The pool-level begin-while-active guard (see {@link PowdbPool}) catches the
115
- * other re-entrant shape — a fresh top-level `db.$transaction` opened inside
116
- * an already-open one before it can deadlock on the write lock.
125
+ * The pool-level transaction gate (see {@link PowdbTxGate}) handles the
126
+ * other shapes: a fresh top-level `db.$transaction` opened inside an
127
+ * already-open one throws E017 before it can deadlock on the write lock,
128
+ * while INDEPENDENT concurrent `db.$transaction` calls queue FIFO and run
129
+ * one at a time instead of failing.
117
130
  * Isolation levels remain Phase B.
118
131
  */
119
132
  exports.powdbDialect = {
@@ -259,6 +272,125 @@ function isDateColumn(col) {
259
272
  * `auto` modifier, so PowDB assigns a monotonic id on insert and Turbine stops
260
273
  * synthesizing a client-side value for it.
261
274
  */
275
+ /**
276
+ * PowQL reserved words — the v0.10 lexer keyword table from POWQL.md's
277
+ * "Reserved Words and Quoting" section, including the v0.10 additions
278
+ * `schema` and `describe`. Keyword matching is case-sensitive in the lexer,
279
+ * so only the exact lowercase form collides.
280
+ */
281
+ exports.POWQL_KEYWORDS = new Set([
282
+ 'abs',
283
+ 'add',
284
+ 'alter',
285
+ 'and',
286
+ 'as',
287
+ 'asc',
288
+ 'auto',
289
+ 'avg',
290
+ 'begin',
291
+ 'between',
292
+ 'case',
293
+ 'cast',
294
+ 'ceil',
295
+ 'column',
296
+ 'commit',
297
+ 'concat',
298
+ 'conflict',
299
+ 'count',
300
+ 'cross',
301
+ 'date_add',
302
+ 'date_diff',
303
+ 'default',
304
+ 'delete',
305
+ 'dense_rank',
306
+ 'desc',
307
+ 'describe',
308
+ 'distinct',
309
+ 'drop',
310
+ 'else',
311
+ 'end',
312
+ 'exists',
313
+ 'explain',
314
+ 'extract',
315
+ 'false',
316
+ 'filter',
317
+ 'floor',
318
+ 'group',
319
+ 'having',
320
+ 'in',
321
+ 'index',
322
+ 'inner',
323
+ 'insert',
324
+ 'is',
325
+ 'join',
326
+ 'left',
327
+ 'length',
328
+ 'let',
329
+ 'like',
330
+ 'limit',
331
+ 'link',
332
+ 'lower',
333
+ 'match',
334
+ 'materialize',
335
+ 'materialized',
336
+ 'max',
337
+ 'min',
338
+ 'multi',
339
+ 'not',
340
+ 'now',
341
+ 'null',
342
+ 'offset',
343
+ 'on',
344
+ 'or',
345
+ 'order',
346
+ 'outer',
347
+ 'over',
348
+ 'partition',
349
+ 'pow',
350
+ 'rank',
351
+ 'refresh',
352
+ 'required',
353
+ 'returning',
354
+ 'right',
355
+ 'rollback',
356
+ 'round',
357
+ 'row_number',
358
+ 'schema',
359
+ 'select',
360
+ 'sqrt',
361
+ 'substring',
362
+ 'sum',
363
+ 'then',
364
+ 'transaction',
365
+ 'trim',
366
+ 'true',
367
+ 'type',
368
+ 'union',
369
+ 'unique',
370
+ 'update',
371
+ 'upper',
372
+ 'upsert',
373
+ 'view',
374
+ 'when',
375
+ ]);
376
+ const POWQL_BARE_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
377
+ /**
378
+ * Backtick-quote an identifier when PowQL would otherwise lex it as a keyword
379
+ * (or when it contains characters outside the bare-identifier grammar).
380
+ * Applied only in bare-identifier positions — DDL type/field names, index DDL,
381
+ * and `insert`/`update`/`upsert` assignment targets. Dotted references
382
+ * (`.col` in filters/projections/ordering) bypass keyword lookup on every
383
+ * engine version and deliberately stay bare for ≤0.9 compatibility. Backticks
384
+ * parse on PowDB ≥ 0.10; on older engines these names were already parse
385
+ * errors when emitted bare, so quoting is strictly an improvement.
386
+ */
387
+ function quotePowqlIdent(name) {
388
+ if (name.includes('`')) {
389
+ // The lexer has no backtick escape inside a quoted identifier.
390
+ throw new errors_js_1.ValidationError(`[turbine] Identifier "${name}" contains a backtick, which PowQL cannot represent.`);
391
+ }
392
+ return exports.POWQL_KEYWORDS.has(name) || !POWQL_BARE_IDENT.test(name) ? `\`${name}\`` : name;
393
+ }
262
394
  function powqlSchemaDDL(schema) {
263
395
  const stmts = [];
264
396
  for (const meta of Object.values(schema.tables)) {
@@ -280,13 +412,13 @@ function powqlSchemaDDL(schema) {
280
412
  // a plain typed column (Turbine assigns the value client-side instead).
281
413
  if (col.isGenerated && powqlColumnType(col) === 'int')
282
414
  mods.push('auto');
283
- return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${col.name}: ${powqlColumnType(col)}`;
415
+ return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlColumnType(col)}`;
284
416
  });
285
- stmts.push(`type ${meta.name} {\n${fields.join(',\n')}\n}`);
417
+ stmts.push(`type ${quotePowqlIdent(meta.name)} {\n${fields.join(',\n')}\n}`);
286
418
  // Secondary unique constraints (beyond the PK) become unique indexes.
287
419
  for (const uniq of meta.uniqueColumns) {
288
420
  if (uniq.length === 1 && !pkSet.has(uniq[0])) {
289
- stmts.push(`alter ${meta.name} add unique .${uniq[0]}`);
421
+ stmts.push(`alter ${quotePowqlIdent(meta.name)} add unique .${quotePowqlIdent(uniq[0])}`);
290
422
  }
291
423
  }
292
424
  }
@@ -386,6 +518,17 @@ function wrapPowdbError(err) {
386
518
  const m = /column ['"]?(\w+)['"]?/i.exec(msg);
387
519
  return new errors_js_1.NotNullViolationError({ column: m?.[1], cause: err });
388
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
+ }
526
+ // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
527
+ // connection held the single global write lock past the server's
528
+ // --tx-wait-timeout-ms. Retryable timeout, not a query defect.
529
+ if (/transaction gate timeout/i.test(msg)) {
530
+ return new errors_js_1.TimeoutError(0, 'PowDB transaction gate');
531
+ }
389
532
  // Type mismatch / parse / execution / storage / unexpected → validation
390
533
  // (E003). On the embedded transport these are the only signal we get
391
534
  // (code is always 'GenericFailure'); on the networked path they are a
@@ -431,15 +574,161 @@ function txControl(powql) {
431
574
  return null;
432
575
  }
433
576
  /**
434
- * The error a pool throws when a `begin` arrives while a transaction is already
435
- * open. PowDB has ONE global write lock and supports neither concurrent nor
436
- * nested transactions: on the networked transport a second `begin` checks out a
437
- * fresh pooled connection and blocks forever on the lock the open transaction
438
- * holds. This guard converts that hang into a fast, typed error.
577
+ * The error a pool throws when a `begin` arrives from INSIDE an already-open
578
+ * transaction's async context (a re-entrant `db.$transaction`). PowDB has ONE
579
+ * global write lock and no savepoints: queueing a re-entrant transaction would
580
+ * deadlock (the outer callback awaits the inner transaction, which waits on
581
+ * the write lock the outer transaction holds), and on the networked transport
582
+ * it would block a fresh pooled connection on the lock forever. This guard
583
+ * converts that hang into a fast, typed error. Independent concurrent
584
+ * transactions do NOT hit this — they queue FIFO on {@link PowdbTxGate}.
439
585
  */
440
586
  function reentrantTransactionError() {
441
- return new errors_js_1.UnsupportedFeatureError('concurrent or nested transactions', 'powdb', 'PowDB is single-writer — it has one global write lock. A second transaction would block on it forever; ' +
442
- 'complete the open transaction first.');
587
+ return new errors_js_1.UnsupportedFeatureError('re-entrant transactions', 'powdb', 'PowDB is single-writer — a transaction opened from inside an active transaction callback would deadlock ' +
588
+ 'on the write lock the open transaction holds. Use the `tx` client the callback receives, or start the ' +
589
+ 'second transaction after the first completes. (Independent concurrent transactions queue automatically.)');
590
+ }
591
+ // ---------------------------------------------------------------------------
592
+ // Single-writer transaction gate — FIFO queueing + re-entrancy detection
593
+ // ---------------------------------------------------------------------------
594
+ /**
595
+ * Default cap (ms) on how long a `begin` may wait in the FIFO queue for
596
+ * PowDB's single global write lock before failing with a typed
597
+ * {@link TimeoutError} (E002). Prevents silent starvation behind a wedged
598
+ * transaction. Override via `transactionQueueTimeoutMs`
599
+ * ({@link TurbinePowdbOptions} / {@link PowdbPoolOptions}); `0` or `Infinity`
600
+ * waits without limit.
601
+ */
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;
610
+ const powdbTxStorage = new node_async_hooks_1.AsyncLocalStorage();
611
+ /**
612
+ * FIFO gate serializing transactions across a whole pool. PowDB holds one
613
+ * global write lock, so at most one transaction may be open per database:
614
+ * without this gate a second `begin` on the networked transport checks out a
615
+ * fresh connection and blocks forever on the lock the open transaction holds
616
+ * (and the embedded engine rejects it with a raw parse error).
617
+ *
618
+ * `acquire()` is called (synchronously, see below) for every `begin`:
619
+ * - a **re-entrant** `begin` — issued from inside an active transaction's
620
+ * async context, detected via {@link powdbTxStorage} — throws E017
621
+ * immediately. Queueing it can never succeed: the open transaction cannot
622
+ * commit while its callback awaits the queued one.
623
+ * - an **independent** `begin` waits its FIFO turn, bounded by the queue
624
+ * timeout, then returns a {@link PowdbTxHold} the caller finishes on
625
+ * commit / rollback / connection release.
626
+ *
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.
645
+ *
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.
656
+ */
657
+ class PowdbTxGate {
658
+ queueTimeoutMs;
659
+ /** Tail of the FIFO queue — resolves once every earlier transaction has finished. */
660
+ tail = Promise.resolve();
661
+ constructor(queueTimeoutMs) {
662
+ this.queueTimeoutMs = queueTimeoutMs;
663
+ }
664
+ /**
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.
671
+ */
672
+ async acquire() {
673
+ // Walk the WHOLE marker chain, not just the innermost marker: with two
674
+ // pools, dbA-tx → dbB-tx → dbA-begin leaves dbB's marker innermost, but
675
+ // the dbA ancestor is still open — queueing the inner dbA begin behind it
676
+ // would deadlock. Any live ancestor on this gate ⇒ re-entrant E017.
677
+ // Prune completed heads first (`done` never flips back) so sequential
678
+ // transactions issued from one long-lived context do not chain — and leak
679
+ // — unboundedly; what remains is bounded by real nesting depth.
680
+ let parent = powdbTxStorage.getStore();
681
+ while (parent?.done)
682
+ parent = parent.parent;
683
+ for (let c = parent; c !== undefined; c = c.parent) {
684
+ if (c.gate === this && !c.done) {
685
+ throw reentrantTransactionError();
686
+ }
687
+ }
688
+ const ctx = { gate: this, done: false, parent };
689
+ let handOff;
690
+ const finished = new Promise((resolve) => {
691
+ handOff = resolve;
692
+ });
693
+ const ahead = this.tail;
694
+ this.tail = ahead.then(() => finished);
695
+ const hold = {
696
+ ctx,
697
+ finish: () => {
698
+ if (ctx.done)
699
+ return;
700
+ ctx.done = true;
701
+ handOff();
702
+ },
703
+ };
704
+ // --- FIFO wait (optionally bounded) ---
705
+ const timeoutMs = this.queueTimeoutMs;
706
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
707
+ await ahead;
708
+ return hold;
709
+ }
710
+ await new Promise((resolve, reject) => {
711
+ let settled = false;
712
+ const timer = setTimeout(() => {
713
+ if (settled)
714
+ return;
715
+ settled = true;
716
+ // Give up the queue slot: finishing resolves our `finished` link, so
717
+ // once every transaction ahead completes, later waiters skip straight
718
+ // past us instead of stalling behind a slot nobody will release.
719
+ hold.finish();
720
+ reject(new errors_js_1.TimeoutError(timeoutMs, 'PowDB transaction (queued behind the single-writer lock)'));
721
+ }, timeoutMs);
722
+ void ahead.then(() => {
723
+ if (settled)
724
+ return;
725
+ settled = true;
726
+ clearTimeout(timer);
727
+ resolve();
728
+ });
729
+ });
730
+ return hold;
731
+ }
443
732
  }
444
733
  /** Adapt a PowDB result into the pg-compat `{ rows, rowCount, fields }` shape. */
445
734
  function adaptResult(r) {
@@ -473,49 +762,87 @@ class PowdbPool {
473
762
  toParam;
474
763
  closed = false;
475
764
  /**
476
- * Pool-level single-writer guard. PowDB holds one global write lock, so at
477
- * most one transaction may be open across the whole pool. A `begin` issued
478
- * while this is `true` is rejected (it would otherwise check out a second
479
- * connection and block on the lock forever — the networked re-entrant hang).
765
+ * Pool-level single-writer gate. PowDB holds one global write lock, so at
766
+ * most one transaction may be open across the whole pool. Concurrent
767
+ * `begin`s queue FIFO on the gate (instead of checking out a second
768
+ * connection and blocking on the lock forever — the networked hang);
769
+ * re-entrant `begin`s throw E017 (see {@link PowdbTxGate}).
770
+ */
771
+ txGate;
772
+ /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
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.
480
780
  */
481
- activeTransaction = false;
482
- constructor(pool, toParam = (v) => toPowdbParam(v)) {
781
+ checkedOut = new Set();
782
+ constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
483
783
  this.pool = pool;
484
784
  this.toParam = toParam;
785
+ this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
485
786
  }
486
- /**
487
- * Enforce the single-writer model on a transaction-control statement. Throws
488
- * (before any query runs) if a `begin` arrives while a transaction is open;
489
- * otherwise flips the pool-level flag. Returns the control kind so the caller
490
- * can decide whether it even needs to hit the engine.
491
- */
492
- guardTxControl(powql) {
787
+ // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
788
+ async query(text, values) {
789
+ this.assertOpen();
790
+ const { text: powql, params } = normalizeQueryArgs(text, values);
493
791
  const ctl = txControl(powql);
494
792
  if (ctl === 'begin') {
495
- if (this.activeTransaction)
496
- throw reentrantTransactionError();
497
- this.activeTransaction = true;
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.
796
+ this.poolHold = await this.txGate.acquire();
498
797
  }
499
- else if (ctl === 'commit' || ctl === 'rollback') {
500
- this.activeTransaction = false;
798
+ if ((ctl === 'commit' || ctl === 'rollback') && this.poolHold === null) {
799
+ // No gate hold → our `begin` never ran (gate timeout / re-entrant
800
+ // E017 / no begin at all). Never forward a stray commit/rollback to
801
+ // the engine — PowDB is single-writer, so it could only ever end a
802
+ // DIFFERENT caller's open transaction. Empty success instead.
803
+ return { rows: [], rowCount: 0, fields: [] };
501
804
  }
502
- return ctl;
503
- }
504
- // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
505
- async query(text, values) {
506
- const { text: powql, params } = normalizeQueryArgs(text, values);
507
- this.guardTxControl(powql);
508
805
  try {
509
806
  const result = await this.pool.withClient((c) => c.query(powql, params.map(this.toParam)));
510
807
  return adaptResult(result);
511
808
  }
512
809
  catch (err) {
810
+ if (ctl === 'begin') {
811
+ this.poolHold?.finish();
812
+ this.poolHold = null;
813
+ }
513
814
  throw wrapPowdbError(err);
514
815
  }
816
+ finally {
817
+ if (ctl === 'commit' || ctl === 'rollback') {
818
+ this.poolHold?.finish();
819
+ this.poolHold = null;
820
+ }
821
+ }
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
+ }
515
832
  }
516
833
  async connect() {
517
- 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);
518
843
  let broken = false;
844
+ /** The gate hold of the transaction begun through THIS connection (if any). */
845
+ let hold = null;
519
846
  return {
520
847
  // The networked client's query() supports concurrent in-flight calls on
521
848
  // one connection: every request frame is written to the socket
@@ -525,29 +852,100 @@ class PowdbPool {
525
852
  // for the batch's rollback contract because a failed statement leaves
526
853
  // the engine's transaction open (no aborted state, no auto-rollback) —
527
854
  // later pipelined statements execute inside the same still-open
528
- // transaction and the final `rollback` discards every effect.
855
+ // transaction and the final `rollback` discards every effect. (The
856
+ // batch path awaits `begin` before dispatching the burst, so the gate
857
+ // wait below never reorders statements around it.)
529
858
  supportsPipelining: true,
530
859
  // biome-ignore lint/suspicious/noExplicitAny: see query() above.
531
860
  query: async (text, values) => {
532
861
  const { text: powql, params } = normalizeQueryArgs(text, values);
533
- // Guard BEFORE acquiring the engine — a re-entrant begin throws fast
534
- // instead of blocking on the global write lock the open tx holds.
535
- this.guardTxControl(powql);
862
+ const ctl = txControl(powql);
863
+ if (ctl === 'begin') {
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.
868
+ hold = await this.txGate.acquire();
869
+ }
870
+ if ((ctl === 'commit' || ctl === 'rollback') && hold === null) {
871
+ // This connection never acquired the gate — its `begin` never ran
872
+ // (gate timeout / re-entrant E017). A stray commit/rollback must
873
+ // never reach the single-writer engine, where it could only end a
874
+ // DIFFERENT caller's open transaction. Empty success instead.
875
+ return { rows: [], rowCount: 0, fields: [] };
876
+ }
536
877
  try {
537
878
  return adaptResult(await client.query(powql, params.map(this.toParam)));
538
879
  }
539
880
  catch (err) {
540
881
  broken = true;
882
+ if (ctl === 'begin') {
883
+ hold?.finish();
884
+ hold = null;
885
+ }
541
886
  throw wrapPowdbError(err);
542
887
  }
888
+ finally {
889
+ if (ctl === 'commit' || ctl === 'rollback') {
890
+ hold?.finish();
891
+ hold = null;
892
+ }
893
+ }
543
894
  },
544
- release: () => {
545
- // Releasing this connection ends its transaction scope. Clear the flag
546
- // as a safety net so a tx torn down without an explicit commit/rollback
547
- // (e.g. a timeout that destroys the connection) never leaves the pool
548
- // permanently believing a transaction is still open.
549
- this.activeTransaction = false;
550
- return broken ? this.pool.destroy(client) : this.pool.release(client);
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;
919
+ hold = null;
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();
551
949
  },
552
950
  };
553
951
  }
@@ -555,7 +953,16 @@ class PowdbPool {
555
953
  if (this.closed)
556
954
  return;
557
955
  this.closed = true;
956
+ // close() rejects pending waiters and closes every IDLE client…
558
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();
559
966
  }
560
967
  }
561
968
  exports.PowdbPool = PowdbPool;
@@ -660,57 +1067,112 @@ class PowdbEmbeddedPool {
660
1067
  db;
661
1068
  closed = false;
662
1069
  /**
663
- * Single-writer guard. The embedded engine is one handle with one global
1070
+ * Single-writer gate. The embedded engine is one handle with one global
664
1071
  * write lock — only one transaction may be open at a time. A re-entrant
665
- * `begin` (a fresh top-level `db.$transaction` opened inside an open one)
666
- * would otherwise hit PowDB's raw "already in a transaction" parse error;
667
- * this surfaces a typed error instead. (Nested `tx.$transaction` is caught
668
- * earlier still, by the savepoint override in {@link powdbDialect}.)
1072
+ * `begin` (a fresh top-level `db.$transaction` opened inside an open one's
1073
+ * callback) would otherwise hit PowDB's raw "already in a transaction"
1074
+ * parse error; the gate surfaces a typed E017 instead, while INDEPENDENT
1075
+ * concurrent transactions queue FIFO and run one at a time. (Nested
1076
+ * `tx.$transaction` is caught earlier still, by the savepoint override in
1077
+ * {@link powdbDialect}.)
669
1078
  */
670
- activeTransaction = false;
671
- constructor(db) {
1079
+ txGate;
1080
+ /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
1081
+ poolHoldRef = { hold: null };
1082
+ constructor(db, options = {}) {
672
1083
  this.db = db;
1084
+ this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
673
1085
  }
674
- /** Enforce the single-writer model on a transaction-control statement. */
675
- guardTxControl(powql) {
1086
+ /** Materialize `$N` params and hand the PowQL to the in-process engine. */
1087
+ exec(powql, params) {
1088
+ const materialized = materializePowql(powql, params);
1089
+ return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
1090
+ }
1091
+ /**
1092
+ * Run one statement, gating transaction control. `holdRef` scopes the gate
1093
+ * hold to whoever issued the `begin` (the pool itself or one checked-out
1094
+ * client), so finishing a transaction can never release a slot a different
1095
+ * transaction is holding.
1096
+ */
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
+ }
676
1101
  const ctl = txControl(powql);
677
1102
  if (ctl === 'begin') {
678
- if (this.activeTransaction)
679
- throw reentrantTransactionError();
680
- this.activeTransaction = true;
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.
1106
+ holdRef.hold = await this.txGate.acquire();
681
1107
  }
682
- else if (ctl === 'commit' || ctl === 'rollback') {
683
- this.activeTransaction = false;
1108
+ if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
1109
+ // This context never acquired the gate — its `begin` never ran (the
1110
+ // gate timed out / threw re-entrant E017, or no begin was issued at
1111
+ // all). The engine is ONE shared handle: forwarding this stray
1112
+ // commit/rollback would hit whatever transaction ANOTHER caller has
1113
+ // open on it (live-reproduced: a best-effort ROLLBACK after a failed
1114
+ // begin silently discarded a concurrent transaction's writes). Swallow
1115
+ // it as an empty success instead — there is nothing of ours to end.
1116
+ return { rows: [], rowCount: 0, fields: [] };
684
1117
  }
685
- }
686
- run(powql, params) {
687
- this.guardTxControl(powql);
688
1118
  try {
689
- const materialized = materializePowql(powql, params);
690
- return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
1119
+ return this.exec(powql, params);
691
1120
  }
692
1121
  catch (err) {
1122
+ if (ctl === 'begin') {
1123
+ holdRef.hold?.finish();
1124
+ holdRef.hold = null;
1125
+ }
693
1126
  throw wrapPowdbError(err);
694
1127
  }
1128
+ finally {
1129
+ if (ctl === 'commit' || ctl === 'rollback') {
1130
+ holdRef.hold?.finish();
1131
+ holdRef.hold = null;
1132
+ }
1133
+ }
695
1134
  }
696
1135
  // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
697
1136
  async query(text, values) {
698
1137
  const { text: powql, params } = normalizeQueryArgs(text, values);
699
- return this.run(powql, params);
1138
+ return this.run(powql, params, this.poolHoldRef);
700
1139
  }
701
1140
  async connect() {
702
1141
  // Single in-process handle — the "client" shares the one Database; tx
703
- // keywords run serially on it.
1142
+ // keywords run serially on it. Each checked-out client scopes its own
1143
+ // gate hold so release() only ever finishes ITS transaction.
1144
+ const holdRef = { hold: null };
704
1145
  return {
705
1146
  // biome-ignore lint/suspicious/noExplicitAny: see query() above.
706
1147
  query: async (text, values) => {
707
1148
  const { text: powql, params } = normalizeQueryArgs(text, values);
708
- return this.run(powql, params);
1149
+ return this.run(powql, params, holdRef);
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();
709
1156
  },
710
1157
  release: () => {
711
1158
  // End-of-scope safety net (see PowdbPool.connect()): a tx torn down
712
- // without an explicit commit/rollback must not wedge the handle.
713
- this.activeTransaction = false;
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
+ });
714
1176
  },
715
1177
  };
716
1178
  }
@@ -718,8 +1180,11 @@ class PowdbEmbeddedPool {
718
1180
  if (this.closed)
719
1181
  return;
720
1182
  // The addon exposes no explicit close — drop the reference and let GC /
721
- // the engine's checkpoint flush. Caveat: durability is checkpoint-bound, so
722
- // 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.
723
1188
  this.closed = true;
724
1189
  }
725
1190
  }
@@ -735,10 +1200,14 @@ Object.defineProperty(exports, "PowqlInterface", { enumerable: true, get: functi
735
1200
  */
736
1201
  async function loadPowdb() {
737
1202
  try {
738
- return (await Promise.resolve().then(() => __importStar(require('@zvndev/powdb-client'))));
1203
+ // Via the .cts helper so the CJS build keeps a path to a REAL dynamic
1204
+ // import() — @zvndev/powdb-client ≥ 0.9 is ESM-only, and the CommonJS
1205
+ // pass transpiles a plain `import()` here into an unusable `require()`.
1206
+ return (await (0, optional_peer_import_cjs_1.default)('@zvndev/powdb-client'));
739
1207
  }
740
1208
  catch (err) {
741
- throw new errors_js_1.ConnectionError("[turbine] turbine-orm/powdb requires the optional peer dependency '@zvndev/powdb-client'. Install it: npm i @zvndev/powdb-client. " +
1209
+ throw new errors_js_1.ConnectionError("[turbine] turbine-orm/powdb requires the optional peer dependency '@zvndev/powdb-client'. Install it: npm i @zvndev/powdb-client " +
1210
+ 'or construct the PowDB pool yourself and inject it: turbinePowDB(pool, schema). ' +
742
1211
  `(${err.message})`);
743
1212
  }
744
1213
  }
@@ -752,13 +1221,16 @@ async function loadPowdb() {
752
1221
  async function loadPowdbEmbedded() {
753
1222
  let mod;
754
1223
  try {
755
- mod = (await Promise.resolve().then(() => __importStar(require('@zvndev/powdb-embedded'))));
1224
+ // Via the .cts helper — keeps a real dynamic import() available to the
1225
+ // CJS build in case a future addon version ships ESM-only (see loadPowdb).
1226
+ mod = (await (0, optional_peer_import_cjs_1.default)('@zvndev/powdb-embedded'));
756
1227
  }
757
1228
  catch (err) {
758
1229
  throw new errors_js_1.ConnectionError("[turbine] turbine-orm/powdb embedded mode requires the optional peer '@zvndev/powdb-embedded'. " +
759
1230
  'Install it: npm i @zvndev/powdb-embedded. If install succeeded but loading failed, your platform has no ' +
760
1231
  'prebuilt binary (prebuilts ship for macOS arm64/x64 and Linux glibc x64/arm64; Intel-mac/musl/Windows ' +
761
- 'build from source) — build it with `npm run build` in the addon, then retry. ' +
1232
+ 'build from source) — build it with `npm run build` in the addon, then retry. You can also construct the ' +
1233
+ 'pool yourself and inject it: turbinePowDB(pool, schema). ' +
762
1234
  `(${err.message})`);
763
1235
  }
764
1236
  if (!mod || typeof mod.Database?.open !== 'function') {
@@ -768,7 +1240,7 @@ async function loadPowdbEmbedded() {
768
1240
  return mod;
769
1241
  }
770
1242
  /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
771
- async function openEmbeddedPool(target) {
1243
+ async function openEmbeddedPool(target, poolOptions = {}) {
772
1244
  const mod = await loadPowdbEmbedded();
773
1245
  const { embedded: dir, syncMode, memoryLimit } = target;
774
1246
  let db;
@@ -794,7 +1266,7 @@ async function openEmbeddedPool(target) {
794
1266
  }
795
1267
  db.setSyncMode(syncMode);
796
1268
  }
797
- return new PowdbEmbeddedPool(db);
1269
+ return new PowdbEmbeddedPool(db, poolOptions);
798
1270
  }
799
1271
  /**
800
1272
  * Bind Turbine to PowDB. `target` is one of:
@@ -817,28 +1289,30 @@ async function openEmbeddedPool(target) {
817
1289
  async function turbinePowDB(target, schema, options = {}) {
818
1290
  let pool;
819
1291
  let owns = false;
1292
+ const poolOptions = { transactionQueueTimeoutMs: options.transactionQueueTimeoutMs };
820
1293
  if (typeof target === 'string') {
821
- const mod = await loadPowdb();
1294
+ const mod = options.powdbClientModule ?? (await loadPowdb());
822
1295
  const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max: options.connectionLimit ?? 10 });
823
1296
  await assertNetworkedVersion(clientPool);
824
- pool = new PowdbPool(clientPool);
1297
+ pool = new PowdbPool(clientPool, undefined, poolOptions);
825
1298
  owns = true;
826
1299
  }
827
1300
  else if (target instanceof PowdbPool) {
1301
+ // An injected PowdbPool carries its own PowdbPoolOptions.
828
1302
  pool = target;
829
1303
  }
830
1304
  else if (isEmbeddedTarget(target)) {
831
- pool = await openEmbeddedPool(target);
1305
+ pool = await openEmbeddedPool(target, poolOptions);
832
1306
  owns = true;
833
1307
  }
834
1308
  else if (isPowdbClientPool(target)) {
835
- pool = new PowdbPool(target);
1309
+ pool = new PowdbPool(target, undefined, poolOptions);
836
1310
  }
837
1311
  else {
838
- const mod = await loadPowdb();
1312
+ const mod = options.powdbClientModule ?? (await loadPowdb());
839
1313
  const clientPool = new mod.Pool({ ...target, max: options.connectionLimit ?? 10 });
840
1314
  await assertNetworkedVersion(clientPool);
841
- pool = new PowdbPool(clientPool);
1315
+ pool = new PowdbPool(clientPool, undefined, poolOptions);
842
1316
  owns = true;
843
1317
  }
844
1318
  // The PowQL generator is loaded here to keep client.ts free of any PowDB import.
@@ -853,7 +1327,23 @@ async function turbinePowDB(target, schema, options = {}) {
853
1327
  warnOnUnlimited: options.warnOnUnlimited,
854
1328
  queryInterfaceFactory,
855
1329
  }, schema);
856
- 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 {
857
1347
  // Injected pool — the caller owns its lifecycle.
858
1348
  client.disconnect = async () => { };
859
1349
  }