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/powdb.js CHANGED
@@ -24,6 +24,11 @@
24
24
  * impossible → it degrades to batched N+1 loaders (Phase B).
25
25
  * - **Single global write lock; no savepoints/isolation** — nested
26
26
  * transactions / isolation / vector / LISTEN-NOTIFY / RLS throw.
27
+ * Independent concurrent `db.$transaction` calls do NOT throw: they queue
28
+ * FIFO on a pool-level gate and run one at a time (see {@link PowdbTxGate}).
29
+ * Only a *re-entrant* transaction — a `db.$transaction` opened from inside
30
+ * an active transaction callback's async context, which queueing would
31
+ * deadlock — fails fast with E017.
27
32
  * - **The wire protocol pipelines** — `@zvndev/powdb-client` writes each
28
33
  * request frame immediately and matches replies FIFO, so multiple queries
29
34
  * may be in flight on one connection. {@link PowdbPool}'s checked-out
@@ -47,9 +52,11 @@
47
52
  *
48
53
  * @module
49
54
  */
55
+ import { AsyncLocalStorage } from 'node:async_hooks';
50
56
  import { TurbineClient, } from './client.js';
51
57
  import { postgresDialect } from './dialect.js';
52
58
  import { ConnectionError, NotNullViolationError, TimeoutError, UniqueConstraintError, UnsupportedFeatureError, ValidationError, } from './errors.js';
59
+ import importOptionalPeer from './optional-peer-import.cjs';
53
60
  /**
54
61
  * Capability descriptor for PowDB. PowQL generation is owned by
55
62
  * {@link PowqlInterface} (not the SQL `Dialect`), so this dialect exists only to
@@ -65,9 +72,11 @@ import { ConnectionError, NotNullViolationError, TimeoutError, UniqueConstraintE
65
72
  * {@link UnsupportedFeatureError} (E017): a nested `tx.$transaction` emits a
66
73
  * savepoint synchronously (before any DB call) and so fails fast with a
67
74
  * clear typed error instead of leaking PowDB's cryptic `Parse(... 'sp_1')`.
68
- * The pool-level begin-while-active guard (see {@link PowdbPool}) catches the
69
- * other re-entrant shape — a fresh top-level `db.$transaction` opened inside
70
- * an already-open one — before it can deadlock on the write lock.
75
+ * The pool-level transaction gate (see {@link PowdbTxGate}) handles the
76
+ * other shapes: a fresh top-level `db.$transaction` opened inside an
77
+ * already-open one throws E017 before it can deadlock on the write lock,
78
+ * while INDEPENDENT concurrent `db.$transaction` calls queue FIFO and run
79
+ * one at a time instead of failing.
71
80
  * Isolation levels remain Phase B.
72
81
  */
73
82
  export const powdbDialect = {
@@ -212,6 +221,125 @@ function isDateColumn(col) {
212
221
  * `auto` modifier, so PowDB assigns a monotonic id on insert and Turbine stops
213
222
  * synthesizing a client-side value for it.
214
223
  */
224
+ /**
225
+ * PowQL reserved words — the v0.10 lexer keyword table from POWQL.md's
226
+ * "Reserved Words and Quoting" section, including the v0.10 additions
227
+ * `schema` and `describe`. Keyword matching is case-sensitive in the lexer,
228
+ * so only the exact lowercase form collides.
229
+ */
230
+ export const POWQL_KEYWORDS = new Set([
231
+ 'abs',
232
+ 'add',
233
+ 'alter',
234
+ 'and',
235
+ 'as',
236
+ 'asc',
237
+ 'auto',
238
+ 'avg',
239
+ 'begin',
240
+ 'between',
241
+ 'case',
242
+ 'cast',
243
+ 'ceil',
244
+ 'column',
245
+ 'commit',
246
+ 'concat',
247
+ 'conflict',
248
+ 'count',
249
+ 'cross',
250
+ 'date_add',
251
+ 'date_diff',
252
+ 'default',
253
+ 'delete',
254
+ 'dense_rank',
255
+ 'desc',
256
+ 'describe',
257
+ 'distinct',
258
+ 'drop',
259
+ 'else',
260
+ 'end',
261
+ 'exists',
262
+ 'explain',
263
+ 'extract',
264
+ 'false',
265
+ 'filter',
266
+ 'floor',
267
+ 'group',
268
+ 'having',
269
+ 'in',
270
+ 'index',
271
+ 'inner',
272
+ 'insert',
273
+ 'is',
274
+ 'join',
275
+ 'left',
276
+ 'length',
277
+ 'let',
278
+ 'like',
279
+ 'limit',
280
+ 'link',
281
+ 'lower',
282
+ 'match',
283
+ 'materialize',
284
+ 'materialized',
285
+ 'max',
286
+ 'min',
287
+ 'multi',
288
+ 'not',
289
+ 'now',
290
+ 'null',
291
+ 'offset',
292
+ 'on',
293
+ 'or',
294
+ 'order',
295
+ 'outer',
296
+ 'over',
297
+ 'partition',
298
+ 'pow',
299
+ 'rank',
300
+ 'refresh',
301
+ 'required',
302
+ 'returning',
303
+ 'right',
304
+ 'rollback',
305
+ 'round',
306
+ 'row_number',
307
+ 'schema',
308
+ 'select',
309
+ 'sqrt',
310
+ 'substring',
311
+ 'sum',
312
+ 'then',
313
+ 'transaction',
314
+ 'trim',
315
+ 'true',
316
+ 'type',
317
+ 'union',
318
+ 'unique',
319
+ 'update',
320
+ 'upper',
321
+ 'upsert',
322
+ 'view',
323
+ 'when',
324
+ ]);
325
+ const POWQL_BARE_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
326
+ /**
327
+ * Backtick-quote an identifier when PowQL would otherwise lex it as a keyword
328
+ * (or when it contains characters outside the bare-identifier grammar).
329
+ * Applied only in bare-identifier positions — DDL type/field names, index DDL,
330
+ * and `insert`/`update`/`upsert` assignment targets. Dotted references
331
+ * (`.col` in filters/projections/ordering) bypass keyword lookup on every
332
+ * engine version and deliberately stay bare for ≤0.9 compatibility. Backticks
333
+ * parse on PowDB ≥ 0.10; on older engines these names were already parse
334
+ * errors when emitted bare, so quoting is strictly an improvement.
335
+ */
336
+ export function quotePowqlIdent(name) {
337
+ if (name.includes('`')) {
338
+ // The lexer has no backtick escape inside a quoted identifier.
339
+ throw new ValidationError(`[turbine] Identifier "${name}" contains a backtick, which PowQL cannot represent.`);
340
+ }
341
+ return POWQL_KEYWORDS.has(name) || !POWQL_BARE_IDENT.test(name) ? `\`${name}\`` : name;
342
+ }
215
343
  export function powqlSchemaDDL(schema) {
216
344
  const stmts = [];
217
345
  for (const meta of Object.values(schema.tables)) {
@@ -233,13 +361,13 @@ export function powqlSchemaDDL(schema) {
233
361
  // a plain typed column (Turbine assigns the value client-side instead).
234
362
  if (col.isGenerated && powqlColumnType(col) === 'int')
235
363
  mods.push('auto');
236
- return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${col.name}: ${powqlColumnType(col)}`;
364
+ return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlColumnType(col)}`;
237
365
  });
238
- stmts.push(`type ${meta.name} {\n${fields.join(',\n')}\n}`);
366
+ stmts.push(`type ${quotePowqlIdent(meta.name)} {\n${fields.join(',\n')}\n}`);
239
367
  // Secondary unique constraints (beyond the PK) become unique indexes.
240
368
  for (const uniq of meta.uniqueColumns) {
241
369
  if (uniq.length === 1 && !pkSet.has(uniq[0])) {
242
- stmts.push(`alter ${meta.name} add unique .${uniq[0]}`);
370
+ stmts.push(`alter ${quotePowqlIdent(meta.name)} add unique .${quotePowqlIdent(uniq[0])}`);
243
371
  }
244
372
  }
245
373
  }
@@ -339,6 +467,17 @@ export function wrapPowdbError(err) {
339
467
  const m = /column ['"]?(\w+)['"]?/i.exec(msg);
340
468
  return new NotNullViolationError({ column: m?.[1], cause: err });
341
469
  }
470
+ // Driver pool lifecycle errors (acquire after close, acquire timeout) carry
471
+ // no .code — classify by message so both transports surface E004.
472
+ if (/pool closed|pool acquire timeout/i.test(msg)) {
473
+ return new ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`);
474
+ }
475
+ // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
476
+ // connection held the single global write lock past the server's
477
+ // --tx-wait-timeout-ms. Retryable timeout, not a query defect.
478
+ if (/transaction gate timeout/i.test(msg)) {
479
+ return new TimeoutError(0, 'PowDB transaction gate');
480
+ }
342
481
  // Type mismatch / parse / execution / storage / unexpected → validation
343
482
  // (E003). On the embedded transport these are the only signal we get
344
483
  // (code is always 'GenericFailure'); on the networked path they are a
@@ -384,15 +523,161 @@ function txControl(powql) {
384
523
  return null;
385
524
  }
386
525
  /**
387
- * The error a pool throws when a `begin` arrives while a transaction is already
388
- * open. PowDB has ONE global write lock and supports neither concurrent nor
389
- * nested transactions: on the networked transport a second `begin` checks out a
390
- * fresh pooled connection and blocks forever on the lock the open transaction
391
- * holds. This guard converts that hang into a fast, typed error.
526
+ * The error a pool throws when a `begin` arrives from INSIDE an already-open
527
+ * transaction's async context (a re-entrant `db.$transaction`). PowDB has ONE
528
+ * global write lock and no savepoints: queueing a re-entrant transaction would
529
+ * deadlock (the outer callback awaits the inner transaction, which waits on
530
+ * the write lock the outer transaction holds), and on the networked transport
531
+ * it would block a fresh pooled connection on the lock forever. This guard
532
+ * converts that hang into a fast, typed error. Independent concurrent
533
+ * transactions do NOT hit this — they queue FIFO on {@link PowdbTxGate}.
392
534
  */
393
535
  function reentrantTransactionError() {
394
- return new UnsupportedFeatureError('concurrent or nested transactions', 'powdb', 'PowDB is single-writer — it has one global write lock. A second transaction would block on it forever; ' +
395
- 'complete the open transaction first.');
536
+ return new UnsupportedFeatureError('re-entrant transactions', 'powdb', 'PowDB is single-writer — a transaction opened from inside an active transaction callback would deadlock ' +
537
+ 'on the write lock the open transaction holds. Use the `tx` client the callback receives, or start the ' +
538
+ 'second transaction after the first completes. (Independent concurrent transactions queue automatically.)');
539
+ }
540
+ // ---------------------------------------------------------------------------
541
+ // Single-writer transaction gate — FIFO queueing + re-entrancy detection
542
+ // ---------------------------------------------------------------------------
543
+ /**
544
+ * Default cap (ms) on how long a `begin` may wait in the FIFO queue for
545
+ * PowDB's single global write lock before failing with a typed
546
+ * {@link TimeoutError} (E002). Prevents silent starvation behind a wedged
547
+ * transaction. Override via `transactionQueueTimeoutMs`
548
+ * ({@link TurbinePowdbOptions} / {@link PowdbPoolOptions}); `0` or `Infinity`
549
+ * waits without limit.
550
+ */
551
+ export const DEFAULT_TX_QUEUE_TIMEOUT_MS = 30_000;
552
+ /**
553
+ * Upper bound on the best-effort `rollback` a release-with-open-hold fires
554
+ * before handing the gate to the next queued transaction. Keeps a dead socket
555
+ * from wedging the FIFO queue while still letting the engine drop its global
556
+ * write lock cleanly in the normal case.
557
+ */
558
+ const RELEASE_ROLLBACK_TIMEOUT_MS = 2_000;
559
+ const powdbTxStorage = new AsyncLocalStorage();
560
+ /**
561
+ * FIFO gate serializing transactions across a whole pool. PowDB holds one
562
+ * global write lock, so at most one transaction may be open per database:
563
+ * without this gate a second `begin` on the networked transport checks out a
564
+ * fresh connection and blocks forever on the lock the open transaction holds
565
+ * (and the embedded engine rejects it with a raw parse error).
566
+ *
567
+ * `acquire()` is called (synchronously, see below) for every `begin`:
568
+ * - a **re-entrant** `begin` — issued from inside an active transaction's
569
+ * async context, detected via {@link powdbTxStorage} — throws E017
570
+ * immediately. Queueing it can never succeed: the open transaction cannot
571
+ * commit while its callback awaits the queued one.
572
+ * - an **independent** `begin` waits its FIFO turn, bounded by the queue
573
+ * timeout, then returns a {@link PowdbTxHold} the caller finishes on
574
+ * commit / rollback / connection release.
575
+ *
576
+ * Context propagation: `acquire()` only READS the async context (the
577
+ * chain-walking check in its prologue); it never writes it. The marker is
578
+ * planted by the pool's `wrapTransactionCallback` (`TurbineClient` invokes
579
+ * the user callback as `powdbTxStorage.run(hold.ctx, fn)`), so it exists
580
+ * exclusively inside the transaction CALLBACK's async subtree. Everything
581
+ * launched from inside the callback (table ops on the `tx` client, a
582
+ * fire-and-forget `db.$transaction`, nested-write implicit transactions)
583
+ * inherits it; the CALLER's context stays unmarked. This is load-bearing: the
584
+ * pre-0.31 implementation used `enterWith()` in acquire's prologue, which
585
+ * mutates the caller's shared context. On a cold client the FIRST same-tick
586
+ * burst of `db.$transaction` calls saw call #1's live marker from every
587
+ * sibling and falsely threw re-entrant E017 (9/10 rejected in production;
588
+ * one warm-up transaction masked it because its pruned `done` marker changed
589
+ * the propagation shape). With `run()` the sibling contexts are unmarked by
590
+ * construction, so they queue FIFO as intended. Markers form a chain
591
+ * ({@link PowdbTxContext.parent}) so transactions nested across DIFFERENT
592
+ * pools cannot shadow an outer marker on this gate; the re-entrancy check
593
+ * walks every live ancestor.
594
+ *
595
+ * **Residual limitation:** a transaction begun OUTSIDE a `$transaction`
596
+ * callback plants no marker (a manual raw `begin` span, or a worker loop
597
+ * whose continuations captured their context before the transaction opened).
598
+ * A deadlocking re-entrant begin from such a context cannot be told apart
599
+ * from a legitimate independent concurrent transaction: it queues FIFO and,
600
+ * because the open transaction is awaiting it, times out after
601
+ * `transactionQueueTimeoutMs` with a typed {@link TimeoutError} rather than
602
+ * throwing E017 instantly. The 30s default is the backstop for exactly this
603
+ * case: do not set `transactionQueueTimeoutMs: 0` (wait forever) in code
604
+ * paths that may start transactions from unmarked contexts.
605
+ */
606
+ class PowdbTxGate {
607
+ queueTimeoutMs;
608
+ /** Tail of the FIFO queue — resolves once every earlier transaction has finished. */
609
+ tail = Promise.resolve();
610
+ constructor(queueTimeoutMs) {
611
+ this.queueTimeoutMs = queueTimeoutMs;
612
+ }
613
+ /**
614
+ * Take a place in the transaction queue. The prologue walks the caller's
615
+ * marker chain (planted around transaction callbacks by the pool's
616
+ * `wrapTransactionCallback`) and throws re-entrant E017 when any live
617
+ * ancestor holds THIS gate. It never marks the caller's context itself;
618
+ * the returned hold carries the fresh marker (`hold.ctx`) for
619
+ * `wrapTransactionCallback` to scope around the user callback.
620
+ */
621
+ async acquire() {
622
+ // Walk the WHOLE marker chain, not just the innermost marker: with two
623
+ // pools, dbA-tx → dbB-tx → dbA-begin leaves dbB's marker innermost, but
624
+ // the dbA ancestor is still open — queueing the inner dbA begin behind it
625
+ // would deadlock. Any live ancestor on this gate ⇒ re-entrant E017.
626
+ // Prune completed heads first (`done` never flips back) so sequential
627
+ // transactions issued from one long-lived context do not chain — and leak
628
+ // — unboundedly; what remains is bounded by real nesting depth.
629
+ let parent = powdbTxStorage.getStore();
630
+ while (parent?.done)
631
+ parent = parent.parent;
632
+ for (let c = parent; c !== undefined; c = c.parent) {
633
+ if (c.gate === this && !c.done) {
634
+ throw reentrantTransactionError();
635
+ }
636
+ }
637
+ const ctx = { gate: this, done: false, parent };
638
+ let handOff;
639
+ const finished = new Promise((resolve) => {
640
+ handOff = resolve;
641
+ });
642
+ const ahead = this.tail;
643
+ this.tail = ahead.then(() => finished);
644
+ const hold = {
645
+ ctx,
646
+ finish: () => {
647
+ if (ctx.done)
648
+ return;
649
+ ctx.done = true;
650
+ handOff();
651
+ },
652
+ };
653
+ // --- FIFO wait (optionally bounded) ---
654
+ const timeoutMs = this.queueTimeoutMs;
655
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
656
+ await ahead;
657
+ return hold;
658
+ }
659
+ await new Promise((resolve, reject) => {
660
+ let settled = false;
661
+ const timer = setTimeout(() => {
662
+ if (settled)
663
+ return;
664
+ settled = true;
665
+ // Give up the queue slot: finishing resolves our `finished` link, so
666
+ // once every transaction ahead completes, later waiters skip straight
667
+ // past us instead of stalling behind a slot nobody will release.
668
+ hold.finish();
669
+ reject(new TimeoutError(timeoutMs, 'PowDB transaction (queued behind the single-writer lock)'));
670
+ }, timeoutMs);
671
+ void ahead.then(() => {
672
+ if (settled)
673
+ return;
674
+ settled = true;
675
+ clearTimeout(timer);
676
+ resolve();
677
+ });
678
+ });
679
+ return hold;
680
+ }
396
681
  }
397
682
  /** Adapt a PowDB result into the pg-compat `{ rows, rowCount, fields }` shape. */
398
683
  function adaptResult(r) {
@@ -426,49 +711,87 @@ export class PowdbPool {
426
711
  toParam;
427
712
  closed = false;
428
713
  /**
429
- * Pool-level single-writer guard. PowDB holds one global write lock, so at
430
- * most one transaction may be open across the whole pool. A `begin` issued
431
- * while this is `true` is rejected (it would otherwise check out a second
432
- * connection and block on the lock forever — the networked re-entrant hang).
714
+ * Pool-level single-writer gate. PowDB holds one global write lock, so at
715
+ * most one transaction may be open across the whole pool. Concurrent
716
+ * `begin`s queue FIFO on the gate (instead of checking out a second
717
+ * connection and blocking on the lock forever — the networked hang);
718
+ * re-entrant `begin`s throw E017 (see {@link PowdbTxGate}).
719
+ */
720
+ txGate;
721
+ /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
722
+ poolHold = null;
723
+ /**
724
+ * Clients currently checked out via {@link connect}. The driver pool's
725
+ * `close()` only closes IDLE clients (checked-out ones are documented as the
726
+ * caller's responsibility), so {@link end} destroys these explicitly;
727
+ * otherwise a `disconnect()` racing an unreleased connection would leave a
728
+ * live socket holding the process open until the server's idle timeout.
433
729
  */
434
- activeTransaction = false;
435
- constructor(pool, toParam = (v) => toPowdbParam(v)) {
730
+ checkedOut = new Set();
731
+ constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
436
732
  this.pool = pool;
437
733
  this.toParam = toParam;
734
+ this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
438
735
  }
439
- /**
440
- * Enforce the single-writer model on a transaction-control statement. Throws
441
- * (before any query runs) if a `begin` arrives while a transaction is open;
442
- * otherwise flips the pool-level flag. Returns the control kind so the caller
443
- * can decide whether it even needs to hit the engine.
444
- */
445
- guardTxControl(powql) {
736
+ // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
737
+ async query(text, values) {
738
+ this.assertOpen();
739
+ const { text: powql, params } = normalizeQueryArgs(text, values);
446
740
  const ctl = txControl(powql);
447
741
  if (ctl === 'begin') {
448
- if (this.activeTransaction)
449
- throw reentrantTransactionError();
450
- this.activeTransaction = true;
742
+ // Gate BEFORE touching the engine: a begin from inside an active
743
+ // transaction callback throws re-entrant E017 fast; an independent
744
+ // concurrent one waits its FIFO turn.
745
+ this.poolHold = await this.txGate.acquire();
451
746
  }
452
- else if (ctl === 'commit' || ctl === 'rollback') {
453
- this.activeTransaction = false;
747
+ if ((ctl === 'commit' || ctl === 'rollback') && this.poolHold === null) {
748
+ // No gate hold → our `begin` never ran (gate timeout / re-entrant
749
+ // E017 / no begin at all). Never forward a stray commit/rollback to
750
+ // the engine — PowDB is single-writer, so it could only ever end a
751
+ // DIFFERENT caller's open transaction. Empty success instead.
752
+ return { rows: [], rowCount: 0, fields: [] };
454
753
  }
455
- return ctl;
456
- }
457
- // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
458
- async query(text, values) {
459
- const { text: powql, params } = normalizeQueryArgs(text, values);
460
- this.guardTxControl(powql);
461
754
  try {
462
755
  const result = await this.pool.withClient((c) => c.query(powql, params.map(this.toParam)));
463
756
  return adaptResult(result);
464
757
  }
465
758
  catch (err) {
759
+ if (ctl === 'begin') {
760
+ this.poolHold?.finish();
761
+ this.poolHold = null;
762
+ }
466
763
  throw wrapPowdbError(err);
467
764
  }
765
+ finally {
766
+ if (ctl === 'commit' || ctl === 'rollback') {
767
+ this.poolHold?.finish();
768
+ this.poolHold = null;
769
+ }
770
+ }
771
+ }
772
+ /**
773
+ * Typed guard mirroring {@link PowdbEmbeddedPool}: after `end()` the driver
774
+ * pool throws a raw `Error('pool closed')` that {@link wrapPowdbError}
775
+ * cannot classify — surface the same ConnectionError on both transports.
776
+ */
777
+ assertOpen() {
778
+ if (this.closed) {
779
+ throw new ConnectionError('[turbine] The PowDB pool is closed — disconnect() was already called on this client.');
780
+ }
468
781
  }
469
782
  async connect() {
470
- const client = await this.pool.acquire();
783
+ this.assertOpen();
784
+ let client;
785
+ try {
786
+ client = await this.pool.acquire();
787
+ }
788
+ catch (err) {
789
+ throw wrapPowdbError(err);
790
+ }
791
+ this.checkedOut.add(client);
471
792
  let broken = false;
793
+ /** The gate hold of the transaction begun through THIS connection (if any). */
794
+ let hold = null;
472
795
  return {
473
796
  // The networked client's query() supports concurrent in-flight calls on
474
797
  // one connection: every request frame is written to the socket
@@ -478,29 +801,100 @@ export class PowdbPool {
478
801
  // for the batch's rollback contract because a failed statement leaves
479
802
  // the engine's transaction open (no aborted state, no auto-rollback) —
480
803
  // later pipelined statements execute inside the same still-open
481
- // transaction and the final `rollback` discards every effect.
804
+ // transaction and the final `rollback` discards every effect. (The
805
+ // batch path awaits `begin` before dispatching the burst, so the gate
806
+ // wait below never reorders statements around it.)
482
807
  supportsPipelining: true,
483
808
  // biome-ignore lint/suspicious/noExplicitAny: see query() above.
484
809
  query: async (text, values) => {
485
810
  const { text: powql, params } = normalizeQueryArgs(text, values);
486
- // Guard BEFORE acquiring the engine — a re-entrant begin throws fast
487
- // instead of blocking on the global write lock the open tx holds.
488
- this.guardTxControl(powql);
811
+ const ctl = txControl(powql);
812
+ if (ctl === 'begin') {
813
+ // Gate BEFORE hitting the engine: a begin from inside an active
814
+ // transaction callback throws re-entrant E017 fast instead of
815
+ // blocking on the global write lock the open tx holds; an
816
+ // independent concurrent begin queues FIFO.
817
+ hold = await this.txGate.acquire();
818
+ }
819
+ if ((ctl === 'commit' || ctl === 'rollback') && hold === null) {
820
+ // This connection never acquired the gate — its `begin` never ran
821
+ // (gate timeout / re-entrant E017). A stray commit/rollback must
822
+ // never reach the single-writer engine, where it could only end a
823
+ // DIFFERENT caller's open transaction. Empty success instead.
824
+ return { rows: [], rowCount: 0, fields: [] };
825
+ }
489
826
  try {
490
827
  return adaptResult(await client.query(powql, params.map(this.toParam)));
491
828
  }
492
829
  catch (err) {
493
830
  broken = true;
831
+ if (ctl === 'begin') {
832
+ hold?.finish();
833
+ hold = null;
834
+ }
494
835
  throw wrapPowdbError(err);
495
836
  }
837
+ finally {
838
+ if (ctl === 'commit' || ctl === 'rollback') {
839
+ hold?.finish();
840
+ hold = null;
841
+ }
842
+ }
496
843
  },
497
- release: () => {
498
- // Releasing this connection ends its transaction scope. Clear the flag
499
- // as a safety net so a tx torn down without an explicit commit/rollback
500
- // (e.g. a timeout that destroys the connection) never leaves the pool
501
- // permanently believing a transaction is still open.
502
- this.activeTransaction = false;
503
- return broken ? this.pool.destroy(client) : this.pool.release(client);
844
+ // Single-writer re-entrancy scoping: TurbineClient runs the user's
845
+ // transaction callback through this, so the gate's marker lives ONLY
846
+ // in the callback's async subtree (everything inside it, tx table ops
847
+ // and fire-and-forget db.$transaction alike, inherits it; the caller's
848
+ // context stays unmarked). `hold.ctx` is the same object `finish()` flips, so
849
+ // done-pruning keeps working for contexts that outlive the callback.
850
+ wrapTransactionCallback: (fn) => {
851
+ const ctx = hold?.ctx;
852
+ return ctx ? powdbTxStorage.run(ctx, fn) : fn();
853
+ },
854
+ release: (err) => {
855
+ // Releasing this connection ends its transaction scope. pg semantics:
856
+ // a truthy `err` means "destroy, don't re-idle" — client.ts's
857
+ // $transaction timeout path relies on that to keep an abandoned
858
+ // callback's connection out of the pool. Additionally, an OPEN hold
859
+ // here means the tx begun on this connection never saw commit/rollback
860
+ // (timeout teardown or caller bug): fire a best-effort bounded
861
+ // `rollback` FIRST so the engine drops its global write lock and the
862
+ // server-side transaction ends (destroying the socket alone leaves it
863
+ // open until the server's idle timeout), THEN hand the gate to the
864
+ // next queued transaction. If the rollback fails or times out the
865
+ // connection is treated as broken and destroyed. Never throws — a
866
+ // teardown error must not mask the transaction's real outcome.
867
+ const openHold = hold;
868
+ hold = null;
869
+ this.checkedOut.delete(client);
870
+ const teardown = async () => {
871
+ let rolledBack = false;
872
+ if (openHold) {
873
+ try {
874
+ await Promise.race([
875
+ client.query('rollback', []).then(() => {
876
+ rolledBack = true;
877
+ }),
878
+ new Promise((resolve) => {
879
+ const t = setTimeout(resolve, RELEASE_ROLLBACK_TIMEOUT_MS);
880
+ t.unref?.();
881
+ }),
882
+ ]);
883
+ }
884
+ catch {
885
+ /* best-effort */
886
+ }
887
+ openHold.finish();
888
+ }
889
+ const mustDestroy = broken || Boolean(err) || (openHold !== null && !rolledBack);
890
+ try {
891
+ await (mustDestroy ? this.pool.destroy(client) : this.pool.release(client));
892
+ }
893
+ catch {
894
+ /* the pool may already be closed */
895
+ }
896
+ };
897
+ void teardown();
504
898
  },
505
899
  };
506
900
  }
@@ -508,7 +902,16 @@ export class PowdbPool {
508
902
  if (this.closed)
509
903
  return;
510
904
  this.closed = true;
905
+ // close() rejects pending waiters and closes every IDLE client…
511
906
  await this.pool.close();
907
+ // …but NOT checked-out ones (documented in @zvndev/powdb-client: "callers
908
+ // that still hold one when close() is called are responsible for closing
909
+ // it themselves"). Destroy any stragglers so end() never leaves a live
910
+ // socket keeping the process alive until the server's idle timeout.
911
+ for (const client of this.checkedOut) {
912
+ this.pool.destroy(client);
913
+ }
914
+ this.checkedOut.clear();
512
915
  }
513
916
  }
514
917
  /** Normalize the embedded addon's loosely-typed result into a {@link PowdbResult}. */
@@ -612,57 +1015,112 @@ export class PowdbEmbeddedPool {
612
1015
  db;
613
1016
  closed = false;
614
1017
  /**
615
- * Single-writer guard. The embedded engine is one handle with one global
1018
+ * Single-writer gate. The embedded engine is one handle with one global
616
1019
  * write lock — only one transaction may be open at a time. A re-entrant
617
- * `begin` (a fresh top-level `db.$transaction` opened inside an open one)
618
- * would otherwise hit PowDB's raw "already in a transaction" parse error;
619
- * this surfaces a typed error instead. (Nested `tx.$transaction` is caught
620
- * earlier still, by the savepoint override in {@link powdbDialect}.)
1020
+ * `begin` (a fresh top-level `db.$transaction` opened inside an open one's
1021
+ * callback) would otherwise hit PowDB's raw "already in a transaction"
1022
+ * parse error; the gate surfaces a typed E017 instead, while INDEPENDENT
1023
+ * concurrent transactions queue FIFO and run one at a time. (Nested
1024
+ * `tx.$transaction` is caught earlier still, by the savepoint override in
1025
+ * {@link powdbDialect}.)
621
1026
  */
622
- activeTransaction = false;
623
- constructor(db) {
1027
+ txGate;
1028
+ /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
1029
+ poolHoldRef = { hold: null };
1030
+ constructor(db, options = {}) {
624
1031
  this.db = db;
1032
+ this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
1033
+ }
1034
+ /** Materialize `$N` params and hand the PowQL to the in-process engine. */
1035
+ exec(powql, params) {
1036
+ const materialized = materializePowql(powql, params);
1037
+ return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
625
1038
  }
626
- /** Enforce the single-writer model on a transaction-control statement. */
627
- guardTxControl(powql) {
1039
+ /**
1040
+ * Run one statement, gating transaction control. `holdRef` scopes the gate
1041
+ * hold to whoever issued the `begin` (the pool itself or one checked-out
1042
+ * client), so finishing a transaction can never release a slot a different
1043
+ * transaction is holding.
1044
+ */
1045
+ async run(powql, params, holdRef) {
1046
+ if (this.closed) {
1047
+ throw new ConnectionError('[turbine] The PowDB embedded pool is closed: disconnect() was already called on this client.');
1048
+ }
628
1049
  const ctl = txControl(powql);
629
1050
  if (ctl === 'begin') {
630
- if (this.activeTransaction)
631
- throw reentrantTransactionError();
632
- this.activeTransaction = true;
1051
+ // Gate BEFORE hitting the engine: a begin from inside an active
1052
+ // transaction callback throws re-entrant E017 fast; independent
1053
+ // concurrent ones wait their FIFO turn.
1054
+ holdRef.hold = await this.txGate.acquire();
633
1055
  }
634
- else if (ctl === 'commit' || ctl === 'rollback') {
635
- this.activeTransaction = false;
1056
+ if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
1057
+ // This context never acquired the gate — its `begin` never ran (the
1058
+ // gate timed out / threw re-entrant E017, or no begin was issued at
1059
+ // all). The engine is ONE shared handle: forwarding this stray
1060
+ // commit/rollback would hit whatever transaction ANOTHER caller has
1061
+ // open on it (live-reproduced: a best-effort ROLLBACK after a failed
1062
+ // begin silently discarded a concurrent transaction's writes). Swallow
1063
+ // it as an empty success instead — there is nothing of ours to end.
1064
+ return { rows: [], rowCount: 0, fields: [] };
636
1065
  }
637
- }
638
- run(powql, params) {
639
- this.guardTxControl(powql);
640
1066
  try {
641
- const materialized = materializePowql(powql, params);
642
- return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
1067
+ return this.exec(powql, params);
643
1068
  }
644
1069
  catch (err) {
1070
+ if (ctl === 'begin') {
1071
+ holdRef.hold?.finish();
1072
+ holdRef.hold = null;
1073
+ }
645
1074
  throw wrapPowdbError(err);
646
1075
  }
1076
+ finally {
1077
+ if (ctl === 'commit' || ctl === 'rollback') {
1078
+ holdRef.hold?.finish();
1079
+ holdRef.hold = null;
1080
+ }
1081
+ }
647
1082
  }
648
1083
  // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
649
1084
  async query(text, values) {
650
1085
  const { text: powql, params } = normalizeQueryArgs(text, values);
651
- return this.run(powql, params);
1086
+ return this.run(powql, params, this.poolHoldRef);
652
1087
  }
653
1088
  async connect() {
654
1089
  // Single in-process handle — the "client" shares the one Database; tx
655
- // keywords run serially on it.
1090
+ // keywords run serially on it. Each checked-out client scopes its own
1091
+ // gate hold so release() only ever finishes ITS transaction.
1092
+ const holdRef = { hold: null };
656
1093
  return {
657
1094
  // biome-ignore lint/suspicious/noExplicitAny: see query() above.
658
1095
  query: async (text, values) => {
659
1096
  const { text: powql, params } = normalizeQueryArgs(text, values);
660
- return this.run(powql, params);
1097
+ return this.run(powql, params, holdRef);
1098
+ },
1099
+ // Scope the gate's re-entrancy marker to the user callback's async
1100
+ // subtree (see PowdbPool.connect(), identical contract).
1101
+ wrapTransactionCallback: (fn) => {
1102
+ const ctx = holdRef.hold?.ctx;
1103
+ return ctx ? powdbTxStorage.run(ctx, fn) : fn();
661
1104
  },
662
1105
  release: () => {
663
1106
  // End-of-scope safety net (see PowdbPool.connect()): a tx torn down
664
- // without an explicit commit/rollback must not wedge the handle.
665
- this.activeTransaction = false;
1107
+ // without an explicit commit/rollback must not wedge the queue — and
1108
+ // on the ONE shared embedded handle its open engine transaction must
1109
+ // actually be rolled back before the gate moves on, or the next
1110
+ // transaction's work interleaves into it. run() owns the
1111
+ // finish/null-out in its commit/rollback finally; the .finally here
1112
+ // is the fallback when run() itself rejects.
1113
+ const h = holdRef.hold;
1114
+ if (!h)
1115
+ return;
1116
+ void this.run('rollback', [], holdRef)
1117
+ .catch(() => {
1118
+ /* best-effort */
1119
+ })
1120
+ .finally(() => {
1121
+ h.finish();
1122
+ holdRef.hold = null;
1123
+ });
666
1124
  },
667
1125
  };
668
1126
  }
@@ -670,8 +1128,11 @@ export class PowdbEmbeddedPool {
670
1128
  if (this.closed)
671
1129
  return;
672
1130
  // The addon exposes no explicit close — drop the reference and let GC /
673
- // the engine's checkpoint flush. Caveat: durability is checkpoint-bound, so
674
- // hold the process open long enough for the final WAL flush in short scripts.
1131
+ // the engine's checkpoint flush. Marking the pool closed makes later
1132
+ // queries fail with a typed ConnectionError instead of silently running
1133
+ // against a handle the caller believes is gone. Caveat: durability is
1134
+ // checkpoint-bound, so hold the process open long enough for the final
1135
+ // WAL flush in short scripts.
675
1136
  this.closed = true;
676
1137
  }
677
1138
  }
@@ -685,10 +1146,14 @@ export { PowqlInterface } from './powql.js';
685
1146
  */
686
1147
  async function loadPowdb() {
687
1148
  try {
688
- return (await import('@zvndev/powdb-client'));
1149
+ // Via the .cts helper so the CJS build keeps a path to a REAL dynamic
1150
+ // import() — @zvndev/powdb-client ≥ 0.9 is ESM-only, and the CommonJS
1151
+ // pass transpiles a plain `import()` here into an unusable `require()`.
1152
+ return (await importOptionalPeer('@zvndev/powdb-client'));
689
1153
  }
690
1154
  catch (err) {
691
- throw new ConnectionError("[turbine] turbine-orm/powdb requires the optional peer dependency '@zvndev/powdb-client'. Install it: npm i @zvndev/powdb-client. " +
1155
+ throw new ConnectionError("[turbine] turbine-orm/powdb requires the optional peer dependency '@zvndev/powdb-client'. Install it: npm i @zvndev/powdb-client — " +
1156
+ 'or construct the PowDB pool yourself and inject it: turbinePowDB(pool, schema). ' +
692
1157
  `(${err.message})`);
693
1158
  }
694
1159
  }
@@ -702,13 +1167,16 @@ async function loadPowdb() {
702
1167
  async function loadPowdbEmbedded() {
703
1168
  let mod;
704
1169
  try {
705
- mod = (await import('@zvndev/powdb-embedded'));
1170
+ // Via the .cts helper — keeps a real dynamic import() available to the
1171
+ // CJS build in case a future addon version ships ESM-only (see loadPowdb).
1172
+ mod = (await importOptionalPeer('@zvndev/powdb-embedded'));
706
1173
  }
707
1174
  catch (err) {
708
1175
  throw new ConnectionError("[turbine] turbine-orm/powdb embedded mode requires the optional peer '@zvndev/powdb-embedded'. " +
709
1176
  'Install it: npm i @zvndev/powdb-embedded. If install succeeded but loading failed, your platform has no ' +
710
1177
  'prebuilt binary (prebuilts ship for macOS arm64/x64 and Linux glibc x64/arm64; Intel-mac/musl/Windows ' +
711
- 'build from source) — build it with `npm run build` in the addon, then retry. ' +
1178
+ 'build from source) — build it with `npm run build` in the addon, then retry. You can also construct the ' +
1179
+ 'pool yourself and inject it: turbinePowDB(pool, schema). ' +
712
1180
  `(${err.message})`);
713
1181
  }
714
1182
  if (!mod || typeof mod.Database?.open !== 'function') {
@@ -718,7 +1186,7 @@ async function loadPowdbEmbedded() {
718
1186
  return mod;
719
1187
  }
720
1188
  /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
721
- async function openEmbeddedPool(target) {
1189
+ async function openEmbeddedPool(target, poolOptions = {}) {
722
1190
  const mod = await loadPowdbEmbedded();
723
1191
  const { embedded: dir, syncMode, memoryLimit } = target;
724
1192
  let db;
@@ -744,7 +1212,7 @@ async function openEmbeddedPool(target) {
744
1212
  }
745
1213
  db.setSyncMode(syncMode);
746
1214
  }
747
- return new PowdbEmbeddedPool(db);
1215
+ return new PowdbEmbeddedPool(db, poolOptions);
748
1216
  }
749
1217
  /**
750
1218
  * Bind Turbine to PowDB. `target` is one of:
@@ -767,28 +1235,30 @@ async function openEmbeddedPool(target) {
767
1235
  export async function turbinePowDB(target, schema, options = {}) {
768
1236
  let pool;
769
1237
  let owns = false;
1238
+ const poolOptions = { transactionQueueTimeoutMs: options.transactionQueueTimeoutMs };
770
1239
  if (typeof target === 'string') {
771
- const mod = await loadPowdb();
1240
+ const mod = options.powdbClientModule ?? (await loadPowdb());
772
1241
  const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max: options.connectionLimit ?? 10 });
773
1242
  await assertNetworkedVersion(clientPool);
774
- pool = new PowdbPool(clientPool);
1243
+ pool = new PowdbPool(clientPool, undefined, poolOptions);
775
1244
  owns = true;
776
1245
  }
777
1246
  else if (target instanceof PowdbPool) {
1247
+ // An injected PowdbPool carries its own PowdbPoolOptions.
778
1248
  pool = target;
779
1249
  }
780
1250
  else if (isEmbeddedTarget(target)) {
781
- pool = await openEmbeddedPool(target);
1251
+ pool = await openEmbeddedPool(target, poolOptions);
782
1252
  owns = true;
783
1253
  }
784
1254
  else if (isPowdbClientPool(target)) {
785
- pool = new PowdbPool(target);
1255
+ pool = new PowdbPool(target, undefined, poolOptions);
786
1256
  }
787
1257
  else {
788
- const mod = await loadPowdb();
1258
+ const mod = options.powdbClientModule ?? (await loadPowdb());
789
1259
  const clientPool = new mod.Pool({ ...target, max: options.connectionLimit ?? 10 });
790
1260
  await assertNetworkedVersion(clientPool);
791
- pool = new PowdbPool(clientPool);
1261
+ pool = new PowdbPool(clientPool, undefined, poolOptions);
792
1262
  owns = true;
793
1263
  }
794
1264
  // The PowQL generator is loaded here to keep client.ts free of any PowDB import.
@@ -803,7 +1273,23 @@ export async function turbinePowDB(target, schema, options = {}) {
803
1273
  warnOnUnlimited: options.warnOnUnlimited,
804
1274
  queryInterfaceFactory,
805
1275
  }, schema);
806
- if (!owns) {
1276
+ if (owns) {
1277
+ // Turbine built this pool / embedded handle, so disconnect()/end() must
1278
+ // close it. client.ts sees TurbineConfig.pool as EXTERNAL (ownsPool =
1279
+ // false) and skips pool.end(); before this patch an owned networked
1280
+ // client leaked its live socket(s) on disconnect(), holding the process
1281
+ // open until powdb-server's idle timeout (~300s) closed them. Consistent
1282
+ // with turbineMssql's owned-pool patch.
1283
+ const baseDisconnect = client.disconnect.bind(client);
1284
+ const close = async () => {
1285
+ await baseDisconnect();
1286
+ await pool.end();
1287
+ };
1288
+ const patch = client;
1289
+ patch.disconnect = close;
1290
+ patch.end = close;
1291
+ }
1292
+ else {
807
1293
  // Injected pool — the caller owns its lifecycle.
808
1294
  client.disconnect = async () => { };
809
1295
  }