turbine-orm 0.78.0 → 0.79.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/client.js CHANGED
@@ -24,12 +24,13 @@
24
24
  import pg from 'pg';
25
25
  import { mergeConnectionStringOptions } from './connection-url.js';
26
26
  import { postgresDialect } from './dialect.js';
27
- import { ConnectionError, errorMessageModesDiverged, registerClientErrorMessageMode, runWithErrorMessageMode, setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
27
+ import { ConnectionError, errorMessageModesDiverged, PipelineError, registerClientErrorMessageMode, runWithErrorMessageMode, setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
28
28
  import { ObserveEngine } from './observe.js';
29
29
  import { executePipeline, pipelineSupported } from './pipeline.js';
30
30
  import { QueryInterface, } from './query/index.js';
31
31
  import { markTurbineParser, ownLookup, quoteIdent, registerUtcTemporalParsers, suggestKey, warnParserOverwrite, } from './query/utils.js';
32
32
  import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
33
+ import { currentQueryTag, deferredIdentity, emitStatementEvent, rawIdentity, resultRowCount, runReportedStatement, runWithQueryTag, } from './query-events.js';
33
34
  import { createSubscription, validateChannel, } from './realtime.js';
34
35
  import { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
35
36
  export async function withRetry(fn, options) {
@@ -531,13 +532,8 @@ export class TransactionClient {
531
532
  sql += this.dialect.paramPlaceholder(i + 1);
532
533
  }
533
534
  });
534
- try {
535
- const result = await this.client.query(sql, values);
536
- return result.rows;
537
- }
538
- catch (err) {
539
- throw wrapPgError(err);
540
- }
535
+ const result = await runReportedStatement(this.queryOptions?._onQuery, rawIdentity('raw'), sql, values, () => this.client.query(sql, values), wrapPgError);
536
+ return result.rows;
541
537
  }
542
538
  /**
543
539
  * @internal The `turbine-orm/prisma-compat` adapter's transaction seam. NOT
@@ -561,16 +557,13 @@ export class TransactionClient {
561
557
  * inside the same BEGIN/COMMIT (and any active SAVEPOINT) as every other
562
558
  * statement in the callback. Driver errors are translated by `wrapPgError`,
563
559
  * exactly as pool-scoped and table-scoped queries are. Like {@link raw}, it
564
- * emits no `$on('query')` event and runs no middleware.
560
+ * runs no middleware, and it emits one `$on('query')` event with model
561
+ * `'$raw'` and `action` set to the optional `action` label (the adapter
562
+ * passes `'$queryRaw'` / `'$executeRaw'` ...; default `'rawQuery'`).
565
563
  */
566
- async rawQuery(text, params = []) {
567
- try {
568
- const result = await this.client.query(text, params);
569
- return { rows: result.rows, rowCount: result.rowCount };
570
- }
571
- catch (err) {
572
- throw wrapPgError(err);
573
- }
564
+ async rawQuery(text, params = [], action = 'rawQuery') {
565
+ const result = await runReportedStatement(this.queryOptions?._onQuery, rawIdentity(action), text, params, () => this.client.query(text, params), wrapPgError);
566
+ return { rows: result.rows, rowCount: result.rowCount };
574
567
  }
575
568
  /**
576
569
  * Create a pool-like wrapper around the transaction client.
@@ -900,7 +893,11 @@ export class TurbineClient {
900
893
  _onQuery: (event) => {
901
894
  if (this.queryListeners.size === 0)
902
895
  return;
903
- const emitted = this.queryParamsVisible ? event : { ...event, params: event.params.map(() => '[REDACTED]') };
896
+ // The $tag() scope is read here, at the one seam every emitter shares,
897
+ // so a tag reaches model, raw, pipeline and batch events alike.
898
+ const tag = event.tag ?? currentQueryTag();
899
+ const tagged = tag === undefined || event.tag !== undefined ? event : { ...event, tag };
900
+ const emitted = this.queryParamsVisible ? tagged : { ...tagged, params: tagged.params.map(() => '[REDACTED]') };
904
901
  for (const listener of this.queryListeners) {
905
902
  try {
906
903
  listener(emitted);
@@ -1512,7 +1509,63 @@ export class TurbineClient {
1512
1509
  // where VALUES as soon as any verbose client existed in the process. The
1513
1510
  // scope is established around the await, so every continuation of the batch
1514
1511
  // resolves this client's mode.
1515
- return this.withErrorMode(() => executePipeline(this.pool, queries, options));
1512
+ const sink = this.queryListeners.size > 0 ? this.queryOptions._onQuery : undefined;
1513
+ if (!sink)
1514
+ return this.withErrorMode(() => executePipeline(this.pool, queries, options));
1515
+ return this.withErrorMode(() => this.reportedPipeline(sink, queries, options));
1516
+ }
1517
+ /**
1518
+ * {@link pipeline} with one `$on('query')` event per statement. Only taken
1519
+ * when a listener is registered, so an unobserved pipeline runs exactly as
1520
+ * before. Each statement's `transform` is wrapped to capture the driver's
1521
+ * row count on the way through (the pipeline returns transformed values, not
1522
+ * driver results). The statements share one round trip, so the batch's wall
1523
+ * time is split evenly across them and each event carries
1524
+ * `batch: 'pipeline'`.
1525
+ */
1526
+ async reportedPipeline(sink, queries, options) {
1527
+ const rowCounts = new Array(queries.length).fill(undefined);
1528
+ const observed = queries.map((q, i) => ({
1529
+ ...q,
1530
+ transform: (raw) => {
1531
+ rowCounts[i] = resultRowCount(raw);
1532
+ return q.transform(raw);
1533
+ },
1534
+ }));
1535
+ const start = performance.now();
1536
+ let failure;
1537
+ try {
1538
+ return await executePipeline(this.pool, observed, options);
1539
+ }
1540
+ catch (err) {
1541
+ failure = err;
1542
+ throw err;
1543
+ }
1544
+ finally {
1545
+ const duration = (performance.now() - start) / queries.length;
1546
+ // PipelineError names the failing slot; any other failure (connect,
1547
+ // BEGIN) is charged to the first statement that produced no result.
1548
+ const failedIndex = failure === undefined
1549
+ ? -1
1550
+ : failure instanceof PipelineError && failure.failedIndex !== undefined
1551
+ ? failure.failedIndex
1552
+ : rowCounts.indexOf(undefined);
1553
+ queries.forEach((q, i) => {
1554
+ const failed = i === failedIndex;
1555
+ // A slot that never ran (or rolled back unseen) is not reported.
1556
+ if (failed || rowCounts[i] !== undefined) {
1557
+ emitStatementEvent(sink, {
1558
+ sql: q.sql,
1559
+ params: q.params,
1560
+ duration,
1561
+ ...deferredIdentity(q.tag),
1562
+ rows: rowCounts[i] ?? 0,
1563
+ batch: 'pipeline',
1564
+ ...(failed ? { error: failure instanceof Error ? failure : new Error(String(failure)) } : {}),
1565
+ });
1566
+ }
1567
+ });
1568
+ }
1516
1569
  }
1517
1570
  /**
1518
1571
  * Check whether the underlying pool supports the real pipeline protocol.
@@ -1548,13 +1601,43 @@ export class TurbineClient {
1548
1601
  if (this.logging) {
1549
1602
  console.log(`[turbine] Raw SQL: ${sql.trim().substring(0, 120)}...`);
1550
1603
  }
1551
- try {
1552
- const result = await this.pool.query(sql, values);
1553
- return result.rows;
1554
- }
1555
- catch (err) {
1556
- throw this.withErrorMode(() => wrapPgError(err));
1557
- }
1604
+ const result = await runReportedStatement(this.queryOptions._onQuery, rawIdentity('raw'), sql, values, () => this.pool.query(sql, values), (err) => this.withErrorMode(() => wrapPgError(err)));
1605
+ return result.rows;
1606
+ }
1607
+ /**
1608
+ * @internal The `turbine-orm/prisma-compat` adapter's pool-level raw seam,
1609
+ * the twin of {@link TransactionClient.rawQuery}. NOT application API: use
1610
+ * {@link raw} or {@link sql}, whose tagged templates make concatenating a
1611
+ * value into the SQL text impossible. It exists so the adapter's
1612
+ * `$queryRaw` / `$executeRaw` statements emit a `$on('query')` event (model
1613
+ * `'$raw'`, `action` the adapter's label) instead of running unobserved on
1614
+ * the pool. Errors are translated by `wrapPgError`, like {@link raw}.
1615
+ */
1616
+ async rawQuery(text, params = [], action = 'rawQuery') {
1617
+ const result = await runReportedStatement(this.queryOptions._onQuery, rawIdentity(action), text, params, () => this.pool.query(text, params), (err) => this.withErrorMode(() => wrapPgError(err)));
1618
+ return { rows: result.rows, rowCount: result.rowCount };
1619
+ }
1620
+ /**
1621
+ * Run `fn` with `tag` attached to every `$on('query')` event it causes, so a
1622
+ * listener can attribute queries to the feature or code path that issued
1623
+ * them. The scope follows async context: queries in awaited helpers,
1624
+ * transactions, pipelines and raw SQL inside `fn` are all tagged. An inner
1625
+ * `$tag` replaces an outer one for its own duration. The tag is event
1626
+ * metadata only and never reaches SQL.
1627
+ *
1628
+ * Throws {@link ValidationError} (E003) for an empty tag or one longer than
1629
+ * {@link MAX_QUERY_TAG_LENGTH} (128) characters.
1630
+ *
1631
+ * @example
1632
+ * ```ts
1633
+ * const order = await db.$tag('checkout', async () => {
1634
+ * const cart = await db.carts.findUnique({ where: { id } });
1635
+ * return db.orders.create({ data: { cartId: cart.id } });
1636
+ * });
1637
+ * ```
1638
+ */
1639
+ $tag(tag, fn) {
1640
+ return runWithQueryTag(tag, fn);
1558
1641
  }
1559
1642
  /**
1560
1643
  * Execute a **typed** raw SQL query, Turbine's answer to Prisma's TypedSQL.
@@ -1594,7 +1677,7 @@ export class TurbineClient {
1594
1677
  // `withErrorMode` docstring already claimed to cover the typed-SQL builder
1595
1678
  // and did not, which left two raw-SQL entry points disagreeing about the
1596
1679
  // same statement, since the adjacent `raw` tag WAS scoped.
1597
- return new TypedSqlQuery(this.pool, sql, params, this.logging, (fn) => this.withErrorMode(fn));
1680
+ return new TypedSqlQuery(this.pool, sql, params, this.logging, (fn) => this.withErrorMode(fn), this.queryOptions._onQuery);
1598
1681
  }
1599
1682
  // -------------------------------------------------------------------------
1600
1683
  // Transaction support (raw, legacy)
@@ -1834,13 +1917,18 @@ export class TurbineClient {
1834
1917
  if (queries.length === 0) {
1835
1918
  return [];
1836
1919
  }
1920
+ // One `$on('query')` event per statement, `batch: 'transaction'`, only
1921
+ // when someone is listening; each statement is timed on its own.
1922
+ const sink = this.queryListeners.size > 0 ? this.queryOptions._onQuery : undefined;
1923
+ const reported = (dq, run) => runReportedStatement(sink, { ...deferredIdentity(dq.tag), batch: 'transaction' }, dq.sql, dq.params, run, wrapPgError);
1837
1924
  return this.transaction(async (client) => {
1838
1925
  const pipelined = client.supportsPipelining === true &&
1839
1926
  this.dialect.resultStrategy !== 'reselect';
1840
1927
  if (pipelined) {
1841
1928
  // Dispatch every statement before awaiting any reply. The driver's
1842
- // FIFO guarantee makes settled[i] the reply to queries[i].
1843
- const settled = await Promise.allSettled(queries.map((dq) => client.query(dq.sql, dq.params)));
1929
+ // FIFO guarantee makes settled[i] the reply to queries[i]. Each
1930
+ // statement is timed from its dispatch to its own reply.
1931
+ const settled = await Promise.allSettled(queries.map((dq) => reported(dq, () => client.query(dq.sql, dq.params))));
1844
1932
  const results = [];
1845
1933
  for (let i = 0; i < settled.length; i++) {
1846
1934
  const outcome = settled[i];
@@ -1853,19 +1941,12 @@ export class TurbineClient {
1853
1941
  }
1854
1942
  const results = [];
1855
1943
  for (const dq of queries) {
1856
- let raw;
1857
- try {
1858
- // Non-RETURNING engines (resultStrategy 'reselect', e.g. MySQL)
1859
- // attach a reselect plan that runs the write plus a follow-up SELECT;
1860
- // running dq.sql alone would transform a row-less write result.
1861
- raw =
1862
- this.dialect.resultStrategy === 'reselect' && dq.reselect
1863
- ? await dq.reselect((sql, params) => client.query(sql, params))
1864
- : await client.query(dq.sql, dq.params);
1865
- }
1866
- catch (err) {
1867
- throw wrapPgError(err);
1868
- }
1944
+ // Non-RETURNING engines (resultStrategy 'reselect', e.g. MySQL) attach a
1945
+ // reselect plan that runs the write plus a follow-up SELECT; running
1946
+ // dq.sql alone would transform a row-less write result.
1947
+ const raw = await reported(dq, () => this.dialect.resultStrategy === 'reselect' && dq.reselect
1948
+ ? dq.reselect((sql, params) => client.query(sql, params))
1949
+ : client.query(dq.sql, dq.params));
1869
1950
  results.push(dq.transform(raw));
1870
1951
  }
1871
1952
  return results;
package/dist/index.d.ts CHANGED
@@ -45,6 +45,7 @@ export { HttpJsonSink, type HttpJsonSinkOptions, type MetricsFlushBatch, type Me
45
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
46
46
  export { fingerprintPrismaSchema } from './prisma-schema-fingerprint.js';
47
47
  export { type AggregateArgs, type AggregateResult, type ArrayFilter, AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type GroupByResult, type HavingClause, type JsonEncoding, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderByObject, type OrderBySpec, type OrderDirection, type PrivilegeOption, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationLoadStrategy, type RelationOrderBy, type RelationOrderByChain, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TemporalInfinityReading, type TextSearchFilter, type TypedWithClause, UNSAFE, type Unsafe, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithOrderByObject, type WithResult, } from './query/index.js';
48
+ export { MAX_QUERY_TAG_LENGTH, RAW_QUERY_MODEL } from './query-events.js';
48
49
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
49
50
  export type { CheckMetadata, ColumnMetadata, IndexMetadata, PrismaCompatMap, PrismaModelMap, PrismaRelationMap, PrismaSchemaSource, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
50
51
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, withDbFieldNames, } from './schema.js';
package/dist/index.js CHANGED
@@ -55,6 +55,8 @@ export { fingerprintPrismaSchema } from './prisma-schema-fingerprint.js';
55
55
  // `allowFullTableScan` are unlocked by THIS VALUE and nothing else, so a
56
56
  // request body spread into query args cannot enable them (JSON has no symbols).
57
57
  export { AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, QueryInterface, UNSAFE, } from './query/index.js';
58
+ // $on('query') event metadata: raw-statement model name, $tag() label limit
59
+ export { MAX_QUERY_TAG_LENGTH, RAW_QUERY_MODEL } from './query-events.js';
58
60
  // Realtime, LISTEN/NOTIFY pub/sub
59
61
  export { validateChannel } from './realtime.js';
60
62
  // Schema utilities
package/dist/powql.d.ts CHANGED
@@ -155,6 +155,34 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
155
155
  * the two can never disagree about which limit is in force.
156
156
  */
157
157
  private effectiveLimit;
158
+ /**
159
+ * Options for the child-side interfaces the relation loaders and the
160
+ * relation-filter resolvers build: the client's, with `defaultLimit` cleared
161
+ * and the unlimited warning off. `defaultLimit` bounds the caller's PAGE; on
162
+ * an internal fetch that serves that page (a relation's children, a filter's
163
+ * key set) it would cap the whole page's children, or the key set, at the
164
+ * page size: a silent wrong answer by another route than the one
165
+ * {@link pageOf} closes. The SQL batched loader clears it the same way
166
+ * (`batchedChildOptions` in query/builder.ts).
167
+ */
168
+ private childOptions?;
169
+ private loaderChildOptions;
170
+ /**
171
+ * ONE parent's window of its stitched children: the relation's `offset` and
172
+ * `limit` applied to that parent's own list.
173
+ *
174
+ * A relation `limit` means "at most N on EACH parent". The loaders used to
175
+ * spread it onto the flat child fetch (`post filter .author_id in (...)
176
+ * limit 5`), which caps the TOTAL across every parent in the chunk, so ten
177
+ * parents shared five posts and most got none, with no error. It shipped
178
+ * for the loaders' whole life because the one test of the shape compared
179
+ * the join path against the loader path and both were wrong the same way;
180
+ * the cross-engine benchmark found it, where the wrong answer was the fast
181
+ * one. Every loader now fetches its children unbounded, WITH the relation
182
+ * `orderBy` (which decides which rows the window keeps), and applies this
183
+ * at stitch time: the rule the SQL engines' batched loader has always used.
184
+ */
185
+ private static pageOf;
158
186
  /**
159
187
  * Reject a negative `limit` / `offset` before it reaches the engine. PowDB
160
188
  * casts both with `as usize` at execution, so below engine 0.20 a negative
@@ -623,9 +651,10 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
623
651
  * `referenceKey` on the TARGET table (the join's non-fetched side);
624
652
  * - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
625
653
  * stitch can't be reproduced by the 3-table join deterministically);
626
- * - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
627
- * does a to-many relation `limit`/`offset` when the parent set spills past
628
- * one loader chunk (the loader limits per chunk, the join once globally).
654
+ * - a to-one relation `limit`/`offset` (meaningless) stays on the loader. A
655
+ * to-many relation `limit`/`offset` is a PER-PARENT bound on both paths,
656
+ * fetched unbounded and sliced per parent at stitch time (`pageOf`), so
657
+ * the join statement carries neither clause.
629
658
  */
630
659
  private joinEligible;
631
660
  /**
package/dist/powql.js CHANGED
@@ -357,6 +357,42 @@ export class PowqlInterface {
357
357
  effectiveLimit(args) {
358
358
  return args.limit ?? this.defaultLimit;
359
359
  }
360
+ /**
361
+ * Options for the child-side interfaces the relation loaders and the
362
+ * relation-filter resolvers build: the client's, with `defaultLimit` cleared
363
+ * and the unlimited warning off. `defaultLimit` bounds the caller's PAGE; on
364
+ * an internal fetch that serves that page (a relation's children, a filter's
365
+ * key set) it would cap the whole page's children, or the key set, at the
366
+ * page size: a silent wrong answer by another route than the one
367
+ * {@link pageOf} closes. The SQL batched loader clears it the same way
368
+ * (`batchedChildOptions` in query/builder.ts).
369
+ */
370
+ childOptions;
371
+ loaderChildOptions() {
372
+ this.childOptions ??= { ...this.options, defaultLimit: undefined, warnOnUnlimited: false };
373
+ return this.childOptions;
374
+ }
375
+ /**
376
+ * ONE parent's window of its stitched children: the relation's `offset` and
377
+ * `limit` applied to that parent's own list.
378
+ *
379
+ * A relation `limit` means "at most N on EACH parent". The loaders used to
380
+ * spread it onto the flat child fetch (`post filter .author_id in (...)
381
+ * limit 5`), which caps the TOTAL across every parent in the chunk, so ten
382
+ * parents shared five posts and most got none, with no error. It shipped
383
+ * for the loaders' whole life because the one test of the shape compared
384
+ * the join path against the loader path and both were wrong the same way;
385
+ * the cross-engine benchmark found it, where the wrong answer was the fast
386
+ * one. Every loader now fetches its children unbounded, WITH the relation
387
+ * `orderBy` (which decides which rows the window keeps), and applies this
388
+ * at stitch time: the rule the SQL engines' batched loader has always used.
389
+ */
390
+ static pageOf(rows, limit, offset) {
391
+ if (limit === undefined && !offset)
392
+ return rows;
393
+ const start = offset ?? 0;
394
+ return rows.slice(start, limit === undefined ? undefined : start + limit);
395
+ }
360
396
  /**
361
397
  * Reject a negative `limit` / `offset` before it reaches the engine. PowDB
362
398
  * casts both with `as usize` at execution, so below engine 0.20 a negative
@@ -938,7 +974,7 @@ export class PowqlInterface {
938
974
  const childCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
939
975
  const localField = this.meta.reverseColumnMap[localCol] ?? localCol;
940
976
  const childField = targetMeta.reverseColumnMap[childCol] ?? childCol;
941
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
977
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
942
978
  const collect = async (w) => {
943
979
  const rows = await targetQi.findMany({
944
980
  where: w,
@@ -977,7 +1013,7 @@ export class PowqlInterface {
977
1013
  const sourceRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
978
1014
  const sourceRefColMeta = this.meta.columns.find((c) => c.name === sourceRefCol);
979
1015
  const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
980
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1016
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
981
1017
  const collectTargetPks = async (w) => {
982
1018
  const rows = await targetQi.findMany({
983
1019
  where: w,
@@ -1689,7 +1725,7 @@ export class PowqlInterface {
1689
1725
  const rel = this.meta.relations[relName];
1690
1726
  if (!rel)
1691
1727
  throw new ValidationError(`Unknown relation "${relName}" on "${this.table}".`);
1692
- if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args, parents.length)) {
1728
+ if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args)) {
1693
1729
  if (this.capabilities.serverJoins) {
1694
1730
  await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii);
1695
1731
  continue;
@@ -1712,7 +1748,19 @@ export class PowqlInterface {
1712
1748
  throw new UnsupportedFeatureError('composite-key nested reads', 'PowDB', `relation "${relName}"`);
1713
1749
  }
1714
1750
  const options = (opt === true ? {} : opt);
1715
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1751
+ // The relation's own page, applied PER PARENT at stitch time (see pageOf),
1752
+ // never on the flat fetch below.
1753
+ const relLimit = options.limit;
1754
+ const relOffset = options.offset;
1755
+ this.assertPagination(relLimit, relOffset, `relation "${relName}"`);
1756
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1757
+ if (relLimit === 0) {
1758
+ // `[]` on every parent by construction: nothing to fetch.
1759
+ for (const parent of parents)
1760
+ parent[relName] = single ? null : [];
1761
+ continue;
1762
+ }
1763
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
1716
1764
  const targetMeta = this.schema.tables[rel.to];
1717
1765
  // Local key on the parent, remote key on the target child.
1718
1766
  const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
@@ -1770,6 +1818,9 @@ export class PowqlInterface {
1770
1818
  };
1771
1819
  const children = (await targetQi.findMany({
1772
1820
  ...fetchOptions,
1821
+ // Unbounded: the relation limit/offset are per parent, applied below.
1822
+ limit: undefined,
1823
+ offset: undefined,
1773
1824
  where: childWhere,
1774
1825
  with: options.with,
1775
1826
  timeout: options.timeout ?? timeout,
@@ -1796,11 +1847,12 @@ export class PowqlInterface {
1796
1847
  delete child[childKeyField];
1797
1848
  }
1798
1849
  }
1799
- const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1800
1850
  for (const parent of parents) {
1801
1851
  const k = this.joinKey(parent[parentKeyField]);
1802
1852
  const matches = (k == null ? undefined : childByKey.get(k)) ?? [];
1803
- parent[relName] = single ? (matches[0] ?? null) : matches;
1853
+ parent[relName] = single
1854
+ ? (matches[0] ?? null)
1855
+ : PowqlInterface.pageOf(matches, relLimit, relOffset);
1804
1856
  }
1805
1857
  }
1806
1858
  }
@@ -1866,7 +1918,13 @@ export class PowqlInterface {
1866
1918
  }
1867
1919
  // (2) Target rows by PK, honouring the relation's own where/with/select/…
1868
1920
  const options = (opt === true ? {} : opt);
1869
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1921
+ this.assertPagination(options.limit, options.offset, `relation "${relName}"`);
1922
+ if (options.limit === 0) {
1923
+ for (const parent of parents)
1924
+ parent[relName] = [];
1925
+ return;
1926
+ }
1927
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
1870
1928
  // This loader stitches on the TARGET's own primary key, so the PK has to be
1871
1929
  // in the fetch even when the caller's select/omit excludes it, and has to
1872
1930
  // come back off afterwards. Exactly the shape `loadRelation` uses for its
@@ -1903,6 +1961,11 @@ export class PowqlInterface {
1903
1961
  }
1904
1962
  }
1905
1963
  const targetByPk = new Map();
1964
+ // Position of each target in the fetch, i.e. its rank under the relation's
1965
+ // `orderBy`; the stitch below sorts a parent's children by it when an
1966
+ // orderBy was given, so `limit` keeps that parent's top-N and not its first
1967
+ // N junction rows. Across fetch CHUNKS the rank is fetch order.
1968
+ const targetRank = new Map();
1906
1969
  const targetValList = [...allTargetVals].map((v) => targetPkColMeta ? coerceScalar(v, targetPkColMeta.tsType) : v);
1907
1970
  const targetChunk = this.keyChunkSize(targetMeta, targetPkCol);
1908
1971
  for (let i = 0; i < targetValList.length; i += targetChunk) {
@@ -1913,14 +1976,21 @@ export class PowqlInterface {
1913
1976
  };
1914
1977
  const targets = (await targetQi.findMany({
1915
1978
  ...fetchOptions,
1979
+ // Unbounded: the relation limit/offset are per parent, applied in the stitch.
1980
+ limit: undefined,
1981
+ offset: undefined,
1916
1982
  where,
1917
1983
  with: options.with,
1918
1984
  timeout: options.timeout ?? timeout,
1919
1985
  // Public findMany, so the sentinel form. See loadRelation above.
1920
1986
  includePii: includePii ? UNSAFE : undefined,
1921
1987
  }));
1922
- for (const t of targets)
1923
- targetByPk.set(String(t[targetPkField]), t);
1988
+ for (const t of targets) {
1989
+ const pk = String(t[targetPkField]);
1990
+ targetByPk.set(pk, t);
1991
+ if (!targetRank.has(pk))
1992
+ targetRank.set(pk, targetRank.size);
1993
+ }
1924
1994
  }
1925
1995
  // (3) Stitch: each parent → its junction targets (m2m is always a list).
1926
1996
  for (const parent of parents) {
@@ -1932,7 +2002,11 @@ export class PowqlInterface {
1932
2002
  if (child)
1933
2003
  children.push(child);
1934
2004
  }
1935
- parent[relName] = children;
2005
+ if (options.orderBy) {
2006
+ const rank = (t) => targetRank.get(String(t[targetPkField])) ?? 0;
2007
+ children.sort((a, b) => rank(a) - rank(b));
2008
+ }
2009
+ parent[relName] = PowqlInterface.pageOf(children, options.limit, options.offset);
1936
2010
  }
1937
2011
  // Stitching is done: take the forced PK back off. Iterating the map rather
1938
2012
  // than the stitched lists is deliberate, one target can be linked from many
@@ -1976,11 +2050,12 @@ export class PowqlInterface {
1976
2050
  * `referenceKey` on the TARGET table (the join's non-fetched side);
1977
2051
  * - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
1978
2052
  * stitch can't be reproduced by the 3-table join deterministically);
1979
- * - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
1980
- * does a to-many relation `limit`/`offset` when the parent set spills past
1981
- * one loader chunk (the loader limits per chunk, the join once globally).
2053
+ * - a to-one relation `limit`/`offset` (meaningless) stays on the loader. A
2054
+ * to-many relation `limit`/`offset` is a PER-PARENT bound on both paths,
2055
+ * fetched unbounded and sliced per parent at stitch time (`pageOf`), so
2056
+ * the join statement carries neither clause.
1982
2057
  */
1983
- joinEligible(rel, opt, args, parentCount) {
2058
+ joinEligible(rel, opt, args) {
1984
2059
  const effLimit = args.limit ?? this.defaultLimit;
1985
2060
  if (effLimit !== undefined || args.offset)
1986
2061
  return false;
@@ -2021,9 +2096,8 @@ export class PowqlInterface {
2021
2096
  return false;
2022
2097
  }
2023
2098
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
2024
- if ((options.limit !== undefined || options.offset) && (single || parentCount > MAX_RELATION_KEYS)) {
2099
+ if ((options.limit !== undefined || options.offset) && single)
2025
2100
  return false;
2026
- }
2027
2101
  // A `limit 0` relation stays off the join statement for the same reason it
2028
2102
  // stays off a nested projection: PowDB answered `limit 0` with one row below
2029
2103
  // engine 0.20. The loader resolves it client-side, correctly on every version.
@@ -2070,13 +2144,13 @@ export class PowqlInterface {
2070
2144
  const { cols: childCols, forcedPk: childForcedPk } = this.joinChildCols(targetQi, options, includePii);
2071
2145
  const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
2072
2146
  const order = targetQi.buildOrder(options.orderBy, params, 'c');
2147
+ // No limit/offset clause: on the join they would bound the TOTAL child
2148
+ // count across every parent. The relation's page is per parent (pageOf).
2073
2149
  this.assertPagination(options.limit, options.offset, `relation "${relName}"`);
2074
- const limitClause = options.limit !== undefined ? ` limit ${this.param(options.limit, params)}` : '';
2075
- const offsetClause = options.offset ? ` offset ${this.param(options.offset, params)}` : '';
2076
2150
  const proj = this.joinProjection(childCols, `p.${quotePowqlIdent(parentKeyCol)}`, 'c');
2077
2151
  const powql = `${targetQi.qt} as c join ${this.qt} as p ` +
2078
2152
  `on c.${quotePowqlIdent(childKeyCol)} = p.${quotePowqlIdent(parentKeyCol)}` +
2079
- `${filter}${order}${limitClause}${offsetClause} ${proj}`;
2153
+ `${filter}${order} ${proj}`;
2080
2154
  // A READ: thread a read-shaped action through the exec seam.
2081
2155
  const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
2082
2156
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
@@ -2084,7 +2158,9 @@ export class PowqlInterface {
2084
2158
  for (const p of parents) {
2085
2159
  const key = this.joinKey(p[parentKeyField]);
2086
2160
  const matches = (key == null ? undefined : byKey.get(key)) ?? [];
2087
- p[relName] = single ? (matches[0] ?? null) : matches;
2161
+ p[relName] = single
2162
+ ? (matches[0] ?? null)
2163
+ : PowqlInterface.pageOf(matches, options.limit, options.offset);
2088
2164
  }
2089
2165
  }
2090
2166
  /**
@@ -174,7 +174,7 @@ export interface CompatTransactionClient {
174
174
  * breaks atomicity. Optional only so a test stub can omit it: when it is
175
175
  * absent the raw methods throw instead of escaping the transaction.
176
176
  */
177
- rawQuery?(text: string, params?: readonly unknown[]): Promise<{
177
+ rawQuery?(text: string, params?: readonly unknown[], action?: string): Promise<{
178
178
  rows: unknown[];
179
179
  rowCount: number | null;
180
180
  }>;
@@ -2067,17 +2067,17 @@ function makeRawSurface(exec, ph) {
2067
2067
  return {
2068
2068
  $queryRaw: async (strings, ...values) => {
2069
2069
  const { text, params } = flattenTemplate(strings, values, ph);
2070
- return (await exec(text, params)).rows;
2070
+ return (await exec(text, params, '$queryRaw')).rows;
2071
2071
  },
2072
2072
  $queryRawUnsafe: async (sql, ...params) => {
2073
- return (await exec(sql, params)).rows;
2073
+ return (await exec(sql, params, '$queryRawUnsafe')).rows;
2074
2074
  },
2075
2075
  $executeRaw: async (strings, ...values) => {
2076
2076
  const { text, params } = flattenTemplate(strings, values, ph);
2077
- return (await exec(text, params)).rowCount ?? 0;
2077
+ return (await exec(text, params, '$executeRaw')).rowCount ?? 0;
2078
2078
  },
2079
2079
  $executeRawUnsafe: async (sql, ...params) => {
2080
- return (await exec(sql, params)).rowCount ?? 0;
2080
+ return (await exec(sql, params, '$executeRawUnsafe')).rowCount ?? 0;
2081
2081
  },
2082
2082
  };
2083
2083
  }
@@ -2334,8 +2334,12 @@ export function createPrismaCompatClient(client, map, options = {}) {
2334
2334
  baseDelegates.set(prismaModel, makeDelegate(ctx, mm, () => db.table(mm.table), (fn) => db.$transaction((tx) => fn((n) => tx.table(n)))));
2335
2335
  }
2336
2336
  const ph = placeholderOf(db);
2337
- const runRaw = async (text, params) => {
2337
+ // Prefer the client's own raw seam, which emits a `$on('query')` event; a
2338
+ // client without one (an older core, a test stub) runs on the pool as before.
2339
+ const runRaw = async (text, params, action) => {
2338
2340
  try {
2341
+ if (typeof db.rawQuery === 'function')
2342
+ return await db.rawQuery(text, params, action);
2339
2343
  return await poolOf(db).query(text, params);
2340
2344
  }
2341
2345
  catch (err) {
@@ -2350,14 +2354,14 @@ export function createPrismaCompatClient(client, map, options = {}) {
2350
2354
  * `wrapPgError` is idempotent (it returns an already-typed TurbineError
2351
2355
  * untouched), so the error shape matches the pool path exactly.
2352
2356
  */
2353
- const txRunRaw = (tx) => async (text, params) => {
2357
+ const txRunRaw = (tx) => async (text, params, action) => {
2354
2358
  if (typeof tx.rawQuery !== 'function') {
2355
2359
  throw decorate(new ValidationError('prisma-compat: raw SQL inside $transaction needs a transaction client that can execute it ' +
2356
2360
  '(core TransactionClient.rawQuery). Refusing to run the statement on a pool connection, which would ' +
2357
2361
  'silently place it outside the transaction.'), ctx.options.prismaErrorCodes);
2358
2362
  }
2359
2363
  try {
2360
- return await tx.rawQuery(text, params);
2364
+ return await tx.rawQuery(text, params, action);
2361
2365
  }
2362
2366
  catch (err) {
2363
2367
  throw decorate(wrapPgError(err), ctx.options.prismaErrorCodes);
@@ -93,6 +93,21 @@ export interface QueryEvent {
93
93
  * observability can see exactly which queries the auto default re-planned.
94
94
  */
95
95
  strategy?: 'auto-batched';
96
+ /**
97
+ * The label of the innermost `db.$tag(label, fn)` scope the query ran in.
98
+ * Absent outside any scope. Lets a listener attribute a query to the feature
99
+ * or code path that issued it, which `model` + `action` cannot.
100
+ */
101
+ tag?: string;
102
+ /**
103
+ * Set on statements that ran as part of a batch rather than on their own:
104
+ * `'pipeline'` for `db.pipeline(...)`, `'transaction'` for the array form
105
+ * `$transaction([...])`. A pipeline sends every statement in one round trip,
106
+ * so its per-statement `duration` is the batch's wall time divided evenly
107
+ * across the statements (the sum is exact, the split is not). Transaction
108
+ * batch statements are timed individually. Absent for everything else.
109
+ */
110
+ batch?: 'pipeline' | 'transaction';
96
111
  }
97
112
  export type QueryEventListener = (event: QueryEvent) => void;
98
113
  /** Options passed from TurbineClient to QueryInterface */