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/powdb.js CHANGED
@@ -22,8 +22,19 @@
22
22
  * `string` PKs hold UUID strings.
23
23
  * - **No JSON aggregation / link navigation** — single-query nested `with` is
24
24
  * impossible → it degrades to batched N+1 loaders (Phase B).
25
- * - **Single global write lock; no savepoints/isolation/pipelining** — nested
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.
32
+ * - **The wire protocol pipelines** — `@zvndev/powdb-client` writes each
33
+ * request frame immediately and matches replies FIFO, so multiple queries
34
+ * may be in flight on one connection. {@link PowdbPool}'s checked-out
35
+ * clients advertise `supportsPipelining`, which lets the batch
36
+ * `$transaction([...])` overload dispatch all statements in one write
37
+ * burst (~1 round trip) instead of one round trip per statement.
27
38
  *
28
39
  * `@zvndev/powdb-client` is an **optional peer dependency** loaded by dynamic
29
40
  * import; `npm i turbine-orm` still pulls only `pg`.
@@ -41,9 +52,11 @@
41
52
  *
42
53
  * @module
43
54
  */
55
+ import { AsyncLocalStorage } from 'node:async_hooks';
44
56
  import { TurbineClient, } from './client.js';
45
57
  import { postgresDialect } from './dialect.js';
46
58
  import { ConnectionError, NotNullViolationError, TimeoutError, UniqueConstraintError, UnsupportedFeatureError, ValidationError, } from './errors.js';
59
+ import importOptionalPeer from './optional-peer-import.cjs';
47
60
  /**
48
61
  * Capability descriptor for PowDB. PowQL generation is owned by
49
62
  * {@link PowqlInterface} (not the SQL `Dialect`), so this dialect exists only to
@@ -59,9 +72,11 @@ import { ConnectionError, NotNullViolationError, TimeoutError, UniqueConstraintE
59
72
  * {@link UnsupportedFeatureError} (E017): a nested `tx.$transaction` emits a
60
73
  * savepoint synchronously (before any DB call) and so fails fast with a
61
74
  * clear typed error instead of leaking PowDB's cryptic `Parse(... 'sp_1')`.
62
- * The pool-level begin-while-active guard (see {@link PowdbPool}) catches the
63
- * other re-entrant shape — a fresh top-level `db.$transaction` opened inside
64
- * 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.
65
80
  * Isolation levels remain Phase B.
66
81
  */
67
82
  export const powdbDialect = {
@@ -206,6 +221,125 @@ function isDateColumn(col) {
206
221
  * `auto` modifier, so PowDB assigns a monotonic id on insert and Turbine stops
207
222
  * synthesizing a client-side value for it.
208
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
+ }
209
343
  export function powqlSchemaDDL(schema) {
210
344
  const stmts = [];
211
345
  for (const meta of Object.values(schema.tables)) {
@@ -227,13 +361,13 @@ export function powqlSchemaDDL(schema) {
227
361
  // a plain typed column (Turbine assigns the value client-side instead).
228
362
  if (col.isGenerated && powqlColumnType(col) === 'int')
229
363
  mods.push('auto');
230
- return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${col.name}: ${powqlColumnType(col)}`;
364
+ return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlColumnType(col)}`;
231
365
  });
232
- stmts.push(`type ${meta.name} {\n${fields.join(',\n')}\n}`);
366
+ stmts.push(`type ${quotePowqlIdent(meta.name)} {\n${fields.join(',\n')}\n}`);
233
367
  // Secondary unique constraints (beyond the PK) become unique indexes.
234
368
  for (const uniq of meta.uniqueColumns) {
235
369
  if (uniq.length === 1 && !pkSet.has(uniq[0])) {
236
- stmts.push(`alter ${meta.name} add unique .${uniq[0]}`);
370
+ stmts.push(`alter ${quotePowqlIdent(meta.name)} add unique .${quotePowqlIdent(uniq[0])}`);
237
371
  }
238
372
  }
239
373
  }
@@ -333,6 +467,12 @@ export function wrapPowdbError(err) {
333
467
  const m = /column ['"]?(\w+)['"]?/i.exec(msg);
334
468
  return new NotNullViolationError({ column: m?.[1], cause: err });
335
469
  }
470
+ // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
471
+ // connection held the single global write lock past the server's
472
+ // --tx-wait-timeout-ms. Retryable timeout, not a query defect.
473
+ if (/transaction gate timeout/i.test(msg)) {
474
+ return new TimeoutError(0, 'PowDB transaction gate');
475
+ }
336
476
  // Type mismatch / parse / execution / storage / unexpected → validation
337
477
  // (E003). On the embedded transport these are the only signal we get
338
478
  // (code is always 'GenericFailure'); on the networked path they are a
@@ -378,15 +518,142 @@ function txControl(powql) {
378
518
  return null;
379
519
  }
380
520
  /**
381
- * The error a pool throws when a `begin` arrives while a transaction is already
382
- * open. PowDB has ONE global write lock and supports neither concurrent nor
383
- * nested transactions: on the networked transport a second `begin` checks out a
384
- * fresh pooled connection and blocks forever on the lock the open transaction
385
- * holds. This guard converts that hang into a fast, typed error.
521
+ * The error a pool throws when a `begin` arrives from INSIDE an already-open
522
+ * transaction's async context (a re-entrant `db.$transaction`). PowDB has ONE
523
+ * global write lock and no savepoints: queueing a re-entrant transaction would
524
+ * deadlock (the outer callback awaits the inner transaction, which waits on
525
+ * the write lock the outer transaction holds), and on the networked transport
526
+ * it would block a fresh pooled connection on the lock forever. This guard
527
+ * converts that hang into a fast, typed error. Independent concurrent
528
+ * transactions do NOT hit this — they queue FIFO on {@link PowdbTxGate}.
386
529
  */
387
530
  function reentrantTransactionError() {
388
- 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; ' +
389
- 'complete the open transaction first.');
531
+ return new UnsupportedFeatureError('re-entrant transactions', 'powdb', 'PowDB is single-writer — a transaction opened from inside an active transaction callback would deadlock ' +
532
+ 'on the write lock the open transaction holds. Use the `tx` client the callback receives, or start the ' +
533
+ 'second transaction after the first completes. (Independent concurrent transactions queue automatically.)');
534
+ }
535
+ // ---------------------------------------------------------------------------
536
+ // Single-writer transaction gate — FIFO queueing + re-entrancy detection
537
+ // ---------------------------------------------------------------------------
538
+ /**
539
+ * Default cap (ms) on how long a `begin` may wait in the FIFO queue for
540
+ * PowDB's single global write lock before failing with a typed
541
+ * {@link TimeoutError} (E002). Prevents silent starvation behind a wedged
542
+ * transaction. Override via `transactionQueueTimeoutMs`
543
+ * ({@link TurbinePowdbOptions} / {@link PowdbPoolOptions}); `0` or `Infinity`
544
+ * waits without limit.
545
+ */
546
+ export const DEFAULT_TX_QUEUE_TIMEOUT_MS = 30_000;
547
+ const powdbTxStorage = new AsyncLocalStorage();
548
+ /**
549
+ * FIFO gate serializing transactions across a whole pool. PowDB holds one
550
+ * global write lock, so at most one transaction may be open per database:
551
+ * without this gate a second `begin` on the networked transport checks out a
552
+ * fresh connection and blocks forever on the lock the open transaction holds
553
+ * (and the embedded engine rejects it with a raw parse error).
554
+ *
555
+ * `acquire()` is called (synchronously, see below) for every `begin`:
556
+ * - a **re-entrant** `begin` — issued from inside an active transaction's
557
+ * async context, detected via {@link powdbTxStorage} — throws E017
558
+ * immediately. Queueing it can never succeed: the open transaction cannot
559
+ * commit while its callback awaits the queued one.
560
+ * - an **independent** `begin` waits its FIFO turn, bounded by the queue
561
+ * timeout, then returns a {@link PowdbTxHold} the caller finishes on
562
+ * commit / rollback / connection release.
563
+ *
564
+ * Context propagation relies on `AsyncLocalStorage.enterWith()` running in the
565
+ * caller's *synchronous* execution: the pools call `acquire()` from the
566
+ * synchronous prologue of `query('begin')`, so when `TurbineClient.$transaction`
567
+ * awaits that begin, its continuation — and therefore the user callback — runs
568
+ * with the marker set, while sibling contexts (concurrent transactions, the
569
+ * caller after `$transaction` resolves) captured their snapshots earlier and
570
+ * never see it. Markers form a chain ({@link PowdbTxContext.parent}) so
571
+ * transactions nested across DIFFERENT pools cannot shadow an outer marker on
572
+ * this gate — the re-entrancy check walks every live ancestor.
573
+ *
574
+ * **Residual limitation:** a re-entrant begin issued from an async context
575
+ * created BEFORE the transaction opened (e.g. a job-queue worker loop whose
576
+ * continuations captured their ALS snapshot up front) carries no marker, so it
577
+ * cannot be told apart from a legitimate independent concurrent transaction.
578
+ * It queues FIFO and — because the open transaction is awaiting it — times out
579
+ * after `transactionQueueTimeoutMs` with a typed {@link TimeoutError} rather
580
+ * than throwing E017 instantly. The 30s default is the backstop for exactly
581
+ * this case: do not set `transactionQueueTimeoutMs: 0` (wait forever) in code
582
+ * paths that may start transactions from pre-existing async contexts.
583
+ */
584
+ class PowdbTxGate {
585
+ queueTimeoutMs;
586
+ /** Tail of the FIFO queue — resolves once every earlier transaction has finished. */
587
+ tail = Promise.resolve();
588
+ constructor(queueTimeoutMs) {
589
+ this.queueTimeoutMs = queueTimeoutMs;
590
+ }
591
+ /**
592
+ * Take a place in the transaction queue. MUST be invoked in the same
593
+ * synchronous execution as the caller's `begin` (no `await` before it) so
594
+ * the re-entrancy marker propagates into the transaction's async scope.
595
+ */
596
+ async acquire() {
597
+ // --- synchronous section (runs before the caller's first await) ---
598
+ // Walk the WHOLE marker chain, not just the innermost marker: with two
599
+ // pools, dbA-tx → dbB-tx → dbA-begin leaves dbB's marker innermost, but
600
+ // the dbA ancestor is still open — queueing the inner dbA begin behind it
601
+ // would deadlock. Any live ancestor on this gate ⇒ re-entrant E017.
602
+ // Prune completed heads first (`done` never flips back) so sequential
603
+ // transactions issued from one long-lived context do not chain — and leak
604
+ // — unboundedly; what remains is bounded by real nesting depth.
605
+ let parent = powdbTxStorage.getStore();
606
+ while (parent?.done)
607
+ parent = parent.parent;
608
+ for (let c = parent; c !== undefined; c = c.parent) {
609
+ if (c.gate === this && !c.done) {
610
+ throw reentrantTransactionError();
611
+ }
612
+ }
613
+ const ctx = { gate: this, done: false, parent };
614
+ powdbTxStorage.enterWith(ctx);
615
+ let handOff;
616
+ const finished = new Promise((resolve) => {
617
+ handOff = resolve;
618
+ });
619
+ const ahead = this.tail;
620
+ this.tail = ahead.then(() => finished);
621
+ const hold = {
622
+ finish: () => {
623
+ if (ctx.done)
624
+ return;
625
+ ctx.done = true;
626
+ handOff();
627
+ },
628
+ };
629
+ // --- FIFO wait (optionally bounded) ---
630
+ const timeoutMs = this.queueTimeoutMs;
631
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
632
+ await ahead;
633
+ return hold;
634
+ }
635
+ await new Promise((resolve, reject) => {
636
+ let settled = false;
637
+ const timer = setTimeout(() => {
638
+ if (settled)
639
+ return;
640
+ settled = true;
641
+ // Give up the queue slot: finishing resolves our `finished` link, so
642
+ // once every transaction ahead completes, later waiters skip straight
643
+ // past us instead of stalling behind a slot nobody will release.
644
+ hold.finish();
645
+ reject(new TimeoutError(timeoutMs, 'PowDB transaction (queued behind the single-writer lock)'));
646
+ }, timeoutMs);
647
+ void ahead.then(() => {
648
+ if (settled)
649
+ return;
650
+ settled = true;
651
+ clearTimeout(timer);
652
+ resolve();
653
+ });
654
+ });
655
+ return hold;
656
+ }
390
657
  }
391
658
  /** Adapt a PowDB result into the pg-compat `{ rows, rowCount, fields }` shape. */
392
659
  function adaptResult(r) {
@@ -420,70 +687,117 @@ export class PowdbPool {
420
687
  toParam;
421
688
  closed = false;
422
689
  /**
423
- * Pool-level single-writer guard. PowDB holds one global write lock, so at
424
- * most one transaction may be open across the whole pool. A `begin` issued
425
- * while this is `true` is rejected (it would otherwise check out a second
426
- * connection and block on the lock forever — the networked re-entrant hang).
690
+ * Pool-level single-writer gate. PowDB holds one global write lock, so at
691
+ * most one transaction may be open across the whole pool. Concurrent
692
+ * `begin`s queue FIFO on the gate (instead of checking out a second
693
+ * connection and blocking on the lock forever — the networked hang);
694
+ * re-entrant `begin`s throw E017 (see {@link PowdbTxGate}).
427
695
  */
428
- activeTransaction = false;
429
- constructor(pool, toParam = (v) => toPowdbParam(v)) {
696
+ txGate;
697
+ /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
698
+ poolHold = null;
699
+ constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
430
700
  this.pool = pool;
431
701
  this.toParam = toParam;
702
+ this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
432
703
  }
433
- /**
434
- * Enforce the single-writer model on a transaction-control statement. Throws
435
- * (before any query runs) if a `begin` arrives while a transaction is open;
436
- * otherwise flips the pool-level flag. Returns the control kind so the caller
437
- * can decide whether it even needs to hit the engine.
438
- */
439
- guardTxControl(powql) {
704
+ // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
705
+ async query(text, values) {
706
+ const { text: powql, params } = normalizeQueryArgs(text, values);
440
707
  const ctl = txControl(powql);
441
708
  if (ctl === 'begin') {
442
- if (this.activeTransaction)
443
- throw reentrantTransactionError();
444
- this.activeTransaction = true;
709
+ // Gate BEFORE touching the engine: a re-entrant begin throws fast, an
710
+ // independent concurrent one waits its FIFO turn. (acquire()'s
711
+ // re-entrancy check + context mark run synchronously right here.)
712
+ this.poolHold = await this.txGate.acquire();
445
713
  }
446
- else if (ctl === 'commit' || ctl === 'rollback') {
447
- this.activeTransaction = false;
714
+ if ((ctl === 'commit' || ctl === 'rollback') && this.poolHold === null) {
715
+ // No gate hold → our `begin` never ran (gate timeout / re-entrant
716
+ // E017 / no begin at all). Never forward a stray commit/rollback to
717
+ // the engine — PowDB is single-writer, so it could only ever end a
718
+ // DIFFERENT caller's open transaction. Empty success instead.
719
+ return { rows: [], rowCount: 0, fields: [] };
448
720
  }
449
- return ctl;
450
- }
451
- // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
452
- async query(text, values) {
453
- const { text: powql, params } = normalizeQueryArgs(text, values);
454
- this.guardTxControl(powql);
455
721
  try {
456
722
  const result = await this.pool.withClient((c) => c.query(powql, params.map(this.toParam)));
457
723
  return adaptResult(result);
458
724
  }
459
725
  catch (err) {
726
+ if (ctl === 'begin') {
727
+ this.poolHold?.finish();
728
+ this.poolHold = null;
729
+ }
460
730
  throw wrapPowdbError(err);
461
731
  }
732
+ finally {
733
+ if (ctl === 'commit' || ctl === 'rollback') {
734
+ this.poolHold?.finish();
735
+ this.poolHold = null;
736
+ }
737
+ }
462
738
  }
463
739
  async connect() {
464
740
  const client = await this.pool.acquire();
465
741
  let broken = false;
742
+ /** The gate hold of the transaction begun through THIS connection (if any). */
743
+ let hold = null;
466
744
  return {
745
+ // The networked client's query() supports concurrent in-flight calls on
746
+ // one connection: every request frame is written to the socket
747
+ // immediately and replies are matched to callers in FIFO order. That
748
+ // lets the batch `$transaction([...])` path dispatch all statements in
749
+ // one write burst instead of paying a round trip per statement. Safe
750
+ // for the batch's rollback contract because a failed statement leaves
751
+ // the engine's transaction open (no aborted state, no auto-rollback) —
752
+ // later pipelined statements execute inside the same still-open
753
+ // transaction and the final `rollback` discards every effect. (The
754
+ // batch path awaits `begin` before dispatching the burst, so the gate
755
+ // wait below never reorders statements around it.)
756
+ supportsPipelining: true,
467
757
  // biome-ignore lint/suspicious/noExplicitAny: see query() above.
468
758
  query: async (text, values) => {
469
759
  const { text: powql, params } = normalizeQueryArgs(text, values);
470
- // Guard BEFORE acquiring the engine — a re-entrant begin throws fast
471
- // instead of blocking on the global write lock the open tx holds.
472
- this.guardTxControl(powql);
760
+ const ctl = txControl(powql);
761
+ if (ctl === 'begin') {
762
+ // Gate BEFORE hitting the engine — a re-entrant begin throws fast
763
+ // instead of blocking on the global write lock the open tx holds;
764
+ // an independent concurrent begin queues FIFO.
765
+ hold = await this.txGate.acquire();
766
+ }
767
+ if ((ctl === 'commit' || ctl === 'rollback') && hold === null) {
768
+ // This connection never acquired the gate — its `begin` never ran
769
+ // (gate timeout / re-entrant E017). A stray commit/rollback must
770
+ // never reach the single-writer engine, where it could only end a
771
+ // DIFFERENT caller's open transaction. Empty success instead.
772
+ return { rows: [], rowCount: 0, fields: [] };
773
+ }
473
774
  try {
474
775
  return adaptResult(await client.query(powql, params.map(this.toParam)));
475
776
  }
476
777
  catch (err) {
477
778
  broken = true;
779
+ if (ctl === 'begin') {
780
+ hold?.finish();
781
+ hold = null;
782
+ }
478
783
  throw wrapPowdbError(err);
479
784
  }
785
+ finally {
786
+ if (ctl === 'commit' || ctl === 'rollback') {
787
+ hold?.finish();
788
+ hold = null;
789
+ }
790
+ }
480
791
  },
481
792
  release: () => {
482
- // Releasing this connection ends its transaction scope. Clear the flag
483
- // as a safety net so a tx torn down without an explicit commit/rollback
484
- // (e.g. a timeout that destroys the connection) never leaves the pool
485
- // permanently believing a transaction is still open.
486
- this.activeTransaction = false;
793
+ // Releasing this connection ends its transaction scope. Finish the
794
+ // hold as a safety net so a tx torn down without an explicit
795
+ // commit/rollback (e.g. a timeout that destroys the connection) hands
796
+ // the gate to the next queued transaction instead of wedging the
797
+ // queue. Only THIS connection's hold — releasing an unrelated (read)
798
+ // connection never touches another transaction's slot.
799
+ hold?.finish();
800
+ hold = null;
487
801
  return broken ? this.pool.destroy(client) : this.pool.release(client);
488
802
  },
489
803
  };
@@ -596,57 +910,89 @@ export class PowdbEmbeddedPool {
596
910
  db;
597
911
  closed = false;
598
912
  /**
599
- * Single-writer guard. The embedded engine is one handle with one global
913
+ * Single-writer gate. The embedded engine is one handle with one global
600
914
  * write lock — only one transaction may be open at a time. A re-entrant
601
- * `begin` (a fresh top-level `db.$transaction` opened inside an open one)
602
- * would otherwise hit PowDB's raw "already in a transaction" parse error;
603
- * this surfaces a typed error instead. (Nested `tx.$transaction` is caught
604
- * earlier still, by the savepoint override in {@link powdbDialect}.)
915
+ * `begin` (a fresh top-level `db.$transaction` opened inside an open one's
916
+ * callback) would otherwise hit PowDB's raw "already in a transaction"
917
+ * parse error; the gate surfaces a typed E017 instead, while INDEPENDENT
918
+ * concurrent transactions queue FIFO and run one at a time. (Nested
919
+ * `tx.$transaction` is caught earlier still, by the savepoint override in
920
+ * {@link powdbDialect}.)
605
921
  */
606
- activeTransaction = false;
607
- constructor(db) {
922
+ txGate;
923
+ /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
924
+ poolHoldRef = { hold: null };
925
+ constructor(db, options = {}) {
608
926
  this.db = db;
927
+ this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
928
+ }
929
+ /** Materialize `$N` params and hand the PowQL to the in-process engine. */
930
+ exec(powql, params) {
931
+ const materialized = materializePowql(powql, params);
932
+ return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
609
933
  }
610
- /** Enforce the single-writer model on a transaction-control statement. */
611
- guardTxControl(powql) {
934
+ /**
935
+ * Run one statement, gating transaction control. `holdRef` scopes the gate
936
+ * hold to whoever issued the `begin` (the pool itself or one checked-out
937
+ * client), so finishing a transaction can never release a slot a different
938
+ * transaction is holding.
939
+ */
940
+ async run(powql, params, holdRef) {
612
941
  const ctl = txControl(powql);
613
942
  if (ctl === 'begin') {
614
- if (this.activeTransaction)
615
- throw reentrantTransactionError();
616
- this.activeTransaction = true;
943
+ // Gate BEFORE hitting the engine — re-entrant begins throw fast,
944
+ // independent concurrent ones wait their FIFO turn. (acquire()'s
945
+ // re-entrancy check + context mark run synchronously right here.)
946
+ holdRef.hold = await this.txGate.acquire();
617
947
  }
618
- else if (ctl === 'commit' || ctl === 'rollback') {
619
- this.activeTransaction = false;
948
+ if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
949
+ // This context never acquired the gate — its `begin` never ran (the
950
+ // gate timed out / threw re-entrant E017, or no begin was issued at
951
+ // all). The engine is ONE shared handle: forwarding this stray
952
+ // commit/rollback would hit whatever transaction ANOTHER caller has
953
+ // open on it (live-reproduced: a best-effort ROLLBACK after a failed
954
+ // begin silently discarded a concurrent transaction's writes). Swallow
955
+ // it as an empty success instead — there is nothing of ours to end.
956
+ return { rows: [], rowCount: 0, fields: [] };
620
957
  }
621
- }
622
- run(powql, params) {
623
- this.guardTxControl(powql);
624
958
  try {
625
- const materialized = materializePowql(powql, params);
626
- return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
959
+ return this.exec(powql, params);
627
960
  }
628
961
  catch (err) {
962
+ if (ctl === 'begin') {
963
+ holdRef.hold?.finish();
964
+ holdRef.hold = null;
965
+ }
629
966
  throw wrapPowdbError(err);
630
967
  }
968
+ finally {
969
+ if (ctl === 'commit' || ctl === 'rollback') {
970
+ holdRef.hold?.finish();
971
+ holdRef.hold = null;
972
+ }
973
+ }
631
974
  }
632
975
  // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
633
976
  async query(text, values) {
634
977
  const { text: powql, params } = normalizeQueryArgs(text, values);
635
- return this.run(powql, params);
978
+ return this.run(powql, params, this.poolHoldRef);
636
979
  }
637
980
  async connect() {
638
981
  // Single in-process handle — the "client" shares the one Database; tx
639
- // keywords run serially on it.
982
+ // keywords run serially on it. Each checked-out client scopes its own
983
+ // gate hold so release() only ever finishes ITS transaction.
984
+ const holdRef = { hold: null };
640
985
  return {
641
986
  // biome-ignore lint/suspicious/noExplicitAny: see query() above.
642
987
  query: async (text, values) => {
643
988
  const { text: powql, params } = normalizeQueryArgs(text, values);
644
- return this.run(powql, params);
989
+ return this.run(powql, params, holdRef);
645
990
  },
646
991
  release: () => {
647
992
  // End-of-scope safety net (see PowdbPool.connect()): a tx torn down
648
- // without an explicit commit/rollback must not wedge the handle.
649
- this.activeTransaction = false;
993
+ // without an explicit commit/rollback must not wedge the queue.
994
+ holdRef.hold?.finish();
995
+ holdRef.hold = null;
650
996
  },
651
997
  };
652
998
  }
@@ -669,10 +1015,14 @@ export { PowqlInterface } from './powql.js';
669
1015
  */
670
1016
  async function loadPowdb() {
671
1017
  try {
672
- return (await import('@zvndev/powdb-client'));
1018
+ // Via the .cts helper so the CJS build keeps a path to a REAL dynamic
1019
+ // import() — @zvndev/powdb-client ≥ 0.9 is ESM-only, and the CommonJS
1020
+ // pass transpiles a plain `import()` here into an unusable `require()`.
1021
+ return (await importOptionalPeer('@zvndev/powdb-client'));
673
1022
  }
674
1023
  catch (err) {
675
- throw new ConnectionError("[turbine] turbine-orm/powdb requires the optional peer dependency '@zvndev/powdb-client'. Install it: npm i @zvndev/powdb-client. " +
1024
+ throw new ConnectionError("[turbine] turbine-orm/powdb requires the optional peer dependency '@zvndev/powdb-client'. Install it: npm i @zvndev/powdb-client " +
1025
+ 'or construct the PowDB pool yourself and inject it: turbinePowDB(pool, schema). ' +
676
1026
  `(${err.message})`);
677
1027
  }
678
1028
  }
@@ -686,13 +1036,16 @@ async function loadPowdb() {
686
1036
  async function loadPowdbEmbedded() {
687
1037
  let mod;
688
1038
  try {
689
- mod = (await import('@zvndev/powdb-embedded'));
1039
+ // Via the .cts helper — keeps a real dynamic import() available to the
1040
+ // CJS build in case a future addon version ships ESM-only (see loadPowdb).
1041
+ mod = (await importOptionalPeer('@zvndev/powdb-embedded'));
690
1042
  }
691
1043
  catch (err) {
692
1044
  throw new ConnectionError("[turbine] turbine-orm/powdb embedded mode requires the optional peer '@zvndev/powdb-embedded'. " +
693
1045
  'Install it: npm i @zvndev/powdb-embedded. If install succeeded but loading failed, your platform has no ' +
694
1046
  'prebuilt binary (prebuilts ship for macOS arm64/x64 and Linux glibc x64/arm64; Intel-mac/musl/Windows ' +
695
- 'build from source) — build it with `npm run build` in the addon, then retry. ' +
1047
+ 'build from source) — build it with `npm run build` in the addon, then retry. You can also construct the ' +
1048
+ 'pool yourself and inject it: turbinePowDB(pool, schema). ' +
696
1049
  `(${err.message})`);
697
1050
  }
698
1051
  if (!mod || typeof mod.Database?.open !== 'function') {
@@ -702,7 +1055,7 @@ async function loadPowdbEmbedded() {
702
1055
  return mod;
703
1056
  }
704
1057
  /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
705
- async function openEmbeddedPool(target) {
1058
+ async function openEmbeddedPool(target, poolOptions = {}) {
706
1059
  const mod = await loadPowdbEmbedded();
707
1060
  const { embedded: dir, syncMode, memoryLimit } = target;
708
1061
  let db;
@@ -728,7 +1081,7 @@ async function openEmbeddedPool(target) {
728
1081
  }
729
1082
  db.setSyncMode(syncMode);
730
1083
  }
731
- return new PowdbEmbeddedPool(db);
1084
+ return new PowdbEmbeddedPool(db, poolOptions);
732
1085
  }
733
1086
  /**
734
1087
  * Bind Turbine to PowDB. `target` is one of:
@@ -751,28 +1104,30 @@ async function openEmbeddedPool(target) {
751
1104
  export async function turbinePowDB(target, schema, options = {}) {
752
1105
  let pool;
753
1106
  let owns = false;
1107
+ const poolOptions = { transactionQueueTimeoutMs: options.transactionQueueTimeoutMs };
754
1108
  if (typeof target === 'string') {
755
1109
  const mod = await loadPowdb();
756
1110
  const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max: options.connectionLimit ?? 10 });
757
1111
  await assertNetworkedVersion(clientPool);
758
- pool = new PowdbPool(clientPool);
1112
+ pool = new PowdbPool(clientPool, undefined, poolOptions);
759
1113
  owns = true;
760
1114
  }
761
1115
  else if (target instanceof PowdbPool) {
1116
+ // An injected PowdbPool carries its own PowdbPoolOptions.
762
1117
  pool = target;
763
1118
  }
764
1119
  else if (isEmbeddedTarget(target)) {
765
- pool = await openEmbeddedPool(target);
1120
+ pool = await openEmbeddedPool(target, poolOptions);
766
1121
  owns = true;
767
1122
  }
768
1123
  else if (isPowdbClientPool(target)) {
769
- pool = new PowdbPool(target);
1124
+ pool = new PowdbPool(target, undefined, poolOptions);
770
1125
  }
771
1126
  else {
772
1127
  const mod = await loadPowdb();
773
1128
  const clientPool = new mod.Pool({ ...target, max: options.connectionLimit ?? 10 });
774
1129
  await assertNetworkedVersion(clientPool);
775
- pool = new PowdbPool(clientPool);
1130
+ pool = new PowdbPool(clientPool, undefined, poolOptions);
776
1131
  owns = true;
777
1132
  }
778
1133
  // The PowQL generator is loaded here to keep client.ts free of any PowDB import.