turbine-orm 0.55.0 → 0.56.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.
@@ -1352,11 +1352,11 @@ export class QueryInterface {
1352
1352
  const deferred = single
1353
1353
  ? this.buildFindUnique(baseArgs)
1354
1354
  : this.buildFindMany(baseArgs);
1355
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
1355
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
1356
1356
  const rows = deferred.transform(result);
1357
1357
  const entities = single ? (rows ? [rows] : []) : rows;
1358
1358
  if (entities.length > 0) {
1359
- await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii === true), entities, batchedWith, args.timeout);
1359
+ await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii === true, args.forceCustomPlan === true), entities, batchedWith, args.timeout);
1360
1360
  }
1361
1361
  stripFields(entities, proj.strip);
1362
1362
  return single ? (entities[0] ?? null) : entities;
@@ -1372,7 +1372,7 @@ export class QueryInterface {
1372
1372
  * and unlimited-warnings silenced, a relation load must fetch every matching
1373
1373
  * child, and the per-relation `limit` is applied client-side by the loader.
1374
1374
  */
1375
- batchedContext(timeout, skip, includePii) {
1375
+ batchedContext(timeout, skip, includePii, forceCustomPlan = false) {
1376
1376
  const childOptions = {
1377
1377
  ...this.options,
1378
1378
  defaultLimit: undefined,
@@ -1382,7 +1382,11 @@ export class QueryInterface {
1382
1382
  parentMeta: this.tableMeta,
1383
1383
  schema: this.schema,
1384
1384
  makeChild: (table) => new QueryInterface(this.pool, table, this.schema, [], childOptions),
1385
- exec: (sql, params, preparedName) => this.queryWithTimeout(sql, params, timeout, preparedName),
1385
+ // The per-query `forceCustomPlan` opt-in covers the relation follow-ups
1386
+ // too: a batched load re-issues the SAME tenant-shaped predicate one
1387
+ // level down, so leaving those named would keep exactly the plan-cache
1388
+ // exposure the caller asked to be rid of.
1389
+ exec: (sql, params, preparedName) => this.queryWithTimeout(sql, params, timeout, this.preparedNameFor({ forceCustomPlan }, preparedName)),
1386
1390
  quote: (name) => this.q(name),
1387
1391
  buildInClause: (expr, paramRef, negated) => this.inClause(expr, paramRef, negated),
1388
1392
  inClauseParam: (values) => this.inParam(values),
@@ -1434,10 +1438,10 @@ export class QueryInterface {
1434
1438
  const { baseArgs, strip } = this.prepareBatchedBase(args, withClause);
1435
1439
  // baseArgs.with is always undefined here; the cast just bridges the R generic.
1436
1440
  const deferred = this.buildFindMany(baseArgs);
1437
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
1441
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
1438
1442
  const entities = deferred.transform(result);
1439
1443
  if (entities.length > 0) {
1440
- await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii === true), entities, withClause, args.timeout);
1444
+ await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii === true, args.forceCustomPlan === true), entities, withClause, args.timeout);
1441
1445
  }
1442
1446
  stripFields(entities, strip);
1443
1447
  return entities;
@@ -1593,6 +1597,69 @@ export class QueryInterface {
1593
1597
  // Listener errors must never crash a query
1594
1598
  }
1595
1599
  }
1600
+ /**
1601
+ * Resolve the prepared-statement name a read should execute under, honouring
1602
+ * the per-query {@link FindManyArgs.forceCustomPlan} opt-in.
1603
+ *
1604
+ * `forceCustomPlan: true` returns `undefined`, which sends the statement
1605
+ * UNNAMED. The mechanism is NOT "PostgreSQL treats an unnamed statement as a
1606
+ * one-shot plan that never enters the plan cache": the backend builds and
1607
+ * saves a `CachedPlanSource` for the unnamed statement too. It works because
1608
+ * node-postgres only skips Parse for a statement it has already parsed BY
1609
+ * NAME (`Query.hasBeenParsed` is `this.name && connection.parsedStatements[this.name]`),
1610
+ * so an unnamed statement is re-Parsed on every execution, each Parse
1611
+ * replaces the unnamed cached plan source with a fresh one whose custom-plan
1612
+ * counter is zero, and the five-execution threshold that precedes promotion
1613
+ * is never reached. Every execution is therefore planned with the real
1614
+ * parameter values.
1615
+ *
1616
+ * No GUC is set, no `SET LOCAL` is emitted, no transaction is opened, and no
1617
+ * extra round trip is added, which is exactly why the opt-in can be per query
1618
+ * while the client-level `planCacheMode` (a connection parameter) cannot be.
1619
+ *
1620
+ * The refusal is deliberately here, at the one seam every read execution
1621
+ * passes through, rather than in each build method: the flag changes NOTHING
1622
+ * about the SQL text, so a build-time check would have had to be repeated in
1623
+ * every builder and could still be bypassed by a hand-executed
1624
+ * `DeferredQuery`.
1625
+ *
1626
+ * Engines whose dialect does not report {@link Dialect.supportsPlanCacheMode}
1627
+ * throw {@link UnsupportedFeatureError} (E017): the flag names a PostgreSQL
1628
+ * plan-cache guarantee, and an engine with no such cache cannot make it.
1629
+ * The same flag left unset (or `false`) is accepted everywhere.
1630
+ *
1631
+ * THE ONE COMBINATION THAT IS REFUSED RATHER THAN HONOURED. A client-level
1632
+ * `planCacheMode: 'force_generic_plan'` DEFEATS this option, and that was
1633
+ * MEASURED rather than reasoned about: on PostgreSQL 16.14, five executions
1634
+ * of one unnamed statement read 19,107 buffers with that setting in force and
1635
+ * 55 buffers with the same connection set back to `auto`, against 19,107 for
1636
+ * the named statement. So the setting governs the unnamed statement too, and
1637
+ * withholding the name buys nothing against it. Accepting the flag there
1638
+ * would report a guarantee the very next execution breaks, so the
1639
+ * contradiction throws {@link ValidationError} (E003) naming both settings.
1640
+ * Turbine can only see the setting IT applied: a `plan_cache_mode` installed
1641
+ * by the caller's own `SET`, by `ALTER ROLE`, or by a pooler is invisible
1642
+ * here and is not refused.
1643
+ */
1644
+ preparedNameFor(args, name) {
1645
+ if (args?.forceCustomPlan !== true)
1646
+ return name;
1647
+ if (this.dialect.supportsPlanCacheMode !== true) {
1648
+ throw new UnsupportedFeatureError('The forceCustomPlan query option', this.dialect.name, 'Forcing a per-query custom plan means keeping the statement out of the PostgreSQL plan cache, and this ' +
1649
+ 'engine has no such cache to keep it out of. Remove the option, or set it only on PostgreSQL queries.');
1650
+ }
1651
+ if (this.options?.planCacheMode === 'force_generic_plan') {
1652
+ throw new ValidationError('[turbine] forceCustomPlan: true cannot be honoured on a client configured with ' +
1653
+ "planCacheMode: 'force_generic_plan'. That setting is a connection parameter and it governs UNNAMED " +
1654
+ 'statements as well as named ones, so the mechanism this option uses (withholding the ' +
1655
+ 'prepared-statement name, so the driver re-parses the statement on every execution and it is planned ' +
1656
+ 'with its real values) is ' +
1657
+ 'overridden by it and the query would be planned generically anyway. Leave the client on the default ' +
1658
+ '(`planCacheMode` unset, or `auto`) and force the custom plan per query: that is the combination that ' +
1659
+ 'expresses "custom here, auto there".');
1660
+ }
1661
+ return undefined;
1662
+ }
1596
1663
  /**
1597
1664
  * Execute a pool.query with an optional timeout.
1598
1665
  * If timeout is set, races the query against a timer and rejects on expiry.
@@ -1741,7 +1808,7 @@ export class QueryInterface {
1741
1808
  }
1742
1809
  }
1743
1810
  const deferred = this.buildFindUnique(args);
1744
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
1811
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
1745
1812
  return deferred.transform(result);
1746
1813
  });
1747
1814
  }
@@ -1762,11 +1829,11 @@ export class QueryInterface {
1762
1829
  const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, args.includePii));
1763
1830
  const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
1764
1831
  const deferred = this.buildFindUnique(baseArgs);
1765
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
1832
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
1766
1833
  const entity = deferred.transform(result);
1767
1834
  if (!entity)
1768
1835
  return null;
1769
- await loadRelationsBatched(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii === true), [entity], withClause, args.timeout);
1836
+ await loadRelationsBatched(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii === true, args.forceCustomPlan === true), [entity], withClause, args.timeout);
1770
1837
  stripFields([entity], proj.strip);
1771
1838
  return entity;
1772
1839
  }
@@ -1937,7 +2004,7 @@ export class QueryInterface {
1937
2004
  }
1938
2005
  }
1939
2006
  const deferred = this.buildFindMany(args);
1940
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
2007
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, this.preparedNameFor(args, deferred.preparedName));
1941
2008
  return deferred.transform(result);
1942
2009
  });
1943
2010
  }
@@ -2388,7 +2455,13 @@ export class QueryInterface {
2388
2455
  limit: batchSize + 1,
2389
2456
  });
2390
2457
  this.currentAction = 'findManyStream';
2391
- const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout);
2458
+ // Streaming is ALREADY immune to the generic-plan cliff: the speculative
2459
+ // fetch has never passed a prepared name, and the cursor path runs through
2460
+ // DECLARE, so neither statement enters the plan cache. `preparedNameFor` is
2461
+ // still called with no name so that `forceCustomPlan: true` is VALIDATED on
2462
+ // an engine that cannot honour it here either, rather than being quietly
2463
+ // satisfied by an accident of this code path.
2464
+ const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout, this.preparedNameFor(args, undefined));
2392
2465
  if (speculativeResult.rows.length <= batchSize) {
2393
2466
  // Small drain, yield all rows and return, no cursor needed
2394
2467
  for (const row of speculativeResult.rows) {
@@ -2451,7 +2524,7 @@ export class QueryInterface {
2451
2524
  }
2452
2525
  }
2453
2526
  const deferred = this.buildFindFirst(args);
2454
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
2527
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, this.preparedNameFor(args, deferred.preparedName));
2455
2528
  return deferred.transform(result);
2456
2529
  });
2457
2530
  }
@@ -2476,7 +2549,7 @@ export class QueryInterface {
2476
2549
  async findFirstOrThrow(args) {
2477
2550
  return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
2478
2551
  const deferred = this.buildFindFirstOrThrow(args);
2479
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
2552
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, this.preparedNameFor(args, deferred.preparedName));
2480
2553
  return deferred.transform(result);
2481
2554
  });
2482
2555
  }
@@ -2506,7 +2579,7 @@ export class QueryInterface {
2506
2579
  async findUniqueOrThrow(args) {
2507
2580
  return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
2508
2581
  const deferred = this.buildFindUniqueOrThrow(args);
2509
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
2582
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
2510
2583
  return deferred.transform(result);
2511
2584
  });
2512
2585
  }
@@ -2691,7 +2764,7 @@ export class QueryInterface {
2691
2764
  async count(args) {
2692
2765
  return this.executeWithMiddleware('count', (args ?? {}), async () => {
2693
2766
  const deferred = this.buildCount(args);
2694
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
2767
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, this.preparedNameFor(args, deferred.preparedName));
2695
2768
  return deferred.transform(result);
2696
2769
  });
2697
2770
  }
@@ -2735,7 +2808,7 @@ export class QueryInterface {
2735
2808
  async groupBy(args) {
2736
2809
  return this.executeWithMiddleware('groupBy', args, async () => {
2737
2810
  const deferred = this.buildGroupBy(args);
2738
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
2811
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
2739
2812
  return deferred.transform(result);
2740
2813
  });
2741
2814
  }
@@ -2754,7 +2827,7 @@ export class QueryInterface {
2754
2827
  async aggregate(args) {
2755
2828
  return this.executeWithMiddleware('aggregate', args, async () => {
2756
2829
  const deferred = this.buildAggregate(args);
2757
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
2830
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
2758
2831
  return deferred.transform(result);
2759
2832
  });
2760
2833
  }
@@ -127,6 +127,15 @@ export interface QueryInterfaceOptions {
127
127
  * (serverless drivers may not support named statements).
128
128
  */
129
129
  preparedStatements?: boolean;
130
+ /**
131
+ * The client-level `plan_cache_mode` this interface's connections were opened
132
+ * with, forwarded from `TurbineConfig.planCacheMode` purely so the per-query
133
+ * `forceCustomPlan` option can REFUSE the one combination it cannot honour
134
+ * (`'force_generic_plan'`, see `preparedNameFor` in builder.ts). Nothing else
135
+ * reads it, and it is never emitted into SQL: the setting itself travels as a
136
+ * connection parameter, set by client.ts when it opens the pool.
137
+ */
138
+ planCacheMode?: 'auto' | 'force_custom_plan' | 'force_generic_plan';
130
139
  /**
131
140
  * Enable the SQL template cache. When true, repeated queries with the
132
141
  * same shape (same keys, operators, relations, different values) reuse
@@ -465,6 +465,8 @@ export interface FindUniqueArgs<T, R extends object = {}, W extends TypedWithCla
465
465
  skipGlobalFilters?: SkipGlobalFilters;
466
466
  /** Include PII-tagged columns in the result. See {@link FindManyArgs.includePii}. */
467
467
  includePii?: boolean;
468
+ /** Plan this query with its real parameter values. See {@link FindManyArgs.forceCustomPlan}. */
469
+ forceCustomPlan?: boolean;
468
470
  }
469
471
  export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined> {
470
472
  /** Row filter. Keys are checked against `T` and `R` (see {@link WhereClause}). */
@@ -518,6 +520,138 @@ export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClaus
518
520
  * always allowed regardless of this flag (the reference is explicit).
519
521
  */
520
522
  includePii?: boolean;
523
+ /**
524
+ * Plan THIS query with its actual parameter values, every execution.
525
+ * PostgreSQL only (see the refusal below).
526
+ *
527
+ * WHY IT EXISTS. Turbine executes through a NAMED prepared statement, which
528
+ * enters the backend's plan cache, and from the sixth execution onward
529
+ * PostgreSQL may replace the per-execution plan with a single GENERIC plan
530
+ * (whenever the generic plan's estimated cost is not worse than the average
531
+ * custom cost). A generic plan substitutes a default for every value it
532
+ * cannot see: an unknown equality gets `rows / n_distinct`, an unknown range
533
+ * gets a third of the table, an unknown LIKE gets 0.5%, and an unknown LIMIT
534
+ * gets 10% of its child node's estimate. When one of those defaults lands on
535
+ * the other side of a plan boundary from the real value, the plan SHAPE
536
+ * flips, and the flip can be catastrophic. It fails in both directions: a
537
+ * default above the truth and a default below it are both capable of it, so
538
+ * "the tenant with few rows" is not the predictor.
539
+ *
540
+ * WHAT IT DOES, stated as the mechanism really is rather than as the tempting
541
+ * one-liner. `true` sends this one statement UNNAMED. It is NOT true that
542
+ * PostgreSQL treats an unnamed statement as a one-shot plan that never enters
543
+ * the plan cache: `exec_parse_message` builds a `CachedPlanSource` and calls
544
+ * `SaveCachedPlan` on it for the unnamed statement too (it is kept in
545
+ * `unnamed_stmt_psrc`). The reason the option works is one level up, in the
546
+ * DRIVER: node-postgres only skips Parse for a statement it has already
547
+ * parsed BY NAME (`Query.hasBeenParsed` is `this.name && ...`), so an unnamed
548
+ * statement is re-Parsed on every execution. Each Parse replaces the unnamed
549
+ * entry with a fresh `CachedPlanSource` whose custom-plan counter starts at
550
+ * zero, so the five-execution threshold that precedes promotion is never
551
+ * reached and every execution is planned with the real parameter values.
552
+ *
553
+ * That distinction matters in practice: the guarantee is a property of the
554
+ * driver's behaviour plus the backend's promotion rule, not a special
555
+ * one-shot plan class, which is exactly why a connection pinned to
556
+ * `force_generic_plan` still overrides it (see PRECEDENCE below).
557
+ *
558
+ * Nothing is set on the session, no `SET` is emitted, no transaction is
559
+ * opened, and no extra round trip is added.
560
+ *
561
+ * WHAT IT CANNOT DO, and why this is a boolean rather than the client-level
562
+ * three-value {@link TurbineConfig.planCacheMode}: the generic direction is
563
+ * NOT expressible per query. `force_generic_plan` is a property of a CACHED
564
+ * plan, and the only per-query lever here is keeping the statement out of the
565
+ * cache, which can only ever mean "custom". A per-query
566
+ * `planCacheMode: 'force_generic_plan'` would be a promise this mechanism
567
+ * cannot keep, so the option is named for the one thing it does.
568
+ *
569
+ * PRECEDENCE over the client-level `planCacheMode`, which is a connection
570
+ * parameter and cannot be unset for one query. Stated exactly, because one of
571
+ * these four is the opposite of what the mechanism suggests:
572
+ * - Client on the default (`planCacheMode` unset) or `'auto'`: this is what
573
+ * the option is FOR. `auto` is the only mode in which promotion to a
574
+ * generic plan happens, and the re-Parse described above resets the
575
+ * counter that promotion depends on before it can ever be reached.
576
+ * - Client on `'force_custom_plan'`: redundant and harmless, both routes
577
+ * plan with the real values.
578
+ * - Client on `'force_generic_plan'`: REFUSED, with `ValidationError`
579
+ * (E003). It does NOT win. That setting governs the unnamed statement's
580
+ * cached plan source as well as a named one (measured on PostgreSQL 16.14: five executions
581
+ * of the same unnamed statement read 19,107 buffers under the setting and
582
+ * 55 with the connection back on `auto`), so withholding the name buys
583
+ * nothing against it and the query would be planned generically anyway.
584
+ * Rather than report a guarantee it cannot keep, Turbine refuses the
585
+ * combination and says which of the two settings to change. Only the
586
+ * setting TURBINE applied is visible: a `plan_cache_mode` installed by a
587
+ * caller's `SET`, `ALTER ROLE`, or a pooler cannot be seen or refused.
588
+ * - `false` / omitted changes nothing. It does not opt back out of a
589
+ * client-level setting, it simply leaves that setting in charge.
590
+ * - With the client-level `preparedStatements: false`, every statement is
591
+ * already unnamed, so this option is a no-op for plan choice.
592
+ *
593
+ * COST, and it has two halves.
594
+ *
595
+ * The first is planning. The statement is parsed and planned on every
596
+ * execution instead of once. On a flat read that is in the noise (an unnamed
597
+ * statement also skips the extra Parse/Describe round trip a named one needs
598
+ * on its first execution, so it can even come out ahead). It grows with the
599
+ * size of the statement: a deep `with` tree is a much larger plan, and
600
+ * re-planning it per execution is a measurable share of a fast query's
601
+ * latency. Turn it on where a plan flip is the risk, not everywhere.
602
+ *
603
+ * The second is the one nobody expects: A CUSTOM PLAN IS NOT ALWAYS THE
604
+ * BETTER PLAN. There are real shapes where the generic plan's ignorance is
605
+ * what saves it, and forcing a custom plan forecloses that. Reproduced on
606
+ * PostgreSQL 16.14, `synchronize_seqscans` off, parallel workers off:
607
+ *
608
+ * ```sql
609
+ * CREATE TABLE ev (id bigserial PRIMARY KEY, tenant_id int NOT NULL, pad text);
610
+ * -- 320,000 rows over 800 tenants, inserted in RANDOM physical order
611
+ * INSERT INTO ev (tenant_id, pad)
612
+ * SELECT t, repeat('x', 60)
613
+ * FROM (SELECT ((g % 800) + 1) AS t FROM generate_series(1, 320000) g
614
+ * ORDER BY random()) s
615
+ * WHERE t <> 400;
616
+ * -- then tenant 400's 80,000 rows LAST, so they all sit past everything above
617
+ * INSERT INTO ev (tenant_id, pad)
618
+ * SELECT 400, repeat('x', 60) FROM generate_series(1, 80000) g;
619
+ * CREATE INDEX ev_tenant_idx ON ev (tenant_id);
620
+ * ANALYZE ev; -- relpages 5334, n_distinct 800, correlation 0.004
621
+ *
622
+ * PREPARE q(int, int) AS SELECT * FROM ev WHERE tenant_id = $1 LIMIT $2;
623
+ * -- force_custom_plan : Seq Scan, Buffers: shared hit=4262
624
+ * -- force_generic_plan: Bitmap Heap Scan, Buffers: shared hit=71
625
+ * ```
626
+ *
627
+ * 60x, with no `ORDER BY` anywhere. The custom planner knows tenant 400 is
628
+ * 20% of the table, so with `LIMIT 20` it prices a sequential scan as
629
+ * essentially free on the assumption it will stop almost immediately. It is
630
+ * right about how MANY rows match and wrong about WHERE they are: they are
631
+ * all at the end of the heap, so it reads 319,600 non-matching rows first.
632
+ * The generic plan, unable to see the value, estimates 500 rows, takes the
633
+ * bitmap path, and touches one heap block. Re-insert the identical rows in
634
+ * random physical order and the effect vanishes and reverses (custom 2
635
+ * buffers, generic 66): physical CLUSTERING is the variable, not selectivity.
636
+ *
637
+ * Read that carefully before treating it as an argument against this option.
638
+ * On that shape `plan_cache_mode = auto` never promotes (the generic plan's
639
+ * ESTIMATED cost is far higher than the average custom cost, which is exactly
640
+ * the condition under which `auto` refuses), so the default already produces
641
+ * the 4,262-buffer plan and `forceCustomPlan` costs nothing against it. The
642
+ * honest statement is that a generic plan is 60x better there than either the
643
+ * default or this option, and only an explicit client-level
644
+ * `planCacheMode: 'force_generic_plan'` can reach it. The reason to scope
645
+ * this option per query is still real: it is a targeted remedy for a measured
646
+ * flip, not a setting to turn on globally.
647
+ *
648
+ * NON-POSTGRESQL ENGINES. `true` throws {@link UnsupportedFeatureError}
649
+ * (E017), the same refusal the client-level option gives: an engine with no
650
+ * PostgreSQL plan cache has no cached generic plan to keep this query out of,
651
+ * so silently accepting the flag would report a guarantee that was never
652
+ * made. Omitting it (or `false`) is accepted everywhere.
653
+ */
654
+ forceCustomPlan?: boolean;
521
655
  }
522
656
  export interface FindManyStreamArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined> extends FindManyArgs<T, R, W, S, O> {
523
657
  /**
@@ -716,6 +850,8 @@ export interface CountArgs<T, R extends object = {}> {
716
850
  timeout?: number;
717
851
  /** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
718
852
  skipGlobalFilters?: SkipGlobalFilters;
853
+ /** Plan this query with its real parameter values. See {@link FindManyArgs.forceCustomPlan}. */
854
+ forceCustomPlan?: boolean;
719
855
  }
720
856
  /**
721
857
  * Comparison operators usable inside a `having` aggregate filter. A bare value
@@ -955,6 +1091,8 @@ export interface GroupByArgs<T, R extends object = {}> {
955
1091
  timeout?: number;
956
1092
  /** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
957
1093
  skipGlobalFilters?: SkipGlobalFilters;
1094
+ /** Plan this query with its real parameter values. See {@link FindManyArgs.forceCustomPlan}. */
1095
+ forceCustomPlan?: boolean;
958
1096
  }
959
1097
  /** The by-key union of a groupBy args type (array element type of `by`). */
960
1098
  type GroupByKeys<A> = A extends {
@@ -1055,6 +1193,8 @@ export interface AggregateArgs<T, R extends object = {}> {
1055
1193
  timeout?: number;
1056
1194
  /** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
1057
1195
  skipGlobalFilters?: SkipGlobalFilters;
1196
+ /** Plan this query with its real parameter values. See {@link FindManyArgs.forceCustomPlan}. */
1197
+ forceCustomPlan?: boolean;
1058
1198
  }
1059
1199
  /** Result type for aggregate queries */
1060
1200
  export interface AggregateResult<T> {
@@ -319,6 +319,20 @@ export declare function isDefaultTextParser(oid: number, parser: (text: string)
319
319
  * the origin of, and the resulting bug is order-dependent: which reading wins
320
320
  * depends on module evaluation order, which lazy route imports make unstable
321
321
  * between requests. So say it out loud, once.
322
+ *
323
+ * NOT DEV-ONLY. It used to go quiet under `NODE_ENV=production`, along with
324
+ * every other dev warning, and that was the wrong rule for THIS one, for the
325
+ * same reason the temporal-infinity warning (builder.ts `warnTemporalInfinity`)
326
+ * is not dev-only either. A parser overwrite is ORDER-DEPENDENT: which module
327
+ * calls `setTypeParser` last decides the reading, and evaluation order is
328
+ * exactly what differs between a dev process (eager imports, one route
329
+ * exercised at a time) and a production one (bundled or lazily imported routes,
330
+ * warmed in whatever order traffic arrives). So a process can be clean in dev
331
+ * and wrong in production purely from import order, which makes production the
332
+ * case that matters MOST, and it was the case that was silent. The cost is
333
+ * bounded to the point of irrelevance: once per OID per process, at client
334
+ * construction, and only when somebody else's non-default parser is actually
335
+ * being replaced.
322
336
  */
323
337
  export declare function warnParserOverwrite(oid: number, typeName: string): void;
324
338
  /**
@@ -565,10 +565,22 @@ export function isDefaultTextParser(oid, parser) {
565
565
  * the origin of, and the resulting bug is order-dependent: which reading wins
566
566
  * depends on module evaluation order, which lazy route imports make unstable
567
567
  * between requests. So say it out loud, once.
568
+ *
569
+ * NOT DEV-ONLY. It used to go quiet under `NODE_ENV=production`, along with
570
+ * every other dev warning, and that was the wrong rule for THIS one, for the
571
+ * same reason the temporal-infinity warning (builder.ts `warnTemporalInfinity`)
572
+ * is not dev-only either. A parser overwrite is ORDER-DEPENDENT: which module
573
+ * calls `setTypeParser` last decides the reading, and evaluation order is
574
+ * exactly what differs between a dev process (eager imports, one route
575
+ * exercised at a time) and a production one (bundled or lazily imported routes,
576
+ * warmed in whatever order traffic arrives). So a process can be clean in dev
577
+ * and wrong in production purely from import order, which makes production the
578
+ * case that matters MOST, and it was the case that was silent. The cost is
579
+ * bounded to the point of irrelevance: once per OID per process, at client
580
+ * construction, and only when somebody else's non-default parser is actually
581
+ * being replaced.
568
582
  */
569
583
  export function warnParserOverwrite(oid, typeName) {
570
- if (process.env.NODE_ENV === 'production')
571
- return;
572
584
  const getParser = pg.types.getTypeParser;
573
585
  const current = getParser(oid, 'text');
574
586
  // Turbine's own earlier registration is not a third party's expectation.
@@ -588,7 +600,9 @@ export function warnParserOverwrite(oid, typeName) {
588
600
  'process, and Turbine is replacing it. `pg.types.setTypeParser` is process-global and takes effect ' +
589
601
  'immediately for EVERY pg.Pool in the process, including pools that already exist and are already ' +
590
602
  'querying, so whatever set that parser will now read this column differently. If yours should win, ' +
591
- `register it AFTER constructing the client.${remedy} Dev-only: silent under \`NODE_ENV=production\`.`);
603
+ `register it AFTER constructing the client.${remedy} This warning fires under \`NODE_ENV=production\` ` +
604
+ 'too: which parser wins depends on module evaluation order, so a process can be clean in dev and wrong ' +
605
+ 'in production from import order alone.');
592
606
  }
593
607
  /**
594
608
  * Register the UTC readings of the four zone-less temporal OIDs on the pg
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "Postgres-native TypeScript ORM, runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "//exports": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",