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/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 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.
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 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.
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. 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.
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 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.)
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
- const client = await this.pool.acquire();
783
+ this.assertOpen();
784
+ let client;
785
+ try {
786
+ client = await this.pool.acquire();
787
+ }
788
+ catch (err) {
789
+ throw wrapPowdbError(err);
790
+ }
791
+ this.checkedOut.add(client);
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 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.
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
- release: () => {
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();
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
- return broken ? this.pool.destroy(client) : this.pool.release(client);
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 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.)
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
- holdRef.hold?.finish();
995
- holdRef.hold = null;
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. Caveat: durability is checkpoint-bound, so
1004
- // hold the process open long enough for the final WAL flush in short scripts.
1131
+ // the engine's checkpoint flush. Marking the pool closed makes later
1132
+ // queries fail with a typed ConnectionError instead of silently running
1133
+ // against a handle the caller believes is gone. Caveat: durability is
1134
+ // checkpoint-bound, so hold the process open long enough for the final
1135
+ // WAL flush in short scripts.
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 (!owns) {
1276
+ if (owns) {
1277
+ // Turbine built this pool / embedded handle, so disconnect()/end() must
1278
+ // close it. client.ts sees TurbineConfig.pool as EXTERNAL (ownsPool =
1279
+ // false) and skips pool.end(); before this patch an owned networked
1280
+ // client leaked its live socket(s) on disconnect(), holding the process
1281
+ // open until powdb-server's idle timeout (~300s) closed them. Consistent
1282
+ // with turbineMssql's owned-pool patch.
1283
+ const baseDisconnect = client.disconnect.bind(client);
1284
+ const close = async () => {
1285
+ await baseDisconnect();
1286
+ await pool.end();
1287
+ };
1288
+ const patch = client;
1289
+ patch.disconnect = close;
1290
+ patch.end = close;
1291
+ }
1292
+ else {
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
- throw new UnsupportedFeatureError('vector / distance ordering', 'PowDB', `field "${field}"`);
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
- const result = await fn(ctx);
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]