turbine-orm 0.59.0 → 0.59.2
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.js +3 -2
- package/dist/cjs/introspect.js +1 -1
- package/dist/cjs/plan-flip-probe.d.ts +2 -2
- package/dist/cjs/plan-flip-probe.js +2 -2
- package/dist/cjs/query/relations.js +1 -1
- package/dist/cli/index.js +3 -2
- package/dist/introspect.js +1 -1
- package/dist/plan-flip-probe.d.ts +2 -2
- package/dist/plan-flip-probe.js +2 -2
- package/dist/query/relations.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -454,7 +454,7 @@ const db = turbine({
|
|
|
454
454
|
|
|
455
455
|
Where a pg-style alias exists (`max`, `idleTimeoutMillis`, `connectionTimeoutMillis`), the explicit Turbine field wins when both are set.
|
|
456
456
|
|
|
457
|
-
> **`planCacheMode` (Postgres only, opt-in).** PostgreSQL may promote a **named** prepared statement to a generic plan from its sixth execution onward, and a generic plan is costed blind to the bound values. On a predicate whose selectivity swings per value (a `tenant_id` equality on a shared table, where one value matches a handful of rows and another matches most of them), the statement can be locked onto a plan chosen for the average value, and it never reverts. `planCacheMode: 'auto' | 'force_custom_plan' | 'force_generic_plan'` pins the backend's choice; `'force_custom_plan'` re-plans every execution and removes the cliff. It is applied as a connection parameter (`options=-c plan_cache_mode=...`) when Turbine opens a connection, so it is in force for that connection's first statement and for every checkout, `$transaction`, stream and pipeline on it, and it cannot race your first query. Leave it unset (the default) and Turbine sends nothing at all. Reach for it when you have measured a statement getting slower after its fifth execution. **Correction to the 0.54 text, which said `findMany` / `findFirst` bind `LIMIT $n` and are "much less exposed":** that was false. PostgreSQL does not deny the planner a limit fraction for a bound limit, it substitutes a default of 10% of the child node's own row estimate (clamped at one row), and an unknown `OFFSET` triggers the same substitution even when the limit is a constant, which a paginated Turbine read always has. Two things also need saying about the sentence that opens this note. The sixth execution is a ceiling, not a trigger: `auto` promotes only when the generic plan's **estimated** cost is not worse than the average custom cost, so many statements are never promoted at all, and `pg_prepared_statements.generic_plans` is how you tell. And the shape that gets promoted unprompted is the one with **no limit**, not the limited one: measured on a skewed join predicate, an unlimited `count()`-shaped statement promoted under the default `auto` and ran a nested loop at 430x the buffers of the custom plan, while the same predicate under `LIMIT $n` was never promoted across eight executions (its substituted row count made the generic plan look more expensive). A limited `findMany` gives the planner two unknowns instead of one, which is not the same as more damage. `implicitPkOrdering` is **off by default in core**, so a default `findMany` emits no `ORDER BY`; switching it on adds an ordering a generic plan can walk the whole table in. Measure with `plan_cache_mode = force_generic_plan` against `force_custom_plan` rather than reasoning about which shapes ought to be safe; the fixtures and numbers are on the [relations page](https://turbineorm.dev/relations) and in the 0.55.0 changelog. **Two 0.56 corrections to the paragraph above.** First, "neither an ORDER BY nor a limit is required" is true, but it read as if ordering did not matter:
|
|
457
|
+
> **`planCacheMode` (Postgres only, opt-in).** PostgreSQL may promote a **named** prepared statement to a generic plan from its sixth execution onward, and a generic plan is costed blind to the bound values. On a predicate whose selectivity swings per value (a `tenant_id` equality on a shared table, where one value matches a handful of rows and another matches most of them), the statement can be locked onto a plan chosen for the average value, and it never reverts. `planCacheMode: 'auto' | 'force_custom_plan' | 'force_generic_plan'` pins the backend's choice; `'force_custom_plan'` re-plans every execution and removes the cliff. It is applied as a connection parameter (`options=-c plan_cache_mode=...`) when Turbine opens a connection, so it is in force for that connection's first statement and for every checkout, `$transaction`, stream and pipeline on it, and it cannot race your first query. Leave it unset (the default) and Turbine sends nothing at all. Reach for it when you have measured a statement getting slower after its fifth execution. **Correction to the 0.54 text, which said `findMany` / `findFirst` bind `LIMIT $n` and are "much less exposed":** that was false. PostgreSQL does not deny the planner a limit fraction for a bound limit, it substitutes a default of 10% of the child node's own row estimate (clamped at one row), and an unknown `OFFSET` triggers the same substitution even when the limit is a constant, which a paginated Turbine read always has. Two things also need saying about the sentence that opens this note. The sixth execution is a ceiling, not a trigger: `auto` promotes only when the generic plan's **estimated** cost is not worse than the average custom cost, so many statements are never promoted at all, and `pg_prepared_statements.generic_plans` is how you tell. And the shape that gets promoted unprompted is the one with **no limit**, not the limited one: measured on a skewed join predicate, an unlimited `count()`-shaped statement promoted under the default `auto` and ran a nested loop at 430x the buffers of the custom plan, while the same predicate under `LIMIT $n` was never promoted across eight executions (its substituted row count made the generic plan look more expensive). A limited `findMany` gives the planner two unknowns instead of one, which is not the same as more damage. `implicitPkOrdering` is **off by default in core**, so a default `findMany` emits no `ORDER BY`; switching it on adds an ordering a generic plan can walk the whole table in. Measure with `plan_cache_mode = force_generic_plan` against `force_custom_plan` rather than reasoning about which shapes ought to be safe; the fixtures and numbers are on the [relations page](https://turbineorm.dev/relations) and in the 0.55.0 changelog. **Two 0.56 corrections to the paragraph above.** First, "neither an ORDER BY nor a limit is required" is true, but it read as if ordering did not matter: in a table-by-table sweep of a multi-tenant schema, every divergent shape measured had an `ORDER BY` and every shape without one measured 1.00x, so it is not necessary in general and is still the strongest single predictor in practice. Second, a custom plan is not automatically the better one: on a reproducible fixture where one dense value's rows are packed at the end of the heap, `LIMIT 20` with no ordering reads 4,262 buffers custom against 71 generic (the default `auto` never promotes there, so it produces the 4,262-buffer plan too). Since 0.56 the per-query read arg **`forceCustomPlan: true`** covers the case a connection-wide setting cannot express, custom on one query and `auto` everywhere else, and `turbine doctor` detects the distribution that admits the flip. **0.57 correction:** that read arg reached the core client only. Through `turbine-orm/prisma-compat` it was accepted and silently dropped until 0.57.0, so a compat integration that followed this advice got a no-op; confirm at the wire with `pg_prepared_statements` rather than assuming. 0.57 also adds a third divergence mechanism to `doctor`: an **unindexed** filter column, where the good plan is a sequential scan the generic plan will not choose (measured 250 buffers against 20,074 on a 20,000-row / 247-page fixture). Three scope limits: it does nothing on an **external pool** (Turbine never opens those connections, so set the GUC in the driver's own setup; Turbine-owned string `replicas` on that same client DO get it); a Postgres wire-compatible engine without the setting (CockroachDB, YugabyteDB, pre-12 PostgreSQL) refuses the connection parameter itself; and a **connection pooler** may filter startup parameters (PgBouncer's `ignore_startup_parameters`), where `ALTER ROLE ... SET plan_cache_mode = ...` is the way in. Any value outside the three throws `ValidationError` at construction, and a non-Postgres engine throws `UnsupportedFeatureError` (`TURBINE_E017`).
|
|
458
458
|
|
|
459
459
|
> **`preparedStatements` and connection poolers.** With prepared statements on, Turbine submits queries as `{ name, text, values }` so Postgres caches the parse and plan **per backend connection**. That is a real win against a database you connect to directly, and a hazard behind a transaction-pooling proxy (PgBouncer in `transaction` mode, Supabase's pooler port, some serverless poolers): the named statement is prepared on one backend and your next query may land on another, which fails with `prepared statement "..." does not exist`. Turbine defaults it to `true` only for pools it creates itself and `false` for external pools passed via `pool` / `turbineHttp()`, because serverless drivers are the common case there. If you are pointing a Turbine-owned pool at a transaction pooler, set `preparedStatements: false`. The environment variable `TURBINE_DISABLE_PREPARED=1` turns it off globally without a code change.
|
|
460
460
|
|
package/dist/cjs/cli/index.js
CHANGED
|
@@ -2389,7 +2389,8 @@ async function cmdDoctor(args, config) {
|
|
|
2389
2389
|
: { findings: [], notices: [], candidatesConsidered: 0, consideredIndexed: 0, consideredUnindexed: 0 };
|
|
2390
2390
|
// Statistics can say how bad a flip WOULD be; only the planner can say whether
|
|
2391
2391
|
// it is reachable. The `unindexed-filter` branch shipped in 0.57 without that
|
|
2392
|
-
// question answered and
|
|
2392
|
+
// question answered, and a measured sample of 13 findings held up only 6 times,
|
|
2393
|
+
// so every one
|
|
2393
2394
|
// of its findings is now put to a plan-only EXPLAIN. Nothing is executed, and a
|
|
2394
2395
|
// probe that fails keeps its finding rather than dropping it.
|
|
2395
2396
|
const flipProbe = divergenceOn && scored.findings.some(plan_flip_probe_js_1.needsFlipProbe)
|
|
@@ -2943,7 +2944,7 @@ function renderPlanDivergence(divergence, attached) {
|
|
|
2943
2944
|
const args = `where: { ${first.columnField}: value }, orderBy: { ${first.orderColumnField}: 'asc' },`;
|
|
2944
2945
|
// The accessor is the camelCase FIELD spelling, not the raw table name:
|
|
2945
2946
|
// TurbineClient and the code generator both define table accessors through
|
|
2946
|
-
// snakeToCamel, so `db.
|
|
2947
|
+
// snakeToCamel, so `db.user_session` is undefined on every
|
|
2947
2948
|
// snake_case schema. The finding's own `columnField` / `orderColumnField`
|
|
2948
2949
|
// are already field-space for the same reason.
|
|
2949
2950
|
console.log(` ${(0, ui_js_1.dim)('On the core client:')}`);
|
package/dist/cjs/introspect.js
CHANGED
|
@@ -526,7 +526,7 @@ async function introspectPostgresCatalog(options) {
|
|
|
526
526
|
// per-FK-column when several FKs point at the same target, and every name
|
|
527
527
|
// is collision-checked against the table's scalar column fields so a
|
|
528
528
|
// relation can never shadow a column (which generated unsound types and
|
|
529
|
-
// made both surfaces unusable
|
|
529
|
+
// made both surfaces unusable).
|
|
530
530
|
const columnFieldsByTable = new Map();
|
|
531
531
|
const unknownTypedFieldsByTable = new Map();
|
|
532
532
|
for (const [tbl, cols] of columnsByTable) {
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* `plan-divergence.ts` scores a column from statistics alone and answers "IF the
|
|
7
7
|
* cached plan flips, how bad is it". Its `unindexed-filter` branch (0.57) shipped
|
|
8
8
|
* without an answer to the prior question, "CAN it flip at all", and that turned
|
|
9
|
-
* out to be the majority case:
|
|
10
|
-
* findings
|
|
9
|
+
* out to be the majority case: in validation against a large schema the branch
|
|
10
|
+
* emitted 39 findings, and a measured sample of 13 of them held up only 6 times.
|
|
11
11
|
*
|
|
12
12
|
* Every false positive had one signature: **the generic plan was not the ordered
|
|
13
13
|
* index walk the finding claims.** There was no flip to be had, so the
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* `plan-divergence.ts` scores a column from statistics alone and answers "IF the
|
|
8
8
|
* cached plan flips, how bad is it". Its `unindexed-filter` branch (0.57) shipped
|
|
9
9
|
* without an answer to the prior question, "CAN it flip at all", and that turned
|
|
10
|
-
* out to be the majority case:
|
|
11
|
-
* findings
|
|
10
|
+
* out to be the majority case: in validation against a large schema the branch
|
|
11
|
+
* emitted 39 findings, and a measured sample of 13 of them held up only 6 times.
|
|
12
12
|
*
|
|
13
13
|
* Every false positive had one signature: **the generic plan was not the ordered
|
|
14
14
|
* index walk the finding claims.** There was no flip to be had, so the
|
|
@@ -2266,7 +2266,7 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
|
|
|
2266
2266
|
// For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
|
|
2267
2267
|
// the child-side column), so alias.fk = parentRef.pk. hasOne is just
|
|
2268
2268
|
// hasMany with a unique FK, treating it like belongsTo here silently
|
|
2269
|
-
// correlated the wrong columns (
|
|
2269
|
+
// correlated the wrong columns (the mismatch is silent: uuid = varchar).
|
|
2270
2270
|
// For belongsTo: SOURCE has the FK, so alias.pk = parentRef.fk (reversed).
|
|
2271
2271
|
// Supports composite foreign keys (string[]) via buildCorrelation.
|
|
2272
2272
|
let whereClause;
|
package/dist/cli/index.js
CHANGED
|
@@ -2339,7 +2339,8 @@ async function cmdDoctor(args, config) {
|
|
|
2339
2339
|
: { findings: [], notices: [], candidatesConsidered: 0, consideredIndexed: 0, consideredUnindexed: 0 };
|
|
2340
2340
|
// Statistics can say how bad a flip WOULD be; only the planner can say whether
|
|
2341
2341
|
// it is reachable. The `unindexed-filter` branch shipped in 0.57 without that
|
|
2342
|
-
// question answered and
|
|
2342
|
+
// question answered, and a measured sample of 13 findings held up only 6 times,
|
|
2343
|
+
// so every one
|
|
2343
2344
|
// of its findings is now put to a plan-only EXPLAIN. Nothing is executed, and a
|
|
2344
2345
|
// probe that fails keeps its finding rather than dropping it.
|
|
2345
2346
|
const flipProbe = divergenceOn && scored.findings.some(needsFlipProbe)
|
|
@@ -2893,7 +2894,7 @@ function renderPlanDivergence(divergence, attached) {
|
|
|
2893
2894
|
const args = `where: { ${first.columnField}: value }, orderBy: { ${first.orderColumnField}: 'asc' },`;
|
|
2894
2895
|
// The accessor is the camelCase FIELD spelling, not the raw table name:
|
|
2895
2896
|
// TurbineClient and the code generator both define table accessors through
|
|
2896
|
-
// snakeToCamel, so `db.
|
|
2897
|
+
// snakeToCamel, so `db.user_session` is undefined on every
|
|
2897
2898
|
// snake_case schema. The finding's own `columnField` / `orderColumnField`
|
|
2898
2899
|
// are already field-space for the same reason.
|
|
2899
2900
|
console.log(` ${dim('On the core client:')}`);
|
package/dist/introspect.js
CHANGED
|
@@ -504,7 +504,7 @@ export async function introspectPostgresCatalog(options) {
|
|
|
504
504
|
// per-FK-column when several FKs point at the same target, and every name
|
|
505
505
|
// is collision-checked against the table's scalar column fields so a
|
|
506
506
|
// relation can never shadow a column (which generated unsound types and
|
|
507
|
-
// made both surfaces unusable
|
|
507
|
+
// made both surfaces unusable).
|
|
508
508
|
const columnFieldsByTable = new Map();
|
|
509
509
|
const unknownTypedFieldsByTable = new Map();
|
|
510
510
|
for (const [tbl, cols] of columnsByTable) {
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* `plan-divergence.ts` scores a column from statistics alone and answers "IF the
|
|
7
7
|
* cached plan flips, how bad is it". Its `unindexed-filter` branch (0.57) shipped
|
|
8
8
|
* without an answer to the prior question, "CAN it flip at all", and that turned
|
|
9
|
-
* out to be the majority case:
|
|
10
|
-
* findings
|
|
9
|
+
* out to be the majority case: in validation against a large schema the branch
|
|
10
|
+
* emitted 39 findings, and a measured sample of 13 of them held up only 6 times.
|
|
11
11
|
*
|
|
12
12
|
* Every false positive had one signature: **the generic plan was not the ordered
|
|
13
13
|
* index walk the finding claims.** There was no flip to be had, so the
|
package/dist/plan-flip-probe.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* `plan-divergence.ts` scores a column from statistics alone and answers "IF the
|
|
7
7
|
* cached plan flips, how bad is it". Its `unindexed-filter` branch (0.57) shipped
|
|
8
8
|
* without an answer to the prior question, "CAN it flip at all", and that turned
|
|
9
|
-
* out to be the majority case:
|
|
10
|
-
* findings
|
|
9
|
+
* out to be the majority case: in validation against a large schema the branch
|
|
10
|
+
* emitted 39 findings, and a measured sample of 13 of them held up only 6 times.
|
|
11
11
|
*
|
|
12
12
|
* Every false positive had one signature: **the generic plan was not the ordered
|
|
13
13
|
* index walk the finding claims.** There was no flip to be had, so the
|
package/dist/query/relations.js
CHANGED
|
@@ -2189,7 +2189,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2189
2189
|
// For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
|
|
2190
2190
|
// the child-side column), so alias.fk = parentRef.pk. hasOne is just
|
|
2191
2191
|
// hasMany with a unique FK, treating it like belongsTo here silently
|
|
2192
|
-
// correlated the wrong columns (
|
|
2192
|
+
// correlated the wrong columns (the mismatch is silent: uuid = varchar).
|
|
2193
2193
|
// For belongsTo: SOURCE has the FK, so alias.pk = parentRef.fk (reversed).
|
|
2194
2194
|
// Supports composite foreign keys (string[]) via buildCorrelation.
|
|
2195
2195
|
let whereClause;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turbine-orm",
|
|
3
|
-
"version": "0.59.
|
|
3
|
+
"version": "0.59.2",
|
|
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).",
|