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.
- package/README.md +1 -1
- package/dist/cjs/cli/index.d.ts +3 -1
- package/dist/cjs/cli/index.js +117 -7
- package/dist/cjs/client.d.ts +16 -1
- package/dist/cjs/client.js +4 -0
- package/dist/cjs/dialect.d.ts +9 -0
- package/dist/cjs/index-stats.d.ts +46 -0
- package/dist/cjs/index-stats.js +42 -1
- package/dist/cjs/plan-divergence.d.ts +244 -0
- package/dist/cjs/plan-divergence.js +444 -0
- package/dist/cjs/powql.d.ts +11 -0
- package/dist/cjs/powql.js +22 -0
- package/dist/cjs/query/builder.d.ts +45 -0
- package/dist/cjs/query/builder.js +90 -17
- package/dist/cjs/query/deferred.d.ts +9 -0
- package/dist/cjs/query/types.d.ts +140 -0
- package/dist/cjs/query/utils.d.ts +14 -0
- package/dist/cjs/query/utils.js +17 -3
- package/dist/cli/index.d.ts +3 -1
- package/dist/cli/index.js +117 -7
- package/dist/client.d.ts +16 -1
- package/dist/client.js +4 -0
- package/dist/dialect.d.ts +9 -0
- package/dist/index-stats.d.ts +46 -0
- package/dist/index-stats.js +42 -1
- package/dist/plan-divergence.d.ts +244 -0
- package/dist/plan-divergence.js +438 -0
- package/dist/powql.d.ts +11 -0
- package/dist/powql.js +22 -0
- package/dist/query/builder.d.ts +45 -0
- package/dist/query/builder.js +90 -17
- package/dist/query/deferred.d.ts +9 -0
- package/dist/query/types.d.ts +140 -0
- package/dist/query/utils.d.ts +14 -0
- package/dist/query/utils.js +17 -3
- package/package.json +1 -1
|
@@ -1388,11 +1388,11 @@ class QueryInterface {
|
|
|
1388
1388
|
const deferred = single
|
|
1389
1389
|
? this.buildFindUnique(baseArgs)
|
|
1390
1390
|
: this.buildFindMany(baseArgs);
|
|
1391
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
1391
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
1392
1392
|
const rows = deferred.transform(result);
|
|
1393
1393
|
const entities = single ? (rows ? [rows] : []) : rows;
|
|
1394
1394
|
if (entities.length > 0) {
|
|
1395
|
-
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip, args.includePii === true), entities, batchedWith, args.timeout);
|
|
1395
|
+
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip, args.includePii === true, args.forceCustomPlan === true), entities, batchedWith, args.timeout);
|
|
1396
1396
|
}
|
|
1397
1397
|
(0, batched_loader_js_1.stripFields)(entities, proj.strip);
|
|
1398
1398
|
return single ? (entities[0] ?? null) : entities;
|
|
@@ -1408,7 +1408,7 @@ class QueryInterface {
|
|
|
1408
1408
|
* and unlimited-warnings silenced, a relation load must fetch every matching
|
|
1409
1409
|
* child, and the per-relation `limit` is applied client-side by the loader.
|
|
1410
1410
|
*/
|
|
1411
|
-
batchedContext(timeout, skip, includePii) {
|
|
1411
|
+
batchedContext(timeout, skip, includePii, forceCustomPlan = false) {
|
|
1412
1412
|
const childOptions = {
|
|
1413
1413
|
...this.options,
|
|
1414
1414
|
defaultLimit: undefined,
|
|
@@ -1418,7 +1418,11 @@ class QueryInterface {
|
|
|
1418
1418
|
parentMeta: this.tableMeta,
|
|
1419
1419
|
schema: this.schema,
|
|
1420
1420
|
makeChild: (table) => new QueryInterface(this.pool, table, this.schema, [], childOptions),
|
|
1421
|
-
|
|
1421
|
+
// The per-query `forceCustomPlan` opt-in covers the relation follow-ups
|
|
1422
|
+
// too: a batched load re-issues the SAME tenant-shaped predicate one
|
|
1423
|
+
// level down, so leaving those named would keep exactly the plan-cache
|
|
1424
|
+
// exposure the caller asked to be rid of.
|
|
1425
|
+
exec: (sql, params, preparedName) => this.queryWithTimeout(sql, params, timeout, this.preparedNameFor({ forceCustomPlan }, preparedName)),
|
|
1422
1426
|
quote: (name) => this.q(name),
|
|
1423
1427
|
buildInClause: (expr, paramRef, negated) => this.inClause(expr, paramRef, negated),
|
|
1424
1428
|
inClauseParam: (values) => this.inParam(values),
|
|
@@ -1470,10 +1474,10 @@ class QueryInterface {
|
|
|
1470
1474
|
const { baseArgs, strip } = this.prepareBatchedBase(args, withClause);
|
|
1471
1475
|
// baseArgs.with is always undefined here; the cast just bridges the R generic.
|
|
1472
1476
|
const deferred = this.buildFindMany(baseArgs);
|
|
1473
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
1477
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
1474
1478
|
const entities = deferred.transform(result);
|
|
1475
1479
|
if (entities.length > 0) {
|
|
1476
|
-
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip, args.includePii === true), entities, withClause, args.timeout);
|
|
1480
|
+
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip, args.includePii === true, args.forceCustomPlan === true), entities, withClause, args.timeout);
|
|
1477
1481
|
}
|
|
1478
1482
|
(0, batched_loader_js_1.stripFields)(entities, strip);
|
|
1479
1483
|
return entities;
|
|
@@ -1629,6 +1633,69 @@ class QueryInterface {
|
|
|
1629
1633
|
// Listener errors must never crash a query
|
|
1630
1634
|
}
|
|
1631
1635
|
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Resolve the prepared-statement name a read should execute under, honouring
|
|
1638
|
+
* the per-query {@link FindManyArgs.forceCustomPlan} opt-in.
|
|
1639
|
+
*
|
|
1640
|
+
* `forceCustomPlan: true` returns `undefined`, which sends the statement
|
|
1641
|
+
* UNNAMED. The mechanism is NOT "PostgreSQL treats an unnamed statement as a
|
|
1642
|
+
* one-shot plan that never enters the plan cache": the backend builds and
|
|
1643
|
+
* saves a `CachedPlanSource` for the unnamed statement too. It works because
|
|
1644
|
+
* node-postgres only skips Parse for a statement it has already parsed BY
|
|
1645
|
+
* NAME (`Query.hasBeenParsed` is `this.name && connection.parsedStatements[this.name]`),
|
|
1646
|
+
* so an unnamed statement is re-Parsed on every execution, each Parse
|
|
1647
|
+
* replaces the unnamed cached plan source with a fresh one whose custom-plan
|
|
1648
|
+
* counter is zero, and the five-execution threshold that precedes promotion
|
|
1649
|
+
* is never reached. Every execution is therefore planned with the real
|
|
1650
|
+
* parameter values.
|
|
1651
|
+
*
|
|
1652
|
+
* No GUC is set, no `SET LOCAL` is emitted, no transaction is opened, and no
|
|
1653
|
+
* extra round trip is added, which is exactly why the opt-in can be per query
|
|
1654
|
+
* while the client-level `planCacheMode` (a connection parameter) cannot be.
|
|
1655
|
+
*
|
|
1656
|
+
* The refusal is deliberately here, at the one seam every read execution
|
|
1657
|
+
* passes through, rather than in each build method: the flag changes NOTHING
|
|
1658
|
+
* about the SQL text, so a build-time check would have had to be repeated in
|
|
1659
|
+
* every builder and could still be bypassed by a hand-executed
|
|
1660
|
+
* `DeferredQuery`.
|
|
1661
|
+
*
|
|
1662
|
+
* Engines whose dialect does not report {@link Dialect.supportsPlanCacheMode}
|
|
1663
|
+
* throw {@link UnsupportedFeatureError} (E017): the flag names a PostgreSQL
|
|
1664
|
+
* plan-cache guarantee, and an engine with no such cache cannot make it.
|
|
1665
|
+
* The same flag left unset (or `false`) is accepted everywhere.
|
|
1666
|
+
*
|
|
1667
|
+
* THE ONE COMBINATION THAT IS REFUSED RATHER THAN HONOURED. A client-level
|
|
1668
|
+
* `planCacheMode: 'force_generic_plan'` DEFEATS this option, and that was
|
|
1669
|
+
* MEASURED rather than reasoned about: on PostgreSQL 16.14, five executions
|
|
1670
|
+
* of one unnamed statement read 19,107 buffers with that setting in force and
|
|
1671
|
+
* 55 buffers with the same connection set back to `auto`, against 19,107 for
|
|
1672
|
+
* the named statement. So the setting governs the unnamed statement too, and
|
|
1673
|
+
* withholding the name buys nothing against it. Accepting the flag there
|
|
1674
|
+
* would report a guarantee the very next execution breaks, so the
|
|
1675
|
+
* contradiction throws {@link ValidationError} (E003) naming both settings.
|
|
1676
|
+
* Turbine can only see the setting IT applied: a `plan_cache_mode` installed
|
|
1677
|
+
* by the caller's own `SET`, by `ALTER ROLE`, or by a pooler is invisible
|
|
1678
|
+
* here and is not refused.
|
|
1679
|
+
*/
|
|
1680
|
+
preparedNameFor(args, name) {
|
|
1681
|
+
if (args?.forceCustomPlan !== true)
|
|
1682
|
+
return name;
|
|
1683
|
+
if (this.dialect.supportsPlanCacheMode !== true) {
|
|
1684
|
+
throw new errors_js_1.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 ' +
|
|
1685
|
+
'engine has no such cache to keep it out of. Remove the option, or set it only on PostgreSQL queries.');
|
|
1686
|
+
}
|
|
1687
|
+
if (this.options?.planCacheMode === 'force_generic_plan') {
|
|
1688
|
+
throw new errors_js_1.ValidationError('[turbine] forceCustomPlan: true cannot be honoured on a client configured with ' +
|
|
1689
|
+
"planCacheMode: 'force_generic_plan'. That setting is a connection parameter and it governs UNNAMED " +
|
|
1690
|
+
'statements as well as named ones, so the mechanism this option uses (withholding the ' +
|
|
1691
|
+
'prepared-statement name, so the driver re-parses the statement on every execution and it is planned ' +
|
|
1692
|
+
'with its real values) is ' +
|
|
1693
|
+
'overridden by it and the query would be planned generically anyway. Leave the client on the default ' +
|
|
1694
|
+
'(`planCacheMode` unset, or `auto`) and force the custom plan per query: that is the combination that ' +
|
|
1695
|
+
'expresses "custom here, auto there".');
|
|
1696
|
+
}
|
|
1697
|
+
return undefined;
|
|
1698
|
+
}
|
|
1632
1699
|
/**
|
|
1633
1700
|
* Execute a pool.query with an optional timeout.
|
|
1634
1701
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
@@ -1777,7 +1844,7 @@ class QueryInterface {
|
|
|
1777
1844
|
}
|
|
1778
1845
|
}
|
|
1779
1846
|
const deferred = this.buildFindUnique(args);
|
|
1780
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
1847
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
1781
1848
|
return deferred.transform(result);
|
|
1782
1849
|
});
|
|
1783
1850
|
}
|
|
@@ -1798,11 +1865,11 @@ class QueryInterface {
|
|
|
1798
1865
|
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, args.includePii));
|
|
1799
1866
|
const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
|
|
1800
1867
|
const deferred = this.buildFindUnique(baseArgs);
|
|
1801
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
1868
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
1802
1869
|
const entity = deferred.transform(result);
|
|
1803
1870
|
if (!entity)
|
|
1804
1871
|
return null;
|
|
1805
|
-
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii === true), [entity], withClause, args.timeout);
|
|
1872
|
+
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii === true, args.forceCustomPlan === true), [entity], withClause, args.timeout);
|
|
1806
1873
|
(0, batched_loader_js_1.stripFields)([entity], proj.strip);
|
|
1807
1874
|
return entity;
|
|
1808
1875
|
}
|
|
@@ -1973,7 +2040,7 @@ class QueryInterface {
|
|
|
1973
2040
|
}
|
|
1974
2041
|
}
|
|
1975
2042
|
const deferred = this.buildFindMany(args);
|
|
1976
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
2043
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
1977
2044
|
return deferred.transform(result);
|
|
1978
2045
|
});
|
|
1979
2046
|
}
|
|
@@ -2424,7 +2491,13 @@ class QueryInterface {
|
|
|
2424
2491
|
limit: batchSize + 1,
|
|
2425
2492
|
});
|
|
2426
2493
|
this.currentAction = 'findManyStream';
|
|
2427
|
-
|
|
2494
|
+
// Streaming is ALREADY immune to the generic-plan cliff: the speculative
|
|
2495
|
+
// fetch has never passed a prepared name, and the cursor path runs through
|
|
2496
|
+
// DECLARE, so neither statement enters the plan cache. `preparedNameFor` is
|
|
2497
|
+
// still called with no name so that `forceCustomPlan: true` is VALIDATED on
|
|
2498
|
+
// an engine that cannot honour it here either, rather than being quietly
|
|
2499
|
+
// satisfied by an accident of this code path.
|
|
2500
|
+
const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout, this.preparedNameFor(args, undefined));
|
|
2428
2501
|
if (speculativeResult.rows.length <= batchSize) {
|
|
2429
2502
|
// Small drain, yield all rows and return, no cursor needed
|
|
2430
2503
|
for (const row of speculativeResult.rows) {
|
|
@@ -2487,7 +2560,7 @@ class QueryInterface {
|
|
|
2487
2560
|
}
|
|
2488
2561
|
}
|
|
2489
2562
|
const deferred = this.buildFindFirst(args);
|
|
2490
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
2563
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
2491
2564
|
return deferred.transform(result);
|
|
2492
2565
|
});
|
|
2493
2566
|
}
|
|
@@ -2512,7 +2585,7 @@ class QueryInterface {
|
|
|
2512
2585
|
async findFirstOrThrow(args) {
|
|
2513
2586
|
return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
|
|
2514
2587
|
const deferred = this.buildFindFirstOrThrow(args);
|
|
2515
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
2588
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
2516
2589
|
return deferred.transform(result);
|
|
2517
2590
|
});
|
|
2518
2591
|
}
|
|
@@ -2542,7 +2615,7 @@ class QueryInterface {
|
|
|
2542
2615
|
async findUniqueOrThrow(args) {
|
|
2543
2616
|
return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
|
|
2544
2617
|
const deferred = this.buildFindUniqueOrThrow(args);
|
|
2545
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
2618
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
2546
2619
|
return deferred.transform(result);
|
|
2547
2620
|
});
|
|
2548
2621
|
}
|
|
@@ -2727,7 +2800,7 @@ class QueryInterface {
|
|
|
2727
2800
|
async count(args) {
|
|
2728
2801
|
return this.executeWithMiddleware('count', (args ?? {}), async () => {
|
|
2729
2802
|
const deferred = this.buildCount(args);
|
|
2730
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
2803
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
2731
2804
|
return deferred.transform(result);
|
|
2732
2805
|
});
|
|
2733
2806
|
}
|
|
@@ -2771,7 +2844,7 @@ class QueryInterface {
|
|
|
2771
2844
|
async groupBy(args) {
|
|
2772
2845
|
return this.executeWithMiddleware('groupBy', args, async () => {
|
|
2773
2846
|
const deferred = this.buildGroupBy(args);
|
|
2774
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
2847
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
2775
2848
|
return deferred.transform(result);
|
|
2776
2849
|
});
|
|
2777
2850
|
}
|
|
@@ -2790,7 +2863,7 @@ class QueryInterface {
|
|
|
2790
2863
|
async aggregate(args) {
|
|
2791
2864
|
return this.executeWithMiddleware('aggregate', args, async () => {
|
|
2792
2865
|
const deferred = this.buildAggregate(args);
|
|
2793
|
-
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
2866
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
2794
2867
|
return deferred.transform(result);
|
|
2795
2868
|
});
|
|
2796
2869
|
}
|
|
@@ -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
|
/**
|
package/dist/cjs/query/utils.js
CHANGED
|
@@ -598,10 +598,22 @@ function isDefaultTextParser(oid, parser) {
|
|
|
598
598
|
* the origin of, and the resulting bug is order-dependent: which reading wins
|
|
599
599
|
* depends on module evaluation order, which lazy route imports make unstable
|
|
600
600
|
* between requests. So say it out loud, once.
|
|
601
|
+
*
|
|
602
|
+
* NOT DEV-ONLY. It used to go quiet under `NODE_ENV=production`, along with
|
|
603
|
+
* every other dev warning, and that was the wrong rule for THIS one, for the
|
|
604
|
+
* same reason the temporal-infinity warning (builder.ts `warnTemporalInfinity`)
|
|
605
|
+
* is not dev-only either. A parser overwrite is ORDER-DEPENDENT: which module
|
|
606
|
+
* calls `setTypeParser` last decides the reading, and evaluation order is
|
|
607
|
+
* exactly what differs between a dev process (eager imports, one route
|
|
608
|
+
* exercised at a time) and a production one (bundled or lazily imported routes,
|
|
609
|
+
* warmed in whatever order traffic arrives). So a process can be clean in dev
|
|
610
|
+
* and wrong in production purely from import order, which makes production the
|
|
611
|
+
* case that matters MOST, and it was the case that was silent. The cost is
|
|
612
|
+
* bounded to the point of irrelevance: once per OID per process, at client
|
|
613
|
+
* construction, and only when somebody else's non-default parser is actually
|
|
614
|
+
* being replaced.
|
|
601
615
|
*/
|
|
602
616
|
function warnParserOverwrite(oid, typeName) {
|
|
603
|
-
if (process.env.NODE_ENV === 'production')
|
|
604
|
-
return;
|
|
605
617
|
const getParser = pg_1.default.types.getTypeParser;
|
|
606
618
|
const current = getParser(oid, 'text');
|
|
607
619
|
// Turbine's own earlier registration is not a third party's expectation.
|
|
@@ -621,7 +633,9 @@ function warnParserOverwrite(oid, typeName) {
|
|
|
621
633
|
'process, and Turbine is replacing it. `pg.types.setTypeParser` is process-global and takes effect ' +
|
|
622
634
|
'immediately for EVERY pg.Pool in the process, including pools that already exist and are already ' +
|
|
623
635
|
'querying, so whatever set that parser will now read this column differently. If yours should win, ' +
|
|
624
|
-
`register it AFTER constructing the client.${remedy}
|
|
636
|
+
`register it AFTER constructing the client.${remedy} This warning fires under \`NODE_ENV=production\` ` +
|
|
637
|
+
'too: which parser wins depends on module evaluation order, so a process can be clean in dev and wrong ' +
|
|
638
|
+
'in production from import order alone.');
|
|
625
639
|
}
|
|
626
640
|
/**
|
|
627
641
|
* Register the UTC readings of the four zone-less temporal OIDs on the pg
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* turbine migrate status , Show migration status
|
|
15
15
|
* turbine seed , Run seed file
|
|
16
16
|
* turbine status , Show schema summary
|
|
17
|
-
* turbine doctor -
|
|
17
|
+
* turbine doctor - Index + cached-plan triage (--fix, --json, --no-concurrently, --unused, --audit, --no-plan-divergence)
|
|
18
18
|
* turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
|
|
19
19
|
* turbine mcp , Start read-only MCP server over JSON-RPC stdio
|
|
20
20
|
* turbine observe , Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
|
|
@@ -61,6 +61,8 @@ export interface CliArgs {
|
|
|
61
61
|
minScans?: number;
|
|
62
62
|
/** `doctor --metrics-url <url>`: read _turbine_metrics for the table-heat boost from a separate DB. */
|
|
63
63
|
metricsUrl?: string;
|
|
64
|
+
/** `doctor --no-plan-divergence`: skip the cached-plan divergence section (and its pg_stats read). */
|
|
65
|
+
noPlanDivergence?: boolean;
|
|
64
66
|
/** `init --yes`/`-y`: accept every step's default non-interactively. */
|
|
65
67
|
yes?: boolean;
|
|
66
68
|
/** `init --skip-schema`: don't scaffold the schema file. */
|