turbine-orm 0.56.0 → 0.58.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 +16 -1
- package/dist/cjs/cli/index.js +284 -38
- package/dist/cjs/client.js +3 -39
- package/dist/cjs/plan-divergence.d.ts +320 -38
- package/dist/cjs/plan-divergence.js +413 -67
- package/dist/cjs/plan-flip-probe.d.ts +165 -0
- package/dist/cjs/plan-flip-probe.js +304 -0
- package/dist/cjs/prisma-compat.d.ts +32 -1
- package/dist/cjs/prisma-compat.js +297 -41
- package/dist/cjs/query/index.d.ts +2 -0
- package/dist/cjs/query/index.js +18 -1
- package/dist/cjs/query/option-surface.d.ts +100 -0
- package/dist/cjs/query/option-surface.js +214 -0
- package/dist/cjs/query/utils.d.ts +16 -0
- package/dist/cjs/query/utils.js +50 -0
- package/dist/cjs/query/warn-registry.d.ts +8 -0
- package/dist/cjs/query/warn-registry.js +8 -0
- package/dist/cli/index.js +285 -39
- package/dist/client.js +4 -40
- package/dist/plan-divergence.d.ts +320 -38
- package/dist/plan-divergence.js +412 -67
- package/dist/plan-flip-probe.d.ts +165 -0
- package/dist/plan-flip-probe.js +262 -0
- package/dist/prisma-compat.d.ts +32 -1
- package/dist/prisma-compat.js +297 -41
- package/dist/query/index.d.ts +2 -0
- package/dist/query/index.js +1 -0
- package/dist/query/option-surface.d.ts +100 -0
- package/dist/query/option-surface.js +209 -0
- package/dist/query/utils.d.ts +16 -0
- package/dist/query/utils.js +49 -0
- package/dist/query/warn-registry.d.ts +8 -0
- package/dist/query/warn-registry.js +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-flip probe: does the generic plan actually differ from the good one?
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `plan-divergence.ts` scores a column from statistics alone and answers "IF the
|
|
7
|
+
* cached plan flips, how bad is it". Its `unindexed-filter` branch (0.57) shipped
|
|
8
|
+
* without an answer to the prior question, "CAN it flip at all", and that turned
|
|
9
|
+
* out to be the majority case: on a real 118-model schema the branch produced 39
|
|
10
|
+
* findings of which a measured sample was right 6 times in 13.
|
|
11
|
+
*
|
|
12
|
+
* Every false positive had one signature: **the generic plan kept the same
|
|
13
|
+
* sequential scan the custom plan chose.** There was no flip to be had, so the
|
|
14
|
+
* amplification the finding printed described a plan the planner would never
|
|
15
|
+
* pick.
|
|
16
|
+
*
|
|
17
|
+
* ## Why this is a probe and not another rule
|
|
18
|
+
*
|
|
19
|
+
* The obvious gate is arithmetic: require the generic row estimate
|
|
20
|
+
* (`rows / n_distinct`) to exceed the assumed LIMIT, on the reasoning that
|
|
21
|
+
* Postgres discounts an ordered index walk by `min(1, limit / estimate)` and an
|
|
22
|
+
* estimate at or below the limit earns no discount at all.
|
|
23
|
+
*
|
|
24
|
+
* That rule is wrong, and it was measured wrong before it was written down here.
|
|
25
|
+
* The real limit fraction for a BOUND limit is `ceil(0.1 x estimate) / estimate`,
|
|
26
|
+
* which pins to 0.1 for estimates of 10 or more but RISES to `1/estimate` below
|
|
27
|
+
* that, and the plan is then chosen by comparing that fraction of the full index
|
|
28
|
+
* scan against a seq scan plus sort. On a 247-page fixture the flip boundary sits
|
|
29
|
+
* between estimates 3 and 4, not at the limit of 20:
|
|
30
|
+
*
|
|
31
|
+
* ```txt
|
|
32
|
+
* generic estimate 2 3 4 10 20 500
|
|
33
|
+
* generic plan seq seq index index index index
|
|
34
|
+
* buffers 250 250 20,074 20,074 19,071 765
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* Estimates 4 through 20 are full-table walks that an estimate-versus-limit gate
|
|
38
|
+
* would discard. The boundary is also not a fixed number: it is a cost
|
|
39
|
+
* comparison, so it moves with the table. Reproducing the same estimate on a
|
|
40
|
+
* 1976-page table and on an 89-page narrow table put the flip in a different
|
|
41
|
+
* place each time.
|
|
42
|
+
*
|
|
43
|
+
* So the honest gate is not a better formula, it is a measurement. `EXPLAIN`
|
|
44
|
+
* WITHOUT `ANALYZE` executes nothing, returns in microseconds, and asks the
|
|
45
|
+
* planner the exact question the rule was trying to predict. This module runs it.
|
|
46
|
+
*
|
|
47
|
+
* ## What it asks, and why only half the pair
|
|
48
|
+
*
|
|
49
|
+
* It plans ONE statement, under `force_generic_plan` only:
|
|
50
|
+
*
|
|
51
|
+
* ```sql
|
|
52
|
+
* PREPARE p AS SELECT * FROM t WHERE col = $1 ORDER BY ord LIMIT $2;
|
|
53
|
+
* EXPLAIN (FORMAT JSON) EXECUTE p(NULL, 20);
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* The custom plan is not needed. The finding's whole claim is that a promoted
|
|
57
|
+
* generic plan abandons the seq scan for an ordered index walk, so if the generic
|
|
58
|
+
* plan IS a seq scan on the target table, the claim is refuted no matter what the
|
|
59
|
+
* custom plan does. Asking one question instead of two halves the work and
|
|
60
|
+
* removes the need for a representative rare value, which statistics do not
|
|
61
|
+
* carry.
|
|
62
|
+
*
|
|
63
|
+
* `NULL` is a safe argument precisely because the plan is generic: a generic plan
|
|
64
|
+
* is built without looking at the value, which is the property the whole check is
|
|
65
|
+
* about. The LIMIT is bound as `$2` rather than inlined because that is the shape
|
|
66
|
+
* Turbine emits, and an inlined limit takes a different code path in the planner.
|
|
67
|
+
*
|
|
68
|
+
* ## Failure is never a silent drop
|
|
69
|
+
*
|
|
70
|
+
* A probe that errors, times out, or returns an unparseable plan yields
|
|
71
|
+
* `'unknown'` and the finding SURVIVES with a note. A diagnostic that deletes
|
|
72
|
+
* findings when the database is uncooperative would be worse than one that
|
|
73
|
+
* over-reports, because the failure would be invisible in exactly the
|
|
74
|
+
* environments (restricted roles, non-Postgres engines) where a human is least
|
|
75
|
+
* able to check.
|
|
76
|
+
*
|
|
77
|
+
* @module
|
|
78
|
+
*/
|
|
79
|
+
import type { PlanDivergenceFinding, PlanDivergenceReport } from './plan-divergence.js';
|
|
80
|
+
/**
|
|
81
|
+
* The planner's answer for one finding.
|
|
82
|
+
*
|
|
83
|
+
* - `'flip-reachable'`, the generic plan is NOT a plain seq scan of the target
|
|
84
|
+
* table, so the divergence the finding describes is one the planner can
|
|
85
|
+
* actually choose.
|
|
86
|
+
* - `'no-flip'`, the generic plan sequentially scans the target table, the same
|
|
87
|
+
* access the good plan uses. Nothing to diverge to.
|
|
88
|
+
* - `'unknown'`, the probe did not produce an answer. The finding is kept.
|
|
89
|
+
*/
|
|
90
|
+
export type FlipVerdict = 'flip-reachable' | 'no-flip' | 'unknown';
|
|
91
|
+
/** Outcome of a probe pass, keyed by {@link flipProbeKey}. */
|
|
92
|
+
export interface FlipProbeResult {
|
|
93
|
+
/** True when the probe pass ran at all (Postgres, connection succeeded). */
|
|
94
|
+
available: boolean;
|
|
95
|
+
verdicts: Record<string, FlipVerdict>;
|
|
96
|
+
notices: string[];
|
|
97
|
+
}
|
|
98
|
+
/** An empty result, which keeps every finding. Used when probing is off. */
|
|
99
|
+
export declare function emptyFlipProbeResult(): FlipProbeResult;
|
|
100
|
+
/**
|
|
101
|
+
* Map key for a (table, column) pair.
|
|
102
|
+
*
|
|
103
|
+
* `\u0000` as the separator, written as the ESCAPE and never as a raw byte: a
|
|
104
|
+
* literal NUL in a source file makes `grep` treat the whole file as binary, which
|
|
105
|
+
* is how four of them survived a release in `cli/index.ts`.
|
|
106
|
+
*/
|
|
107
|
+
export declare function flipProbeKey(table: string, column: string): string;
|
|
108
|
+
/**
|
|
109
|
+
* Which findings are worth probing.
|
|
110
|
+
*
|
|
111
|
+
* `unindexed-filter` only. The `sparse-value` branch already requires an index
|
|
112
|
+
* that serves the equality, and its 0.56 calibration was 6 of 6 on the schema
|
|
113
|
+
* that later produced 6 of 13 here, so there is no measured precision problem to
|
|
114
|
+
* spend a round trip on.
|
|
115
|
+
*/
|
|
116
|
+
export declare function needsFlipProbe(finding: PlanDivergenceFinding): boolean;
|
|
117
|
+
/**
|
|
118
|
+
* The SQL for one probe. Pure, so the exact text is unit-testable without a
|
|
119
|
+
* database.
|
|
120
|
+
*
|
|
121
|
+
* Every identifier goes through {@link quoteIdent}; the only values in the
|
|
122
|
+
* statement are `$1` and `$2`, bound at EXECUTE. `name` is generated by the
|
|
123
|
+
* caller as `tpf_<index>` and is never caller-controlled text.
|
|
124
|
+
*/
|
|
125
|
+
export declare function buildFlipProbeSql(finding: PlanDivergenceFinding, name: string, searchSchema?: string): {
|
|
126
|
+
prepare: string;
|
|
127
|
+
explain: string;
|
|
128
|
+
deallocate: string;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Read a verdict out of one `EXPLAIN (FORMAT JSON)` payload.
|
|
132
|
+
*
|
|
133
|
+
* Exported for unit tests: the plan shapes this has to classify are exactly the
|
|
134
|
+
* ones that are tedious to produce live.
|
|
135
|
+
*
|
|
136
|
+
* The rule is deliberately narrow. Only a `Seq Scan` ON THE TARGET TABLE refutes
|
|
137
|
+
* a finding. A seq scan of some other relation in a more complex plan says
|
|
138
|
+
* nothing about this column, and anything that is not a plain sequential scan of
|
|
139
|
+
* the target (index scan, bitmap heap scan, index-only scan) leaves the flip
|
|
140
|
+
* reachable.
|
|
141
|
+
*/
|
|
142
|
+
export declare function verdictFromPlanJson(payload: unknown, table: string): FlipVerdict;
|
|
143
|
+
export interface ProbePlanFlipsOptions {
|
|
144
|
+
connectionString: string;
|
|
145
|
+
schema?: string;
|
|
146
|
+
findings: PlanDivergenceFinding[];
|
|
147
|
+
statementTimeoutMs?: number;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Ask the planner, once per candidate finding, whether the flip is reachable.
|
|
151
|
+
*
|
|
152
|
+
* Runs inside a single `BEGIN READ ONLY` that is always rolled back. Nothing is
|
|
153
|
+
* executed: `EXPLAIN` without `ANALYZE` plans and discards. Each probe is
|
|
154
|
+
* INDIVIDUALLY optional, the same contract `collectStatsSnapshot` uses, so one
|
|
155
|
+
* unprobeable column degrades that column's verdict to `'unknown'` and never the
|
|
156
|
+
* pass.
|
|
157
|
+
*/
|
|
158
|
+
export declare function probePlanFlips(options: ProbePlanFlipsOptions): Promise<FlipProbeResult>;
|
|
159
|
+
/**
|
|
160
|
+
* Drop the findings the planner refuted, and record how many.
|
|
161
|
+
*
|
|
162
|
+
* Pure. `'unknown'` and a missing verdict both KEEP the finding: see the failure
|
|
163
|
+
* contract in the module header.
|
|
164
|
+
*/
|
|
165
|
+
export declare function applyFlipVerdicts(report: PlanDivergenceReport, probe: FlipProbeResult): PlanDivergenceReport;
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Plan-flip probe: does the generic plan actually differ from the good one?
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* `plan-divergence.ts` scores a column from statistics alone and answers "IF the
|
|
8
|
+
* cached plan flips, how bad is it". Its `unindexed-filter` branch (0.57) shipped
|
|
9
|
+
* without an answer to the prior question, "CAN it flip at all", and that turned
|
|
10
|
+
* out to be the majority case: on a real 118-model schema the branch produced 39
|
|
11
|
+
* findings of which a measured sample was right 6 times in 13.
|
|
12
|
+
*
|
|
13
|
+
* Every false positive had one signature: **the generic plan kept the same
|
|
14
|
+
* sequential scan the custom plan chose.** There was no flip to be had, so the
|
|
15
|
+
* amplification the finding printed described a plan the planner would never
|
|
16
|
+
* pick.
|
|
17
|
+
*
|
|
18
|
+
* ## Why this is a probe and not another rule
|
|
19
|
+
*
|
|
20
|
+
* The obvious gate is arithmetic: require the generic row estimate
|
|
21
|
+
* (`rows / n_distinct`) to exceed the assumed LIMIT, on the reasoning that
|
|
22
|
+
* Postgres discounts an ordered index walk by `min(1, limit / estimate)` and an
|
|
23
|
+
* estimate at or below the limit earns no discount at all.
|
|
24
|
+
*
|
|
25
|
+
* That rule is wrong, and it was measured wrong before it was written down here.
|
|
26
|
+
* The real limit fraction for a BOUND limit is `ceil(0.1 x estimate) / estimate`,
|
|
27
|
+
* which pins to 0.1 for estimates of 10 or more but RISES to `1/estimate` below
|
|
28
|
+
* that, and the plan is then chosen by comparing that fraction of the full index
|
|
29
|
+
* scan against a seq scan plus sort. On a 247-page fixture the flip boundary sits
|
|
30
|
+
* between estimates 3 and 4, not at the limit of 20:
|
|
31
|
+
*
|
|
32
|
+
* ```txt
|
|
33
|
+
* generic estimate 2 3 4 10 20 500
|
|
34
|
+
* generic plan seq seq index index index index
|
|
35
|
+
* buffers 250 250 20,074 20,074 19,071 765
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* Estimates 4 through 20 are full-table walks that an estimate-versus-limit gate
|
|
39
|
+
* would discard. The boundary is also not a fixed number: it is a cost
|
|
40
|
+
* comparison, so it moves with the table. Reproducing the same estimate on a
|
|
41
|
+
* 1976-page table and on an 89-page narrow table put the flip in a different
|
|
42
|
+
* place each time.
|
|
43
|
+
*
|
|
44
|
+
* So the honest gate is not a better formula, it is a measurement. `EXPLAIN`
|
|
45
|
+
* WITHOUT `ANALYZE` executes nothing, returns in microseconds, and asks the
|
|
46
|
+
* planner the exact question the rule was trying to predict. This module runs it.
|
|
47
|
+
*
|
|
48
|
+
* ## What it asks, and why only half the pair
|
|
49
|
+
*
|
|
50
|
+
* It plans ONE statement, under `force_generic_plan` only:
|
|
51
|
+
*
|
|
52
|
+
* ```sql
|
|
53
|
+
* PREPARE p AS SELECT * FROM t WHERE col = $1 ORDER BY ord LIMIT $2;
|
|
54
|
+
* EXPLAIN (FORMAT JSON) EXECUTE p(NULL, 20);
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* The custom plan is not needed. The finding's whole claim is that a promoted
|
|
58
|
+
* generic plan abandons the seq scan for an ordered index walk, so if the generic
|
|
59
|
+
* plan IS a seq scan on the target table, the claim is refuted no matter what the
|
|
60
|
+
* custom plan does. Asking one question instead of two halves the work and
|
|
61
|
+
* removes the need for a representative rare value, which statistics do not
|
|
62
|
+
* carry.
|
|
63
|
+
*
|
|
64
|
+
* `NULL` is a safe argument precisely because the plan is generic: a generic plan
|
|
65
|
+
* is built without looking at the value, which is the property the whole check is
|
|
66
|
+
* about. The LIMIT is bound as `$2` rather than inlined because that is the shape
|
|
67
|
+
* Turbine emits, and an inlined limit takes a different code path in the planner.
|
|
68
|
+
*
|
|
69
|
+
* ## Failure is never a silent drop
|
|
70
|
+
*
|
|
71
|
+
* A probe that errors, times out, or returns an unparseable plan yields
|
|
72
|
+
* `'unknown'` and the finding SURVIVES with a note. A diagnostic that deletes
|
|
73
|
+
* findings when the database is uncooperative would be worse than one that
|
|
74
|
+
* over-reports, because the failure would be invisible in exactly the
|
|
75
|
+
* environments (restricted roles, non-Postgres engines) where a human is least
|
|
76
|
+
* able to check.
|
|
77
|
+
*
|
|
78
|
+
* @module
|
|
79
|
+
*/
|
|
80
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
81
|
+
if (k2 === undefined) k2 = k;
|
|
82
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
83
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
84
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
85
|
+
}
|
|
86
|
+
Object.defineProperty(o, k2, desc);
|
|
87
|
+
}) : (function(o, m, k, k2) {
|
|
88
|
+
if (k2 === undefined) k2 = k;
|
|
89
|
+
o[k2] = m[k];
|
|
90
|
+
}));
|
|
91
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
92
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
93
|
+
}) : function(o, v) {
|
|
94
|
+
o["default"] = v;
|
|
95
|
+
});
|
|
96
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
97
|
+
var ownKeys = function(o) {
|
|
98
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
99
|
+
var ar = [];
|
|
100
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
101
|
+
return ar;
|
|
102
|
+
};
|
|
103
|
+
return ownKeys(o);
|
|
104
|
+
};
|
|
105
|
+
return function (mod) {
|
|
106
|
+
if (mod && mod.__esModule) return mod;
|
|
107
|
+
var result = {};
|
|
108
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
109
|
+
__setModuleDefault(result, mod);
|
|
110
|
+
return result;
|
|
111
|
+
};
|
|
112
|
+
})();
|
|
113
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
114
|
+
exports.emptyFlipProbeResult = emptyFlipProbeResult;
|
|
115
|
+
exports.flipProbeKey = flipProbeKey;
|
|
116
|
+
exports.needsFlipProbe = needsFlipProbe;
|
|
117
|
+
exports.buildFlipProbeSql = buildFlipProbeSql;
|
|
118
|
+
exports.verdictFromPlanJson = verdictFromPlanJson;
|
|
119
|
+
exports.probePlanFlips = probePlanFlips;
|
|
120
|
+
exports.applyFlipVerdicts = applyFlipVerdicts;
|
|
121
|
+
const utils_js_1 = require("./query/utils.js");
|
|
122
|
+
/** An empty result, which keeps every finding. Used when probing is off. */
|
|
123
|
+
function emptyFlipProbeResult() {
|
|
124
|
+
return { available: false, verdicts: {}, notices: [] };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Map key for a (table, column) pair.
|
|
128
|
+
*
|
|
129
|
+
* `\u0000` as the separator, written as the ESCAPE and never as a raw byte: a
|
|
130
|
+
* literal NUL in a source file makes `grep` treat the whole file as binary, which
|
|
131
|
+
* is how four of them survived a release in `cli/index.ts`.
|
|
132
|
+
*/
|
|
133
|
+
function flipProbeKey(table, column) {
|
|
134
|
+
return `${table}\u0000${column}`;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Which findings are worth probing.
|
|
138
|
+
*
|
|
139
|
+
* `unindexed-filter` only. The `sparse-value` branch already requires an index
|
|
140
|
+
* that serves the equality, and its 0.56 calibration was 6 of 6 on the schema
|
|
141
|
+
* that later produced 6 of 13 here, so there is no measured precision problem to
|
|
142
|
+
* spend a round trip on.
|
|
143
|
+
*/
|
|
144
|
+
function needsFlipProbe(finding) {
|
|
145
|
+
return finding.branch === 'unindexed-filter';
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The SQL for one probe. Pure, so the exact text is unit-testable without a
|
|
149
|
+
* database.
|
|
150
|
+
*
|
|
151
|
+
* Every identifier goes through {@link quoteIdent}; the only values in the
|
|
152
|
+
* statement are `$1` and `$2`, bound at EXECUTE. `name` is generated by the
|
|
153
|
+
* caller as `tpf_<index>` and is never caller-controlled text.
|
|
154
|
+
*/
|
|
155
|
+
function buildFlipProbeSql(finding, name, searchSchema) {
|
|
156
|
+
const rel = searchSchema ? `${(0, utils_js_1.quoteIdent)(searchSchema)}.${(0, utils_js_1.quoteIdent)(finding.table)}` : (0, utils_js_1.quoteIdent)(finding.table);
|
|
157
|
+
// No declared parameter types: Postgres infers both from context, which avoids
|
|
158
|
+
// maintaining a second pg-type mapping that could disagree with the column's
|
|
159
|
+
// real type and turn a diagnostic into an error.
|
|
160
|
+
const prepare = `PREPARE ${name} AS SELECT * FROM ${rel} ` +
|
|
161
|
+
`WHERE ${(0, utils_js_1.quoteIdent)(finding.column)} = $1 ` +
|
|
162
|
+
`ORDER BY ${(0, utils_js_1.quoteIdent)(finding.orderColumn)} LIMIT $2`;
|
|
163
|
+
return {
|
|
164
|
+
prepare,
|
|
165
|
+
explain: `EXPLAIN (FORMAT JSON) EXECUTE ${name}(NULL, ${Number(finding.assumedLimit)})`,
|
|
166
|
+
deallocate: `DEALLOCATE ${name}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function* walkPlan(node) {
|
|
170
|
+
yield node;
|
|
171
|
+
for (const child of node.Plans ?? [])
|
|
172
|
+
yield* walkPlan(child);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Read a verdict out of one `EXPLAIN (FORMAT JSON)` payload.
|
|
176
|
+
*
|
|
177
|
+
* Exported for unit tests: the plan shapes this has to classify are exactly the
|
|
178
|
+
* ones that are tedious to produce live.
|
|
179
|
+
*
|
|
180
|
+
* The rule is deliberately narrow. Only a `Seq Scan` ON THE TARGET TABLE refutes
|
|
181
|
+
* a finding. A seq scan of some other relation in a more complex plan says
|
|
182
|
+
* nothing about this column, and anything that is not a plain sequential scan of
|
|
183
|
+
* the target (index scan, bitmap heap scan, index-only scan) leaves the flip
|
|
184
|
+
* reachable.
|
|
185
|
+
*/
|
|
186
|
+
function verdictFromPlanJson(payload, table) {
|
|
187
|
+
const root = Array.isArray(payload) ? payload[0] : undefined;
|
|
188
|
+
const plan = root?.Plan;
|
|
189
|
+
if (!plan)
|
|
190
|
+
return 'unknown';
|
|
191
|
+
for (const node of walkPlan(plan)) {
|
|
192
|
+
if (node['Relation Name'] !== table)
|
|
193
|
+
continue;
|
|
194
|
+
const type = node['Node Type'];
|
|
195
|
+
if (type === undefined)
|
|
196
|
+
continue;
|
|
197
|
+
return type === 'Seq Scan' ? 'no-flip' : 'flip-reachable';
|
|
198
|
+
}
|
|
199
|
+
// The target table is not in the plan at all, which should not happen for a
|
|
200
|
+
// statement that selects from it. Treated as unknown rather than as a refutation.
|
|
201
|
+
return 'unknown';
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Ask the planner, once per candidate finding, whether the flip is reachable.
|
|
205
|
+
*
|
|
206
|
+
* Runs inside a single `BEGIN READ ONLY` that is always rolled back. Nothing is
|
|
207
|
+
* executed: `EXPLAIN` without `ANALYZE` plans and discards. Each probe is
|
|
208
|
+
* INDIVIDUALLY optional, the same contract `collectStatsSnapshot` uses, so one
|
|
209
|
+
* unprobeable column degrades that column's verdict to `'unknown'` and never the
|
|
210
|
+
* pass.
|
|
211
|
+
*/
|
|
212
|
+
async function probePlanFlips(options) {
|
|
213
|
+
const targets = options.findings.filter(needsFlipProbe);
|
|
214
|
+
const result = { available: false, verdicts: {}, notices: [] };
|
|
215
|
+
if (targets.length === 0) {
|
|
216
|
+
result.available = true;
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
const { Client } = (await Promise.resolve().then(() => __importStar(require('pg')))).default;
|
|
220
|
+
const client = new Client({ connectionString: options.connectionString });
|
|
221
|
+
try {
|
|
222
|
+
await client.connect();
|
|
223
|
+
await client.query(`SET statement_timeout = ${Number(options.statementTimeoutMs ?? 5000)}`);
|
|
224
|
+
// READ ONLY is belt-and-braces: EXPLAIN without ANALYZE cannot write, and the
|
|
225
|
+
// transaction is rolled back regardless. It costs nothing and makes the
|
|
226
|
+
// read-only intent checkable from a server-side log.
|
|
227
|
+
await client.query('BEGIN READ ONLY');
|
|
228
|
+
result.available = true;
|
|
229
|
+
for (let i = 0; i < targets.length; i++) {
|
|
230
|
+
const finding = targets[i];
|
|
231
|
+
const key = flipProbeKey(finding.table, finding.column);
|
|
232
|
+
const name = `tpf_${i}`;
|
|
233
|
+
const sql = buildFlipProbeSql(finding, name, options.schema);
|
|
234
|
+
try {
|
|
235
|
+
// A failed probe must not poison the surrounding transaction for the
|
|
236
|
+
// probes after it, so each one gets its own savepoint.
|
|
237
|
+
await client.query(`SAVEPOINT ${name}`);
|
|
238
|
+
await client.query(sql.prepare);
|
|
239
|
+
await client.query('SET LOCAL plan_cache_mode = force_generic_plan');
|
|
240
|
+
const res = await client.query(sql.explain);
|
|
241
|
+
const row = res.rows[0];
|
|
242
|
+
const payload = row ? Object.values(row)[0] : undefined;
|
|
243
|
+
result.verdicts[key] =
|
|
244
|
+
typeof payload === 'string'
|
|
245
|
+
? verdictFromPlanJson(JSON.parse(payload), finding.table)
|
|
246
|
+
: verdictFromPlanJson(payload, finding.table);
|
|
247
|
+
await client.query(sql.deallocate);
|
|
248
|
+
await client.query(`RELEASE SAVEPOINT ${name}`);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
result.verdicts[key] = 'unknown';
|
|
252
|
+
result.notices.push(`flip probe on ${finding.table}.${finding.column} was inconclusive (${err instanceof Error ? err.message.split('\n')[0] : String(err)}); the finding is kept`);
|
|
253
|
+
try {
|
|
254
|
+
await client.query(`ROLLBACK TO SAVEPOINT ${name}`);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// The transaction itself is gone; the remaining probes will each record
|
|
258
|
+
// their own notice and the pass still returns what it has.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
result.notices.push(`plan-flip probing unavailable (${err instanceof Error ? err.message.split('\n')[0] : String(err)}); findings are reported unverified`);
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
try {
|
|
268
|
+
await client.query('ROLLBACK');
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// Nothing to roll back.
|
|
272
|
+
}
|
|
273
|
+
await client.end().catch(() => { });
|
|
274
|
+
}
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Drop the findings the planner refuted, and record how many.
|
|
279
|
+
*
|
|
280
|
+
* Pure. `'unknown'` and a missing verdict both KEEP the finding: see the failure
|
|
281
|
+
* contract in the module header.
|
|
282
|
+
*/
|
|
283
|
+
function applyFlipVerdicts(report, probe) {
|
|
284
|
+
if (!probe.available)
|
|
285
|
+
return report;
|
|
286
|
+
let refuted = 0;
|
|
287
|
+
const findings = report.findings.filter((f) => {
|
|
288
|
+
if (!needsFlipProbe(f))
|
|
289
|
+
return true;
|
|
290
|
+
const verdict = probe.verdicts[flipProbeKey(f.table, f.column)];
|
|
291
|
+
if (verdict === 'no-flip') {
|
|
292
|
+
refuted++;
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
return true;
|
|
296
|
+
});
|
|
297
|
+
return {
|
|
298
|
+
...report,
|
|
299
|
+
findings,
|
|
300
|
+
flipProbed: true,
|
|
301
|
+
flipRefuted: refuted,
|
|
302
|
+
notices: [...report.notices, ...probe.notices.map((n) => ({ table: '', column: '', reason: n }))],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
@@ -31,6 +31,15 @@
|
|
|
31
31
|
* atomic.
|
|
32
32
|
* - **Result reshaping**: `_count` objects keyed back to Prisma relation names,
|
|
33
33
|
* and to-one relations surfaced as `object | null`.
|
|
34
|
+
* - **Turbine-native query options** (`timeout`, `forceCustomPlan`,
|
|
35
|
+
* `warnOnUnlimited`, `skipGlobalFilters`, `stableRelationOrder`,
|
|
36
|
+
* `allowFullTableScan`, `includePii`, `optimisticLock`, `distinctOn`) reach
|
|
37
|
+
* core on every operation whose arg surface declares them. The set is not a
|
|
38
|
+
* hand-maintained list here: it comes from the compiler-checked tables in
|
|
39
|
+
* `query/option-surface.ts`, so a new core option cannot be silently stranded
|
|
40
|
+
* by this layer, and a key that is neither a Prisma arg nor a turbine option
|
|
41
|
+
* gets a dev-mode warning instead of vanishing (see
|
|
42
|
+
* {@link PRISMA_ARG_KEYS} and `warnUnknownQueryOptions`).
|
|
34
43
|
*
|
|
35
44
|
* ## What it deliberately does NOT do (documented divergences)
|
|
36
45
|
*
|
|
@@ -63,6 +72,14 @@
|
|
|
63
72
|
* exclusive-cursor + `offset` translation.
|
|
64
73
|
* - **Negative `take`** (take-from-end) and **`skip` on a nested relation
|
|
65
74
|
* include** throw, Turbine's `with` clause has no offset and no reverse-take.
|
|
75
|
+
* - **`limit` on `updateMany` / `deleteMany`** (Prisma 6.7+) throws. Turbine has
|
|
76
|
+
* no row-bounded mass mutation, and dropping a SAFETY BOUND with a warning
|
|
77
|
+
* would turn "change at most 10 rows" into "change every matching row".
|
|
78
|
+
* - **Write projections** (`select` / `include` / `omit` on
|
|
79
|
+
* create/update/delete/upsert), **`select` on `count`**, and
|
|
80
|
+
* **`orderBy` / `cursor` / `take` / `skip` on `aggregate`** are accepted and
|
|
81
|
+
* IGNORED (they are legitimate Prisma, so they never warn); the full row / a
|
|
82
|
+
* plain number comes back.
|
|
66
83
|
*
|
|
67
84
|
* ## Type dependencies (0.41.0)
|
|
68
85
|
*
|
|
@@ -93,7 +110,7 @@
|
|
|
93
110
|
* ```
|
|
94
111
|
*/
|
|
95
112
|
import type { TurbineClient } from './client.js';
|
|
96
|
-
import type
|
|
113
|
+
import { type DeferredQuery } from './query/index.js';
|
|
97
114
|
import type { PrismaCompatMap, SchemaMetadata } from './schema.js';
|
|
98
115
|
/** A build-only query object with the `build*` methods the adapter drives. */
|
|
99
116
|
export interface CompatQueryInterface {
|
|
@@ -237,6 +254,20 @@ export interface PrismaCompatOptions {
|
|
|
237
254
|
*/
|
|
238
255
|
prismaErrorCodes?: boolean;
|
|
239
256
|
}
|
|
257
|
+
/** The delegate operations this adapter exposes. */
|
|
258
|
+
export type CompatOperation = 'findMany' | 'findFirst' | 'findFirstOrThrow' | 'findUnique' | 'findUniqueOrThrow' | 'create' | 'createMany' | 'update' | 'updateMany' | 'delete' | 'deleteMany' | 'upsert' | 'count' | 'aggregate' | 'groupBy';
|
|
259
|
+
/**
|
|
260
|
+
* Every argument key Prisma itself accepts, per operation.
|
|
261
|
+
*
|
|
262
|
+
* Extracted from a generated `@prisma/client` 7.9.0 `index.d.ts` (the
|
|
263
|
+
* `<Model><Op>Args` blocks). It must stay the UNION across the Prisma majors
|
|
264
|
+
* this adapter supports, never one version's set: a key a newer major
|
|
265
|
+
* introduces should degrade to one noisy dev line, never to a throw, and a key
|
|
266
|
+
* an older major had must keep working. The drift test in
|
|
267
|
+
* `src/test/prisma-compat-option-surface.test.ts` re-extracts from a generated
|
|
268
|
+
* client when one is present and asserts this stays a superset.
|
|
269
|
+
*/
|
|
270
|
+
export declare const PRISMA_ARG_KEYS: Record<CompatOperation, readonly string[]>;
|
|
240
271
|
/** Symbol under which a lazy delegate call exposes its batchable plan. */
|
|
241
272
|
export declare const COMPAT_DEFERRED: unique symbol;
|
|
242
273
|
/**
|