turbine-orm 0.30.0 → 0.32.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.
- package/dist/cjs/client.js +14 -3
- package/dist/cjs/mssql.js +24 -3
- package/dist/cjs/powdb.js +197 -50
- package/dist/cjs/powql.js +45 -2
- package/dist/cjs/query/batched-loader.js +34 -0
- package/dist/cjs/query/builder.js +758 -139
- package/dist/cjs/query/filters.js +77 -2
- package/dist/client.d.ts +13 -0
- package/dist/client.js +14 -3
- package/dist/index.d.ts +1 -1
- package/dist/mssql.js +24 -3
- package/dist/powdb.d.ts +33 -0
- package/dist/powdb.js +197 -50
- package/dist/powql.js +45 -2
- package/dist/query/batched-loader.d.ts +12 -0
- package/dist/query/batched-loader.js +33 -0
- package/dist/query/builder.d.ts +171 -5
- package/dist/query/builder.js +760 -141
- package/dist/query/filters.d.ts +40 -1
- package/dist/query/filters.js +73 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +213 -22
- package/package.json +1 -1
package/dist/powdb.js
CHANGED
|
@@ -467,6 +467,11 @@ export function wrapPowdbError(err) {
|
|
|
467
467
|
const m = /column ['"]?(\w+)['"]?/i.exec(msg);
|
|
468
468
|
return new NotNullViolationError({ column: m?.[1], cause: err });
|
|
469
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
|
+
}
|
|
470
475
|
// Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
|
|
471
476
|
// connection held the single global write lock past the server's
|
|
472
477
|
// --tx-wait-timeout-ms. Retryable timeout, not a query defect.
|
|
@@ -544,6 +549,13 @@ function reentrantTransactionError() {
|
|
|
544
549
|
* waits without limit.
|
|
545
550
|
*/
|
|
546
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;
|
|
547
559
|
const powdbTxStorage = new AsyncLocalStorage();
|
|
548
560
|
/**
|
|
549
561
|
* FIFO gate serializing transactions across a whole pool. PowDB holds one
|
|
@@ -561,25 +573,35 @@ const powdbTxStorage = new AsyncLocalStorage();
|
|
|
561
573
|
* timeout, then returns a {@link PowdbTxHold} the caller finishes on
|
|
562
574
|
* commit / rollback / connection release.
|
|
563
575
|
*
|
|
564
|
-
* Context propagation
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
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.
|
|
573
594
|
*
|
|
574
|
-
* **Residual limitation:** a
|
|
575
|
-
*
|
|
576
|
-
* continuations captured their
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
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.
|
|
583
605
|
*/
|
|
584
606
|
class PowdbTxGate {
|
|
585
607
|
queueTimeoutMs;
|
|
@@ -589,12 +611,14 @@ class PowdbTxGate {
|
|
|
589
611
|
this.queueTimeoutMs = queueTimeoutMs;
|
|
590
612
|
}
|
|
591
613
|
/**
|
|
592
|
-
* Take a place in the transaction queue.
|
|
593
|
-
*
|
|
594
|
-
*
|
|
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.
|
|
595
620
|
*/
|
|
596
621
|
async acquire() {
|
|
597
|
-
// --- synchronous section (runs before the caller's first await) ---
|
|
598
622
|
// Walk the WHOLE marker chain, not just the innermost marker: with two
|
|
599
623
|
// pools, dbA-tx → dbB-tx → dbA-begin leaves dbB's marker innermost, but
|
|
600
624
|
// the dbA ancestor is still open — queueing the inner dbA begin behind it
|
|
@@ -611,7 +635,6 @@ class PowdbTxGate {
|
|
|
611
635
|
}
|
|
612
636
|
}
|
|
613
637
|
const ctx = { gate: this, done: false, parent };
|
|
614
|
-
powdbTxStorage.enterWith(ctx);
|
|
615
638
|
let handOff;
|
|
616
639
|
const finished = new Promise((resolve) => {
|
|
617
640
|
handOff = resolve;
|
|
@@ -619,6 +642,7 @@ class PowdbTxGate {
|
|
|
619
642
|
const ahead = this.tail;
|
|
620
643
|
this.tail = ahead.then(() => finished);
|
|
621
644
|
const hold = {
|
|
645
|
+
ctx,
|
|
622
646
|
finish: () => {
|
|
623
647
|
if (ctx.done)
|
|
624
648
|
return;
|
|
@@ -696,6 +720,14 @@ export class PowdbPool {
|
|
|
696
720
|
txGate;
|
|
697
721
|
/** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
|
|
698
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.
|
|
729
|
+
*/
|
|
730
|
+
checkedOut = new Set();
|
|
699
731
|
constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
|
|
700
732
|
this.pool = pool;
|
|
701
733
|
this.toParam = toParam;
|
|
@@ -703,12 +735,13 @@ export class PowdbPool {
|
|
|
703
735
|
}
|
|
704
736
|
// biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
|
|
705
737
|
async query(text, values) {
|
|
738
|
+
this.assertOpen();
|
|
706
739
|
const { text: powql, params } = normalizeQueryArgs(text, values);
|
|
707
740
|
const ctl = txControl(powql);
|
|
708
741
|
if (ctl === 'begin') {
|
|
709
|
-
// Gate BEFORE touching the engine: a
|
|
710
|
-
//
|
|
711
|
-
//
|
|
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.
|
|
712
745
|
this.poolHold = await this.txGate.acquire();
|
|
713
746
|
}
|
|
714
747
|
if ((ctl === 'commit' || ctl === 'rollback') && this.poolHold === null) {
|
|
@@ -736,8 +769,26 @@ export class PowdbPool {
|
|
|
736
769
|
}
|
|
737
770
|
}
|
|
738
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
|
+
}
|
|
781
|
+
}
|
|
739
782
|
async connect() {
|
|
740
|
-
|
|
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);
|
|
741
792
|
let broken = false;
|
|
742
793
|
/** The gate hold of the transaction begun through THIS connection (if any). */
|
|
743
794
|
let hold = null;
|
|
@@ -759,9 +810,10 @@ export class PowdbPool {
|
|
|
759
810
|
const { text: powql, params } = normalizeQueryArgs(text, values);
|
|
760
811
|
const ctl = txControl(powql);
|
|
761
812
|
if (ctl === 'begin') {
|
|
762
|
-
// Gate BEFORE hitting the engine
|
|
763
|
-
//
|
|
764
|
-
//
|
|
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.
|
|
765
817
|
hold = await this.txGate.acquire();
|
|
766
818
|
}
|
|
767
819
|
if ((ctl === 'commit' || ctl === 'rollback') && hold === null) {
|
|
@@ -789,16 +841,60 @@ export class PowdbPool {
|
|
|
789
841
|
}
|
|
790
842
|
}
|
|
791
843
|
},
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
hold?.
|
|
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;
|
|
800
868
|
hold = null;
|
|
801
|
-
|
|
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();
|
|
802
898
|
},
|
|
803
899
|
};
|
|
804
900
|
}
|
|
@@ -806,7 +902,16 @@ export class PowdbPool {
|
|
|
806
902
|
if (this.closed)
|
|
807
903
|
return;
|
|
808
904
|
this.closed = true;
|
|
905
|
+
// close() rejects pending waiters and closes every IDLE client…
|
|
809
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();
|
|
810
915
|
}
|
|
811
916
|
}
|
|
812
917
|
/** Normalize the embedded addon's loosely-typed result into a {@link PowdbResult}. */
|
|
@@ -938,11 +1043,14 @@ export class PowdbEmbeddedPool {
|
|
|
938
1043
|
* transaction is holding.
|
|
939
1044
|
*/
|
|
940
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
|
+
}
|
|
941
1049
|
const ctl = txControl(powql);
|
|
942
1050
|
if (ctl === 'begin') {
|
|
943
|
-
// Gate BEFORE hitting the engine
|
|
944
|
-
//
|
|
945
|
-
//
|
|
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.
|
|
946
1054
|
holdRef.hold = await this.txGate.acquire();
|
|
947
1055
|
}
|
|
948
1056
|
if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
|
|
@@ -988,11 +1096,31 @@ export class PowdbEmbeddedPool {
|
|
|
988
1096
|
const { text: powql, params } = normalizeQueryArgs(text, values);
|
|
989
1097
|
return this.run(powql, params, holdRef);
|
|
990
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();
|
|
1104
|
+
},
|
|
991
1105
|
release: () => {
|
|
992
1106
|
// End-of-scope safety net (see PowdbPool.connect()): a tx torn down
|
|
993
|
-
// without an explicit commit/rollback must not wedge the queue
|
|
994
|
-
|
|
995
|
-
|
|
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
|
+
});
|
|
996
1124
|
},
|
|
997
1125
|
};
|
|
998
1126
|
}
|
|
@@ -1000,8 +1128,11 @@ export class PowdbEmbeddedPool {
|
|
|
1000
1128
|
if (this.closed)
|
|
1001
1129
|
return;
|
|
1002
1130
|
// The addon exposes no explicit close — drop the reference and let GC /
|
|
1003
|
-
// the engine's checkpoint flush.
|
|
1004
|
-
//
|
|
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.
|
|
1005
1136
|
this.closed = true;
|
|
1006
1137
|
}
|
|
1007
1138
|
}
|
|
@@ -1106,7 +1237,7 @@ export async function turbinePowDB(target, schema, options = {}) {
|
|
|
1106
1237
|
let owns = false;
|
|
1107
1238
|
const poolOptions = { transactionQueueTimeoutMs: options.transactionQueueTimeoutMs };
|
|
1108
1239
|
if (typeof target === 'string') {
|
|
1109
|
-
const mod = await loadPowdb();
|
|
1240
|
+
const mod = options.powdbClientModule ?? (await loadPowdb());
|
|
1110
1241
|
const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max: options.connectionLimit ?? 10 });
|
|
1111
1242
|
await assertNetworkedVersion(clientPool);
|
|
1112
1243
|
pool = new PowdbPool(clientPool, undefined, poolOptions);
|
|
@@ -1124,7 +1255,7 @@ export async function turbinePowDB(target, schema, options = {}) {
|
|
|
1124
1255
|
pool = new PowdbPool(target, undefined, poolOptions);
|
|
1125
1256
|
}
|
|
1126
1257
|
else {
|
|
1127
|
-
const mod = await loadPowdb();
|
|
1258
|
+
const mod = options.powdbClientModule ?? (await loadPowdb());
|
|
1128
1259
|
const clientPool = new mod.Pool({ ...target, max: options.connectionLimit ?? 10 });
|
|
1129
1260
|
await assertNetworkedVersion(clientPool);
|
|
1130
1261
|
pool = new PowdbPool(clientPool, undefined, poolOptions);
|
|
@@ -1142,7 +1273,23 @@ export async function turbinePowDB(target, schema, options = {}) {
|
|
|
1142
1273
|
warnOnUnlimited: options.warnOnUnlimited,
|
|
1143
1274
|
queryInterfaceFactory,
|
|
1144
1275
|
}, schema);
|
|
1145
|
-
if (
|
|
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 {
|
|
1146
1293
|
// Injected pool — the caller owns its lifecycle.
|
|
1147
1294
|
client.disconnect = async () => { };
|
|
1148
1295
|
}
|
package/dist/powql.js
CHANGED
|
@@ -38,6 +38,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
38
38
|
import { NotFoundError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
|
|
39
39
|
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
|
|
40
40
|
import { PowdbFloatParam, powqlColumnType, quotePowqlIdent, rowToEntity } from './powdb.js';
|
|
41
|
+
import { isRelationPickOrderBy } from './query/filters.js';
|
|
41
42
|
import { escapeLike } from './query/utils.js';
|
|
42
43
|
import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
|
|
43
44
|
/**
|
|
@@ -475,7 +476,22 @@ export class PowqlInterface {
|
|
|
475
476
|
return '';
|
|
476
477
|
const parts = keys.map(([field, dir]) => {
|
|
477
478
|
if (dir && typeof dir === 'object') {
|
|
478
|
-
|
|
479
|
+
// Name the actual feature in the refusal — a pick-row ordering
|
|
480
|
+
// reported as "vector / distance ordering" sends users hunting for
|
|
481
|
+
// pgvector docs. All object-valued orderings stay E017 on PowDB.
|
|
482
|
+
const o = dir;
|
|
483
|
+
const feature = isRelationPickOrderBy(dir)
|
|
484
|
+
? 'relation pick-row ordering'
|
|
485
|
+
: 'distance' in o
|
|
486
|
+
? 'vector / distance ordering'
|
|
487
|
+
: Array.isArray(o.path)
|
|
488
|
+
? 'JSON-path ordering'
|
|
489
|
+
: '_count' in o
|
|
490
|
+
? 'relation _count ordering'
|
|
491
|
+
: 'sort' in o || 'nulls' in o
|
|
492
|
+
? 'NULLS placement / sort-spec ordering'
|
|
493
|
+
: 'object-valued ordering';
|
|
494
|
+
throw new UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
|
|
479
495
|
}
|
|
480
496
|
return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
|
|
481
497
|
});
|
|
@@ -949,7 +965,13 @@ export class PowqlInterface {
|
|
|
949
965
|
const { TransactionClient } = await import('./client.js');
|
|
950
966
|
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
|
|
951
967
|
const ctx = { schema: this.schema, tx: tx };
|
|
952
|
-
|
|
968
|
+
// Plant the single-writer re-entrancy marker for the implicit tx's
|
|
969
|
+
// subtree (same seam TurbineClient.$transaction uses) — user code that
|
|
970
|
+
// fires db.$transaction from inside (e.g. $use middleware around a
|
|
971
|
+
// nested-write child op) must fast-fail E017, not queue into deadlock.
|
|
972
|
+
const wrap = client
|
|
973
|
+
.wrapTransactionCallback;
|
|
974
|
+
const result = await (wrap ? wrap(() => fn(ctx)) : fn(ctx));
|
|
953
975
|
await client.query(d?.commitStatement?.() ?? 'commit');
|
|
954
976
|
return result;
|
|
955
977
|
}
|
|
@@ -1113,6 +1135,27 @@ export class PowqlInterface {
|
|
|
1113
1135
|
}
|
|
1114
1136
|
async groupBy(args) {
|
|
1115
1137
|
return this.withMiddleware('groupBy', args, async () => {
|
|
1138
|
+
// The SQL-only groupBy extensions (DISTINCT ON row source, JSON-path
|
|
1139
|
+
// group keys / aggregate targets) have no PowQL equivalent: refuse
|
|
1140
|
+
// clearly instead of emitting broken PowQL.
|
|
1141
|
+
if (args.distinctOn) {
|
|
1142
|
+
throw new UnsupportedFeatureError('groupBy distinctOn row source', 'PowDB');
|
|
1143
|
+
}
|
|
1144
|
+
for (const entry of args.by) {
|
|
1145
|
+
if (typeof entry !== 'string') {
|
|
1146
|
+
throw new UnsupportedFeatureError('JSON-path groupBy keys', 'PowDB');
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
for (const fn of ['_sum', '_avg', '_min', '_max']) {
|
|
1150
|
+
const spec = args[fn];
|
|
1151
|
+
if (!spec)
|
|
1152
|
+
continue;
|
|
1153
|
+
for (const value of Object.values(spec)) {
|
|
1154
|
+
if (value !== undefined && typeof value !== 'boolean') {
|
|
1155
|
+
throw new UnsupportedFeatureError(`JSON-path ${fn} aggregate targets`, 'PowDB');
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1116
1159
|
const params = [];
|
|
1117
1160
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1118
1161
|
const where = this.buildWhere(resolvedWhere, params);
|
|
@@ -138,6 +138,18 @@ export declare function neededParentKeyFields(parentMeta: TableMetadata, withCla
|
|
|
138
138
|
* ({@link ValidationError}) when a named relation is to-one.
|
|
139
139
|
*/
|
|
140
140
|
export declare function resolveCountRelations(parentMeta: TableMetadata, countSpec: WithCount): RelationDef[];
|
|
141
|
+
/**
|
|
142
|
+
* Reject pick-row relation ordering anywhere inside a `with` tree's orderBy —
|
|
143
|
+
* strategy parity with the join path, which throws this exact E003 at SQL
|
|
144
|
+
* build time (`pickOrderNestedError` in builder.ts). Without this guard the
|
|
145
|
+
* loaders would forward `options.orderBy` as the child reader's TOP-LEVEL
|
|
146
|
+
* findMany orderBy, where the pick shape compiles fine — so the same query
|
|
147
|
+
* would execute on 'batched' but throw on 'join'. Walks the whole tree up
|
|
148
|
+
* front so acceptance never depends on which levels have rows — the batched
|
|
149
|
+
* runners in builder.ts call this BEFORE the base query (a zero-row base
|
|
150
|
+
* result must still reject, exactly like the join strategy's build-time throw).
|
|
151
|
+
*/
|
|
152
|
+
export declare function rejectNestedPickOrder(withClause: WithClause): void;
|
|
141
153
|
/**
|
|
142
154
|
* Load every relation in `withClause` for `parents` and attach it onto each row
|
|
143
155
|
* in place. Mirrors the join strategy's output shape exactly. Recurses for nested
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
*/
|
|
49
49
|
import { CircularRelationError, RelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
|
|
50
50
|
import { normalizeKeyColumns } from '../schema.js';
|
|
51
|
+
import { isRelationPickOrderBy } from './filters.js';
|
|
51
52
|
/**
|
|
52
53
|
* Max parent keys per follow-up query. On Postgres the whole key set travels as
|
|
53
54
|
* ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit — it
|
|
@@ -177,6 +178,34 @@ export function resolveCountRelations(parentMeta, countSpec) {
|
|
|
177
178
|
function keyOf(value) {
|
|
178
179
|
return String(value);
|
|
179
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Reject pick-row relation ordering anywhere inside a `with` tree's orderBy —
|
|
183
|
+
* strategy parity with the join path, which throws this exact E003 at SQL
|
|
184
|
+
* build time (`pickOrderNestedError` in builder.ts). Without this guard the
|
|
185
|
+
* loaders would forward `options.orderBy` as the child reader's TOP-LEVEL
|
|
186
|
+
* findMany orderBy, where the pick shape compiles fine — so the same query
|
|
187
|
+
* would execute on 'batched' but throw on 'join'. Walks the whole tree up
|
|
188
|
+
* front so acceptance never depends on which levels have rows — the batched
|
|
189
|
+
* runners in builder.ts call this BEFORE the base query (a zero-row base
|
|
190
|
+
* result must still reject, exactly like the join strategy's build-time throw).
|
|
191
|
+
*/
|
|
192
|
+
export function rejectNestedPickOrder(withClause) {
|
|
193
|
+
for (const spec of Object.values(withClause)) {
|
|
194
|
+
if (!spec || spec === true)
|
|
195
|
+
continue;
|
|
196
|
+
const options = spec;
|
|
197
|
+
if (options.orderBy) {
|
|
198
|
+
for (const [key, value] of Object.entries(options.orderBy)) {
|
|
199
|
+
if (isRelationPickOrderBy(value)) {
|
|
200
|
+
throw new ValidationError(`[turbine] Pick-row ordering on relation "${key}" is only supported in a top-level ` +
|
|
201
|
+
'findMany orderBy: nested `with` orderBy does not support it.');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (options.with)
|
|
206
|
+
rejectNestedPickOrder(options.with);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
180
209
|
/**
|
|
181
210
|
* Load every relation in `withClause` for `parents` and attach it onto each row
|
|
182
211
|
* in place. Mirrors the join strategy's output shape exactly. Recurses for nested
|
|
@@ -185,6 +214,10 @@ function keyOf(value) {
|
|
|
185
214
|
export async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0, path = [ctx.parentMeta.name]) {
|
|
186
215
|
if (depth >= MAX_DEPTH)
|
|
187
216
|
throw new CircularRelationError([...path, '…']);
|
|
217
|
+
// Scope-rule parity with the join strategy: validate the whole tree BEFORE
|
|
218
|
+
// the empty-parents early return, so accept/reject never depends on data.
|
|
219
|
+
if (depth === 0)
|
|
220
|
+
rejectNestedPickOrder(withClause);
|
|
188
221
|
if (parents.length === 0)
|
|
189
222
|
return;
|
|
190
223
|
// Sibling relations are independent (each writes only its own parent[relName]
|