turbine-orm 0.28.3 → 0.30.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 (47) 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 +69 -5
  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 +18 -133
  9. package/dist/cjs/mysql.js +16 -129
  10. package/dist/cjs/optional-peer-import.cjs +122 -0
  11. package/dist/cjs/powdb.js +440 -81
  12. package/dist/cjs/powql.js +49 -25
  13. package/dist/cjs/query/builder.js +290 -23
  14. package/dist/cjs/query/filters.js +32 -1
  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 +44 -6
  22. package/dist/client.js +69 -5
  23. package/dist/generate.d.ts +16 -4
  24. package/dist/generate.js +71 -25
  25. package/dist/index.d.ts +1 -0
  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 +16 -101
  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 +94 -26
  34. package/dist/powdb.js +435 -80
  35. package/dist/powql.d.ts +6 -0
  36. package/dist/powql.js +51 -27
  37. package/dist/query/builder.d.ts +60 -3
  38. package/dist/query/builder.js +291 -24
  39. package/dist/query/deferred.d.ts +7 -2
  40. package/dist/query/filters.d.ts +18 -0
  41. package/dist/query/filters.js +30 -0
  42. package/dist/query/types.d.ts +19 -0
  43. package/dist/schema-metadata.d.ts +77 -0
  44. package/dist/schema-metadata.js +313 -0
  45. package/dist/schema.d.ts +10 -0
  46. package/dist/sqlite.js +9 -90
  47. package/package.json +3 -3
package/dist/cjs/powdb.js CHANGED
@@ -23,8 +23,19 @@
23
23
  * `string` PKs hold UUID strings.
24
24
  * - **No JSON aggregation / link navigation** — single-query nested `with` is
25
25
  * impossible → it degrades to batched N+1 loaders (Phase B).
26
- * - **Single global write lock; no savepoints/isolation/pipelining** — nested
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.
33
+ * - **The wire protocol pipelines** — `@zvndev/powdb-client` writes each
34
+ * request frame immediately and matches replies FIFO, so multiple queries
35
+ * may be in flight on one connection. {@link PowdbPool}'s checked-out
36
+ * clients advertise `supportsPipelining`, which lets the batch
37
+ * `$transaction([...])` overload dispatch all statements in one write
38
+ * burst (~1 round trip) instead of one round trip per statement.
28
39
  *
29
40
  * `@zvndev/powdb-client` is an **optional peer dependency** loaded by dynamic
30
41
  * import; `npm i turbine-orm` still pulls only `pg`.
@@ -75,11 +86,15 @@ var __importStar = (this && this.__importStar) || (function () {
75
86
  return result;
76
87
  };
77
88
  })();
89
+ var __importDefault = (this && this.__importDefault) || function (mod) {
90
+ return (mod && mod.__esModule) ? mod : { "default": mod };
91
+ };
78
92
  Object.defineProperty(exports, "__esModule", { value: true });
79
- 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;
80
94
  exports.parsePowdbUrl = parsePowdbUrl;
81
95
  exports.assertSupportedPowdbVersion = assertSupportedPowdbVersion;
82
96
  exports.powqlColumnType = powqlColumnType;
97
+ exports.quotePowqlIdent = quotePowqlIdent;
83
98
  exports.powqlSchemaDDL = powqlSchemaDDL;
84
99
  exports.coerceValue = coerceValue;
85
100
  exports.rowToEntity = rowToEntity;
@@ -87,9 +102,11 @@ exports.wrapPowdbError = wrapPowdbError;
87
102
  exports.encodePowqlLiteral = encodePowqlLiteral;
88
103
  exports.materializePowql = materializePowql;
89
104
  exports.turbinePowDB = turbinePowDB;
105
+ const node_async_hooks_1 = require("node:async_hooks");
90
106
  const client_js_1 = require("./client.js");
91
107
  const dialect_js_1 = require("./dialect.js");
92
108
  const errors_js_1 = require("./errors.js");
109
+ const optional_peer_import_cjs_1 = __importDefault(require("./optional-peer-import.cjs"));
93
110
  /**
94
111
  * Capability descriptor for PowDB. PowQL generation is owned by
95
112
  * {@link PowqlInterface} (not the SQL `Dialect`), so this dialect exists only to
@@ -105,9 +122,11 @@ const errors_js_1 = require("./errors.js");
105
122
  * {@link UnsupportedFeatureError} (E017): a nested `tx.$transaction` emits a
106
123
  * savepoint synchronously (before any DB call) and so fails fast with a
107
124
  * clear typed error instead of leaking PowDB's cryptic `Parse(... 'sp_1')`.
108
- * The pool-level begin-while-active guard (see {@link PowdbPool}) catches the
109
- * other re-entrant shape — a fresh top-level `db.$transaction` opened inside
110
- * 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.
111
130
  * Isolation levels remain Phase B.
112
131
  */
113
132
  exports.powdbDialect = {
@@ -253,6 +272,125 @@ function isDateColumn(col) {
253
272
  * `auto` modifier, so PowDB assigns a monotonic id on insert and Turbine stops
254
273
  * synthesizing a client-side value for it.
255
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
+ }
256
394
  function powqlSchemaDDL(schema) {
257
395
  const stmts = [];
258
396
  for (const meta of Object.values(schema.tables)) {
@@ -274,13 +412,13 @@ function powqlSchemaDDL(schema) {
274
412
  // a plain typed column (Turbine assigns the value client-side instead).
275
413
  if (col.isGenerated && powqlColumnType(col) === 'int')
276
414
  mods.push('auto');
277
- return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${col.name}: ${powqlColumnType(col)}`;
415
+ return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlColumnType(col)}`;
278
416
  });
279
- stmts.push(`type ${meta.name} {\n${fields.join(',\n')}\n}`);
417
+ stmts.push(`type ${quotePowqlIdent(meta.name)} {\n${fields.join(',\n')}\n}`);
280
418
  // Secondary unique constraints (beyond the PK) become unique indexes.
281
419
  for (const uniq of meta.uniqueColumns) {
282
420
  if (uniq.length === 1 && !pkSet.has(uniq[0])) {
283
- stmts.push(`alter ${meta.name} add unique .${uniq[0]}`);
421
+ stmts.push(`alter ${quotePowqlIdent(meta.name)} add unique .${quotePowqlIdent(uniq[0])}`);
284
422
  }
285
423
  }
286
424
  }
@@ -380,6 +518,12 @@ function wrapPowdbError(err) {
380
518
  const m = /column ['"]?(\w+)['"]?/i.exec(msg);
381
519
  return new errors_js_1.NotNullViolationError({ column: m?.[1], cause: err });
382
520
  }
521
+ // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
522
+ // connection held the single global write lock past the server's
523
+ // --tx-wait-timeout-ms. Retryable timeout, not a query defect.
524
+ if (/transaction gate timeout/i.test(msg)) {
525
+ return new errors_js_1.TimeoutError(0, 'PowDB transaction gate');
526
+ }
383
527
  // Type mismatch / parse / execution / storage / unexpected → validation
384
528
  // (E003). On the embedded transport these are the only signal we get
385
529
  // (code is always 'GenericFailure'); on the networked path they are a
@@ -425,15 +569,142 @@ function txControl(powql) {
425
569
  return null;
426
570
  }
427
571
  /**
428
- * The error a pool throws when a `begin` arrives while a transaction is already
429
- * open. PowDB has ONE global write lock and supports neither concurrent nor
430
- * nested transactions: on the networked transport a second `begin` checks out a
431
- * fresh pooled connection and blocks forever on the lock the open transaction
432
- * holds. This guard converts that hang into a fast, typed error.
572
+ * The error a pool throws when a `begin` arrives from INSIDE an already-open
573
+ * transaction's async context (a re-entrant `db.$transaction`). PowDB has ONE
574
+ * global write lock and no savepoints: queueing a re-entrant transaction would
575
+ * deadlock (the outer callback awaits the inner transaction, which waits on
576
+ * the write lock the outer transaction holds), and on the networked transport
577
+ * it would block a fresh pooled connection on the lock forever. This guard
578
+ * converts that hang into a fast, typed error. Independent concurrent
579
+ * transactions do NOT hit this — they queue FIFO on {@link PowdbTxGate}.
433
580
  */
434
581
  function reentrantTransactionError() {
435
- 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; ' +
436
- 'complete the open transaction first.');
582
+ 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 ' +
583
+ 'on the write lock the open transaction holds. Use the `tx` client the callback receives, or start the ' +
584
+ 'second transaction after the first completes. (Independent concurrent transactions queue automatically.)');
585
+ }
586
+ // ---------------------------------------------------------------------------
587
+ // Single-writer transaction gate — FIFO queueing + re-entrancy detection
588
+ // ---------------------------------------------------------------------------
589
+ /**
590
+ * Default cap (ms) on how long a `begin` may wait in the FIFO queue for
591
+ * PowDB's single global write lock before failing with a typed
592
+ * {@link TimeoutError} (E002). Prevents silent starvation behind a wedged
593
+ * transaction. Override via `transactionQueueTimeoutMs`
594
+ * ({@link TurbinePowdbOptions} / {@link PowdbPoolOptions}); `0` or `Infinity`
595
+ * waits without limit.
596
+ */
597
+ exports.DEFAULT_TX_QUEUE_TIMEOUT_MS = 30_000;
598
+ const powdbTxStorage = new node_async_hooks_1.AsyncLocalStorage();
599
+ /**
600
+ * FIFO gate serializing transactions across a whole pool. PowDB holds one
601
+ * global write lock, so at most one transaction may be open per database:
602
+ * without this gate a second `begin` on the networked transport checks out a
603
+ * fresh connection and blocks forever on the lock the open transaction holds
604
+ * (and the embedded engine rejects it with a raw parse error).
605
+ *
606
+ * `acquire()` is called (synchronously, see below) for every `begin`:
607
+ * - a **re-entrant** `begin` — issued from inside an active transaction's
608
+ * async context, detected via {@link powdbTxStorage} — throws E017
609
+ * immediately. Queueing it can never succeed: the open transaction cannot
610
+ * commit while its callback awaits the queued one.
611
+ * - an **independent** `begin` waits its FIFO turn, bounded by the queue
612
+ * timeout, then returns a {@link PowdbTxHold} the caller finishes on
613
+ * commit / rollback / connection release.
614
+ *
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.
624
+ *
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.
634
+ */
635
+ class PowdbTxGate {
636
+ queueTimeoutMs;
637
+ /** Tail of the FIFO queue — resolves once every earlier transaction has finished. */
638
+ tail = Promise.resolve();
639
+ constructor(queueTimeoutMs) {
640
+ this.queueTimeoutMs = queueTimeoutMs;
641
+ }
642
+ /**
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.
646
+ */
647
+ async acquire() {
648
+ // --- synchronous section (runs before the caller's first await) ---
649
+ // Walk the WHOLE marker chain, not just the innermost marker: with two
650
+ // pools, dbA-tx → dbB-tx → dbA-begin leaves dbB's marker innermost, but
651
+ // the dbA ancestor is still open — queueing the inner dbA begin behind it
652
+ // would deadlock. Any live ancestor on this gate ⇒ re-entrant E017.
653
+ // Prune completed heads first (`done` never flips back) so sequential
654
+ // transactions issued from one long-lived context do not chain — and leak
655
+ // — unboundedly; what remains is bounded by real nesting depth.
656
+ let parent = powdbTxStorage.getStore();
657
+ while (parent?.done)
658
+ parent = parent.parent;
659
+ for (let c = parent; c !== undefined; c = c.parent) {
660
+ if (c.gate === this && !c.done) {
661
+ throw reentrantTransactionError();
662
+ }
663
+ }
664
+ const ctx = { gate: this, done: false, parent };
665
+ powdbTxStorage.enterWith(ctx);
666
+ let handOff;
667
+ const finished = new Promise((resolve) => {
668
+ handOff = resolve;
669
+ });
670
+ const ahead = this.tail;
671
+ this.tail = ahead.then(() => finished);
672
+ const hold = {
673
+ finish: () => {
674
+ if (ctx.done)
675
+ return;
676
+ ctx.done = true;
677
+ handOff();
678
+ },
679
+ };
680
+ // --- FIFO wait (optionally bounded) ---
681
+ const timeoutMs = this.queueTimeoutMs;
682
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
683
+ await ahead;
684
+ return hold;
685
+ }
686
+ await new Promise((resolve, reject) => {
687
+ let settled = false;
688
+ const timer = setTimeout(() => {
689
+ if (settled)
690
+ return;
691
+ settled = true;
692
+ // Give up the queue slot: finishing resolves our `finished` link, so
693
+ // once every transaction ahead completes, later waiters skip straight
694
+ // past us instead of stalling behind a slot nobody will release.
695
+ hold.finish();
696
+ reject(new errors_js_1.TimeoutError(timeoutMs, 'PowDB transaction (queued behind the single-writer lock)'));
697
+ }, timeoutMs);
698
+ void ahead.then(() => {
699
+ if (settled)
700
+ return;
701
+ settled = true;
702
+ clearTimeout(timer);
703
+ resolve();
704
+ });
705
+ });
706
+ return hold;
707
+ }
437
708
  }
438
709
  /** Adapt a PowDB result into the pg-compat `{ rows, rowCount, fields }` shape. */
439
710
  function adaptResult(r) {
@@ -467,70 +738,117 @@ class PowdbPool {
467
738
  toParam;
468
739
  closed = false;
469
740
  /**
470
- * Pool-level single-writer guard. PowDB holds one global write lock, so at
471
- * most one transaction may be open across the whole pool. A `begin` issued
472
- * while this is `true` is rejected (it would otherwise check out a second
473
- * connection and block on the lock forever — the networked re-entrant hang).
741
+ * Pool-level single-writer gate. PowDB holds one global write lock, so at
742
+ * most one transaction may be open across the whole pool. Concurrent
743
+ * `begin`s queue FIFO on the gate (instead of checking out a second
744
+ * connection and blocking on the lock forever — the networked hang);
745
+ * re-entrant `begin`s throw E017 (see {@link PowdbTxGate}).
474
746
  */
475
- activeTransaction = false;
476
- constructor(pool, toParam = (v) => toPowdbParam(v)) {
747
+ txGate;
748
+ /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
749
+ poolHold = null;
750
+ constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
477
751
  this.pool = pool;
478
752
  this.toParam = toParam;
753
+ this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
479
754
  }
480
- /**
481
- * Enforce the single-writer model on a transaction-control statement. Throws
482
- * (before any query runs) if a `begin` arrives while a transaction is open;
483
- * otherwise flips the pool-level flag. Returns the control kind so the caller
484
- * can decide whether it even needs to hit the engine.
485
- */
486
- guardTxControl(powql) {
755
+ // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
756
+ async query(text, values) {
757
+ const { text: powql, params } = normalizeQueryArgs(text, values);
487
758
  const ctl = txControl(powql);
488
759
  if (ctl === 'begin') {
489
- if (this.activeTransaction)
490
- throw reentrantTransactionError();
491
- this.activeTransaction = true;
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.)
763
+ this.poolHold = await this.txGate.acquire();
492
764
  }
493
- else if (ctl === 'commit' || ctl === 'rollback') {
494
- this.activeTransaction = false;
765
+ if ((ctl === 'commit' || ctl === 'rollback') && this.poolHold === null) {
766
+ // No gate hold → our `begin` never ran (gate timeout / re-entrant
767
+ // E017 / no begin at all). Never forward a stray commit/rollback to
768
+ // the engine — PowDB is single-writer, so it could only ever end a
769
+ // DIFFERENT caller's open transaction. Empty success instead.
770
+ return { rows: [], rowCount: 0, fields: [] };
495
771
  }
496
- return ctl;
497
- }
498
- // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
499
- async query(text, values) {
500
- const { text: powql, params } = normalizeQueryArgs(text, values);
501
- this.guardTxControl(powql);
502
772
  try {
503
773
  const result = await this.pool.withClient((c) => c.query(powql, params.map(this.toParam)));
504
774
  return adaptResult(result);
505
775
  }
506
776
  catch (err) {
777
+ if (ctl === 'begin') {
778
+ this.poolHold?.finish();
779
+ this.poolHold = null;
780
+ }
507
781
  throw wrapPowdbError(err);
508
782
  }
783
+ finally {
784
+ if (ctl === 'commit' || ctl === 'rollback') {
785
+ this.poolHold?.finish();
786
+ this.poolHold = null;
787
+ }
788
+ }
509
789
  }
510
790
  async connect() {
511
791
  const client = await this.pool.acquire();
512
792
  let broken = false;
793
+ /** The gate hold of the transaction begun through THIS connection (if any). */
794
+ let hold = null;
513
795
  return {
796
+ // The networked client's query() supports concurrent in-flight calls on
797
+ // one connection: every request frame is written to the socket
798
+ // immediately and replies are matched to callers in FIFO order. That
799
+ // lets the batch `$transaction([...])` path dispatch all statements in
800
+ // one write burst instead of paying a round trip per statement. Safe
801
+ // for the batch's rollback contract because a failed statement leaves
802
+ // the engine's transaction open (no aborted state, no auto-rollback) —
803
+ // later pipelined statements execute inside the same still-open
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.)
807
+ supportsPipelining: true,
514
808
  // biome-ignore lint/suspicious/noExplicitAny: see query() above.
515
809
  query: async (text, values) => {
516
810
  const { text: powql, params } = normalizeQueryArgs(text, values);
517
- // Guard BEFORE acquiring the engine — a re-entrant begin throws fast
518
- // instead of blocking on the global write lock the open tx holds.
519
- this.guardTxControl(powql);
811
+ const ctl = txControl(powql);
812
+ 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.
816
+ hold = await this.txGate.acquire();
817
+ }
818
+ if ((ctl === 'commit' || ctl === 'rollback') && hold === null) {
819
+ // This connection never acquired the gate — its `begin` never ran
820
+ // (gate timeout / re-entrant E017). A stray commit/rollback must
821
+ // never reach the single-writer engine, where it could only end a
822
+ // DIFFERENT caller's open transaction. Empty success instead.
823
+ return { rows: [], rowCount: 0, fields: [] };
824
+ }
520
825
  try {
521
826
  return adaptResult(await client.query(powql, params.map(this.toParam)));
522
827
  }
523
828
  catch (err) {
524
829
  broken = true;
830
+ if (ctl === 'begin') {
831
+ hold?.finish();
832
+ hold = null;
833
+ }
525
834
  throw wrapPowdbError(err);
526
835
  }
836
+ finally {
837
+ if (ctl === 'commit' || ctl === 'rollback') {
838
+ hold?.finish();
839
+ hold = null;
840
+ }
841
+ }
527
842
  },
528
843
  release: () => {
529
- // Releasing this connection ends its transaction scope. Clear the flag
530
- // as a safety net so a tx torn down without an explicit commit/rollback
531
- // (e.g. a timeout that destroys the connection) never leaves the pool
532
- // permanently believing a transaction is still open.
533
- this.activeTransaction = false;
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();
851
+ hold = null;
534
852
  return broken ? this.pool.destroy(client) : this.pool.release(client);
535
853
  },
536
854
  };
@@ -644,57 +962,89 @@ class PowdbEmbeddedPool {
644
962
  db;
645
963
  closed = false;
646
964
  /**
647
- * Single-writer guard. The embedded engine is one handle with one global
965
+ * Single-writer gate. The embedded engine is one handle with one global
648
966
  * write lock — only one transaction may be open at a time. A re-entrant
649
- * `begin` (a fresh top-level `db.$transaction` opened inside an open one)
650
- * would otherwise hit PowDB's raw "already in a transaction" parse error;
651
- * this surfaces a typed error instead. (Nested `tx.$transaction` is caught
652
- * earlier still, by the savepoint override in {@link powdbDialect}.)
967
+ * `begin` (a fresh top-level `db.$transaction` opened inside an open one's
968
+ * callback) would otherwise hit PowDB's raw "already in a transaction"
969
+ * parse error; the gate surfaces a typed E017 instead, while INDEPENDENT
970
+ * concurrent transactions queue FIFO and run one at a time. (Nested
971
+ * `tx.$transaction` is caught earlier still, by the savepoint override in
972
+ * {@link powdbDialect}.)
653
973
  */
654
- activeTransaction = false;
655
- constructor(db) {
974
+ txGate;
975
+ /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
976
+ poolHoldRef = { hold: null };
977
+ constructor(db, options = {}) {
656
978
  this.db = db;
979
+ this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
980
+ }
981
+ /** Materialize `$N` params and hand the PowQL to the in-process engine. */
982
+ exec(powql, params) {
983
+ const materialized = materializePowql(powql, params);
984
+ return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
657
985
  }
658
- /** Enforce the single-writer model on a transaction-control statement. */
659
- guardTxControl(powql) {
986
+ /**
987
+ * Run one statement, gating transaction control. `holdRef` scopes the gate
988
+ * hold to whoever issued the `begin` (the pool itself or one checked-out
989
+ * client), so finishing a transaction can never release a slot a different
990
+ * transaction is holding.
991
+ */
992
+ async run(powql, params, holdRef) {
660
993
  const ctl = txControl(powql);
661
994
  if (ctl === 'begin') {
662
- if (this.activeTransaction)
663
- throw reentrantTransactionError();
664
- this.activeTransaction = true;
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.)
998
+ holdRef.hold = await this.txGate.acquire();
665
999
  }
666
- else if (ctl === 'commit' || ctl === 'rollback') {
667
- this.activeTransaction = false;
1000
+ if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
1001
+ // This context never acquired the gate — its `begin` never ran (the
1002
+ // gate timed out / threw re-entrant E017, or no begin was issued at
1003
+ // all). The engine is ONE shared handle: forwarding this stray
1004
+ // commit/rollback would hit whatever transaction ANOTHER caller has
1005
+ // open on it (live-reproduced: a best-effort ROLLBACK after a failed
1006
+ // begin silently discarded a concurrent transaction's writes). Swallow
1007
+ // it as an empty success instead — there is nothing of ours to end.
1008
+ return { rows: [], rowCount: 0, fields: [] };
668
1009
  }
669
- }
670
- run(powql, params) {
671
- this.guardTxControl(powql);
672
1010
  try {
673
- const materialized = materializePowql(powql, params);
674
- return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
1011
+ return this.exec(powql, params);
675
1012
  }
676
1013
  catch (err) {
1014
+ if (ctl === 'begin') {
1015
+ holdRef.hold?.finish();
1016
+ holdRef.hold = null;
1017
+ }
677
1018
  throw wrapPowdbError(err);
678
1019
  }
1020
+ finally {
1021
+ if (ctl === 'commit' || ctl === 'rollback') {
1022
+ holdRef.hold?.finish();
1023
+ holdRef.hold = null;
1024
+ }
1025
+ }
679
1026
  }
680
1027
  // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
681
1028
  async query(text, values) {
682
1029
  const { text: powql, params } = normalizeQueryArgs(text, values);
683
- return this.run(powql, params);
1030
+ return this.run(powql, params, this.poolHoldRef);
684
1031
  }
685
1032
  async connect() {
686
1033
  // Single in-process handle — the "client" shares the one Database; tx
687
- // keywords run serially on it.
1034
+ // keywords run serially on it. Each checked-out client scopes its own
1035
+ // gate hold so release() only ever finishes ITS transaction.
1036
+ const holdRef = { hold: null };
688
1037
  return {
689
1038
  // biome-ignore lint/suspicious/noExplicitAny: see query() above.
690
1039
  query: async (text, values) => {
691
1040
  const { text: powql, params } = normalizeQueryArgs(text, values);
692
- return this.run(powql, params);
1041
+ return this.run(powql, params, holdRef);
693
1042
  },
694
1043
  release: () => {
695
1044
  // End-of-scope safety net (see PowdbPool.connect()): a tx torn down
696
- // without an explicit commit/rollback must not wedge the handle.
697
- this.activeTransaction = false;
1045
+ // without an explicit commit/rollback must not wedge the queue.
1046
+ holdRef.hold?.finish();
1047
+ holdRef.hold = null;
698
1048
  },
699
1049
  };
700
1050
  }
@@ -719,10 +1069,14 @@ Object.defineProperty(exports, "PowqlInterface", { enumerable: true, get: functi
719
1069
  */
720
1070
  async function loadPowdb() {
721
1071
  try {
722
- return (await Promise.resolve().then(() => __importStar(require('@zvndev/powdb-client'))));
1072
+ // Via the .cts helper so the CJS build keeps a path to a REAL dynamic
1073
+ // import() — @zvndev/powdb-client ≥ 0.9 is ESM-only, and the CommonJS
1074
+ // pass transpiles a plain `import()` here into an unusable `require()`.
1075
+ return (await (0, optional_peer_import_cjs_1.default)('@zvndev/powdb-client'));
723
1076
  }
724
1077
  catch (err) {
725
- 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. " +
1078
+ 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 " +
1079
+ 'or construct the PowDB pool yourself and inject it: turbinePowDB(pool, schema). ' +
726
1080
  `(${err.message})`);
727
1081
  }
728
1082
  }
@@ -736,13 +1090,16 @@ async function loadPowdb() {
736
1090
  async function loadPowdbEmbedded() {
737
1091
  let mod;
738
1092
  try {
739
- mod = (await Promise.resolve().then(() => __importStar(require('@zvndev/powdb-embedded'))));
1093
+ // Via the .cts helper — keeps a real dynamic import() available to the
1094
+ // CJS build in case a future addon version ships ESM-only (see loadPowdb).
1095
+ mod = (await (0, optional_peer_import_cjs_1.default)('@zvndev/powdb-embedded'));
740
1096
  }
741
1097
  catch (err) {
742
1098
  throw new errors_js_1.ConnectionError("[turbine] turbine-orm/powdb embedded mode requires the optional peer '@zvndev/powdb-embedded'. " +
743
1099
  'Install it: npm i @zvndev/powdb-embedded. If install succeeded but loading failed, your platform has no ' +
744
1100
  'prebuilt binary (prebuilts ship for macOS arm64/x64 and Linux glibc x64/arm64; Intel-mac/musl/Windows ' +
745
- 'build from source) — build it with `npm run build` in the addon, then retry. ' +
1101
+ 'build from source) — build it with `npm run build` in the addon, then retry. You can also construct the ' +
1102
+ 'pool yourself and inject it: turbinePowDB(pool, schema). ' +
746
1103
  `(${err.message})`);
747
1104
  }
748
1105
  if (!mod || typeof mod.Database?.open !== 'function') {
@@ -752,7 +1109,7 @@ async function loadPowdbEmbedded() {
752
1109
  return mod;
753
1110
  }
754
1111
  /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
755
- async function openEmbeddedPool(target) {
1112
+ async function openEmbeddedPool(target, poolOptions = {}) {
756
1113
  const mod = await loadPowdbEmbedded();
757
1114
  const { embedded: dir, syncMode, memoryLimit } = target;
758
1115
  let db;
@@ -778,7 +1135,7 @@ async function openEmbeddedPool(target) {
778
1135
  }
779
1136
  db.setSyncMode(syncMode);
780
1137
  }
781
- return new PowdbEmbeddedPool(db);
1138
+ return new PowdbEmbeddedPool(db, poolOptions);
782
1139
  }
783
1140
  /**
784
1141
  * Bind Turbine to PowDB. `target` is one of:
@@ -801,28 +1158,30 @@ async function openEmbeddedPool(target) {
801
1158
  async function turbinePowDB(target, schema, options = {}) {
802
1159
  let pool;
803
1160
  let owns = false;
1161
+ const poolOptions = { transactionQueueTimeoutMs: options.transactionQueueTimeoutMs };
804
1162
  if (typeof target === 'string') {
805
1163
  const mod = await loadPowdb();
806
1164
  const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max: options.connectionLimit ?? 10 });
807
1165
  await assertNetworkedVersion(clientPool);
808
- pool = new PowdbPool(clientPool);
1166
+ pool = new PowdbPool(clientPool, undefined, poolOptions);
809
1167
  owns = true;
810
1168
  }
811
1169
  else if (target instanceof PowdbPool) {
1170
+ // An injected PowdbPool carries its own PowdbPoolOptions.
812
1171
  pool = target;
813
1172
  }
814
1173
  else if (isEmbeddedTarget(target)) {
815
- pool = await openEmbeddedPool(target);
1174
+ pool = await openEmbeddedPool(target, poolOptions);
816
1175
  owns = true;
817
1176
  }
818
1177
  else if (isPowdbClientPool(target)) {
819
- pool = new PowdbPool(target);
1178
+ pool = new PowdbPool(target, undefined, poolOptions);
820
1179
  }
821
1180
  else {
822
1181
  const mod = await loadPowdb();
823
1182
  const clientPool = new mod.Pool({ ...target, max: options.connectionLimit ?? 10 });
824
1183
  await assertNetworkedVersion(clientPool);
825
- pool = new PowdbPool(clientPool);
1184
+ pool = new PowdbPool(clientPool, undefined, poolOptions);
826
1185
  owns = true;
827
1186
  }
828
1187
  // The PowQL generator is loaded here to keep client.ts free of any PowDB import.