turbine-orm 0.67.0 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/cli/error-catalog.d.ts +77 -0
- package/dist/cjs/cli/error-catalog.js +388 -0
- package/dist/cjs/cli/index.js +3 -2
- package/dist/cjs/cli/mcp.d.ts +19 -3
- package/dist/cjs/cli/mcp.js +709 -22
- package/dist/cjs/cli/migrate.d.ts +19 -2
- package/dist/cjs/cli/observe.d.ts +2 -2
- package/dist/cjs/cli/observe.js +20 -2
- package/dist/cjs/cli/pii-predicate-guard.d.ts +6 -2
- package/dist/cjs/cli/pii-predicate-guard.js +6 -2
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/client.d.ts +30 -75
- package/dist/cjs/client.js +31 -11
- package/dist/cjs/introspect.d.ts +113 -17
- package/dist/cjs/introspect.js +229 -33
- package/dist/cjs/pg-types.d.ts +153 -0
- package/dist/cjs/pg-types.js +38 -0
- package/dist/cjs/pipeline.d.ts +3 -3
- package/dist/cjs/query/batched-loader.d.ts +2 -2
- package/dist/cjs/query/builder.d.ts +2 -2
- package/dist/cjs/query/builder.js +10 -1
- package/dist/cjs/query/deferred.d.ts +6 -6
- package/dist/cjs/query/filters.d.ts +13 -7
- package/dist/cjs/query/filters.js +13 -14
- package/dist/cjs/query/where.d.ts +2 -2
- package/dist/cjs/schema-sql.d.ts +18 -0
- package/dist/cjs/schema-sql.js +18 -0
- package/dist/cli/error-catalog.d.ts +77 -0
- package/dist/cli/error-catalog.js +383 -0
- package/dist/cli/index.js +3 -2
- package/dist/cli/mcp.d.ts +19 -3
- package/dist/cli/mcp.js +709 -23
- package/dist/cli/migrate.d.ts +19 -2
- package/dist/cli/observe.d.ts +2 -2
- package/dist/cli/observe.js +20 -2
- package/dist/cli/pii-predicate-guard.d.ts +6 -2
- package/dist/cli/pii-predicate-guard.js +6 -2
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/client.d.ts +30 -75
- package/dist/client.js +31 -11
- package/dist/introspect.d.ts +113 -17
- package/dist/introspect.js +227 -33
- package/dist/pg-types.d.ts +153 -0
- package/dist/pg-types.js +37 -0
- package/dist/pipeline.d.ts +3 -3
- package/dist/query/batched-loader.d.ts +2 -2
- package/dist/query/builder.d.ts +2 -2
- package/dist/query/builder.js +10 -1
- package/dist/query/deferred.d.ts +6 -6
- package/dist/query/filters.d.ts +13 -7
- package/dist/query/filters.js +13 -13
- package/dist/query/where-compile.js +1 -1
- package/dist/query/where.d.ts +2 -2
- package/dist/schema-sql.d.ts +18 -0
- package/dist/schema-sql.js +18 -0
- package/package.json +19 -7
|
@@ -10,7 +10,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
10
10
|
exports.MAX_NAMED_ORDER_KEYS = exports.VECTOR_DISTANCE_COMPARATORS = exports.VECTOR_METRIC_OPERATORS = exports.TEXT_SEARCH_KEYS = exports.ARRAY_UNIQUE_KEYS = exports.ARRAY_OPERATOR_KEYS = exports.JSONB_UNIQUE_KEYS = exports.JSON_FILTER_KEYS = exports.JSON_STRING_OPERATORS = exports.JSON_RANGE_OPERATORS = exports.JSONB_OPERATOR_KEYS = exports.UPDATE_OPERATOR_KEYS = exports.RELATION_FILTER_WRAPPER_SET = exports.RELATION_FILTER_WRAPPERS = exports.COLUMN_REF_OPERATORS = void 0;
|
|
11
11
|
exports.isWhereOperator = isWhereOperator;
|
|
12
12
|
exports.isUnmatchedPlainObject = isUnmatchedPlainObject;
|
|
13
|
-
exports.hasRelationFilterWrapper = hasRelationFilterWrapper;
|
|
14
13
|
exports.isColumnRef = isColumnRef;
|
|
15
14
|
exports.fingerprintOperatorShape = fingerprintOperatorShape;
|
|
16
15
|
exports.assertBindableEqualsOperand = assertBindableEqualsOperand;
|
|
@@ -96,23 +95,23 @@ exports.COLUMN_REF_OPERATORS = new Set(['equals', 'not', 'gt', 'gte', 'lt', 'lte
|
|
|
96
95
|
* dependency is fixed: `cli/` and the prisma-compat shim may import from the
|
|
97
96
|
* query path, and the query path may never import from `cli/`
|
|
98
97
|
* (`scripts/check-import-cycles.mjs`).
|
|
98
|
+
*
|
|
99
|
+
* ## STATUS: a landing spot, not yet a deduplication. Read this before trusting it.
|
|
100
|
+
*
|
|
101
|
+
* NOTHING IMPORTS THIS YET. The three copies listed above are all still in
|
|
102
|
+
* place and still hand-synced; declaring the canonical home did not by itself
|
|
103
|
+
* move any of them onto it. It is kept, rather than deleted as unused, because
|
|
104
|
+
* `cli/pii-predicate-guard.ts` names this module as where its own copy wants to
|
|
105
|
+
* go, and deleting the destination is the one change that makes converging
|
|
106
|
+
* harder. Wiring the copies up is a separate change: each one sits on a
|
|
107
|
+
* different walk, so each has to be re-tested on its own.
|
|
108
|
+
*
|
|
109
|
+
* So do not read this constant as evidence that the walkers agree. The comment
|
|
110
|
+
* on each copy is still the only thing holding them in step.
|
|
99
111
|
*/
|
|
100
112
|
exports.RELATION_FILTER_WRAPPERS = ['some', 'none', 'every', 'is', 'isNot'];
|
|
101
113
|
/** {@link RELATION_FILTER_WRAPPERS} as a membership set, for the walkers. */
|
|
102
114
|
exports.RELATION_FILTER_WRAPPER_SET = new Set(exports.RELATION_FILTER_WRAPPERS);
|
|
103
|
-
/**
|
|
104
|
-
* True when a normalized relation-filter body carries at least one cardinality
|
|
105
|
-
* wrapper. THE predicate the SQL compiler branches on: a key that names a
|
|
106
|
-
* relation but whose value is not one of these falls through to the scalar
|
|
107
|
-
* path.
|
|
108
|
-
*/
|
|
109
|
-
function hasRelationFilterWrapper(filterObj) {
|
|
110
|
-
for (const wrapper of exports.RELATION_FILTER_WRAPPERS) {
|
|
111
|
-
if (wrapper in filterObj)
|
|
112
|
-
return true;
|
|
113
|
-
}
|
|
114
|
-
return false;
|
|
115
|
-
}
|
|
116
115
|
/**
|
|
117
116
|
* Check if an operator value is a column reference: a plain object whose ONLY
|
|
118
117
|
* key is `col` with a string value. Anything else (extra keys, non-string
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* instance (built once in its constructor) exposing exactly the class-resident
|
|
11
11
|
* primitives this module needs. See builder.ts for the thin delegating methods.
|
|
12
12
|
*/
|
|
13
|
-
import type pg from 'pg';
|
|
14
13
|
import type { Dialect } from '../dialect.js';
|
|
15
14
|
import { ValidationError } from '../errors.js';
|
|
15
|
+
import type { PgCompatQueryResult } from '../pg-types.js';
|
|
16
16
|
import type { RelationDef, SchemaMetadata, TableMetadata } from '../schema.js';
|
|
17
17
|
import type { TemporalInfinityReading } from './deferred.js';
|
|
18
18
|
import type { ArrayFilter, ColumnRef, GlobalFilters, JsonFilter, JsonPathOrderBy, ResolvedSkipGlobalFilters, TextSearchFilter, VectorFilter, WhereClause, WhereOperator } from './types.js';
|
|
@@ -127,7 +127,7 @@ export interface BuilderCtx {
|
|
|
127
127
|
resolveOrderByColumn(table: string, meta: TableMetadata, key: string): string;
|
|
128
128
|
buildJsonPathOrderEntry(table: string, meta: TableMetadata, field: string, spec: JsonPathOrderBy, prefix: string, params?: unknown[]): string;
|
|
129
129
|
toSqlColumn(field: string): string;
|
|
130
|
-
mutationInsertId(result:
|
|
130
|
+
mutationInsertId(result: PgCompatQueryResult): unknown;
|
|
131
131
|
acquireSql(cacheKey: string, build: (params: unknown[]) => string): SqlCacheEntry;
|
|
132
132
|
crossCheckCache(op: string, cacheKey: string, entry: SqlCacheEntry, build: (params: unknown[]) => string, collectedParams: unknown[]): void;
|
|
133
133
|
readonly jsonEncoding: 'object' | 'positional';
|
package/dist/cjs/schema-sql.d.ts
CHANGED
|
@@ -35,6 +35,24 @@ export declare function schemaToSQL(schema: SchemaDef, options?: SchemaSqlOption
|
|
|
35
35
|
* column list), or null when the definitions agree. Expression/partial indexes
|
|
36
36
|
* in the DB never structurally match a plain column list, which is the
|
|
37
37
|
* intended outcome: the operator gets a warning rather than a silent skip.
|
|
38
|
+
*
|
|
39
|
+
* ## Parser 3 of 3, and what it is safe for
|
|
40
|
+
*
|
|
41
|
+
* THREE indexdef parsers coexist in this repo, catalogued on
|
|
42
|
+
* `parseIndexKeyEntries` in introspect.ts: that character-by-character scanner
|
|
43
|
+
* (the one to prefer for new callers), `parsePlainUniqueIndexColumns` in the
|
|
44
|
+
* same file, and this one. The two in introspect.ts do NOT feed this function
|
|
45
|
+
* and a fix to either does not reach it.
|
|
46
|
+
*
|
|
47
|
+
* This still uses the `USING \w+ \(([^)]*)\)` regex the scanner was written to
|
|
48
|
+
* replace, so it inherits the same weaknesses: it stops at the FIRST `)`, and it
|
|
49
|
+
* splits on every comma, which misreads an expression key (`lower(email)`), a
|
|
50
|
+
* quoted identifier containing a comma or a paren, and an opclass'd key. Safe
|
|
51
|
+
* here because every outcome of a misread is the SAME outcome as a genuine
|
|
52
|
+
* mismatch: a string describing the difference, which the caller turns into a
|
|
53
|
+
* warning for a human to read. It never drops or alters an index, and it is
|
|
54
|
+
* never consulted about whether a column set is unique. Do not reuse it
|
|
55
|
+
* anywhere those two properties stop holding.
|
|
38
56
|
*/
|
|
39
57
|
export declare function describeIndexDefMismatch(idx: ColumnIndexDef, indexdef: string): string | null;
|
|
40
58
|
/**
|
package/dist/cjs/schema-sql.js
CHANGED
|
@@ -403,6 +403,24 @@ function declaredIndexName(tableName, idx) {
|
|
|
403
403
|
* column list), or null when the definitions agree. Expression/partial indexes
|
|
404
404
|
* in the DB never structurally match a plain column list, which is the
|
|
405
405
|
* intended outcome: the operator gets a warning rather than a silent skip.
|
|
406
|
+
*
|
|
407
|
+
* ## Parser 3 of 3, and what it is safe for
|
|
408
|
+
*
|
|
409
|
+
* THREE indexdef parsers coexist in this repo, catalogued on
|
|
410
|
+
* `parseIndexKeyEntries` in introspect.ts: that character-by-character scanner
|
|
411
|
+
* (the one to prefer for new callers), `parsePlainUniqueIndexColumns` in the
|
|
412
|
+
* same file, and this one. The two in introspect.ts do NOT feed this function
|
|
413
|
+
* and a fix to either does not reach it.
|
|
414
|
+
*
|
|
415
|
+
* This still uses the `USING \w+ \(([^)]*)\)` regex the scanner was written to
|
|
416
|
+
* replace, so it inherits the same weaknesses: it stops at the FIRST `)`, and it
|
|
417
|
+
* splits on every comma, which misreads an expression key (`lower(email)`), a
|
|
418
|
+
* quoted identifier containing a comma or a paren, and an opclass'd key. Safe
|
|
419
|
+
* here because every outcome of a misread is the SAME outcome as a genuine
|
|
420
|
+
* mismatch: a string describing the difference, which the caller turns into a
|
|
421
|
+
* warning for a human to read. It never drops or alters an index, and it is
|
|
422
|
+
* never consulted about whether a column set is unique. Do not reuse it
|
|
423
|
+
* anywhere those two properties stop holding.
|
|
406
424
|
*/
|
|
407
425
|
function describeIndexDefMismatch(idx, indexdef) {
|
|
408
426
|
const dbUnique = /^\s*CREATE\s+UNIQUE\s+INDEX\b/i.test(indexdef);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm CLI: the error-code catalog behind `explain_error`.
|
|
3
|
+
*
|
|
4
|
+
* A pure leaf module, the same role `cli/rate-limit.ts`, `cli/destructive.ts`
|
|
5
|
+
* and `cli/pii-predicate-guard.ts` play: no I/O, no state, and no import from a
|
|
6
|
+
* sibling CLI module. Its ONE dependency is `../errors.js`, which is where the
|
|
7
|
+
* codes and the docs URL actually live.
|
|
8
|
+
*
|
|
9
|
+
* WHY IT EXISTS. An agent that catches `[TURBINE_E003] Unknown column "titel"`
|
|
10
|
+
* has the code and nothing else. `errors.ts` knows the class and the docs
|
|
11
|
+
* anchor but carries no prose about causes or repairs, and the prose that does
|
|
12
|
+
* exist lives on the docs site, which an offline agent cannot read and which a
|
|
13
|
+
* `WebFetch` of a marketing page is a poor way to consult. This turns "I got
|
|
14
|
+
* E015" into "this is an OptimisticLockError, here is why it fired, here is what
|
|
15
|
+
* to do", with no database, no network, and no tokens spent re-deriving it.
|
|
16
|
+
*
|
|
17
|
+
* TWO RULES KEEP IT HONEST.
|
|
18
|
+
*
|
|
19
|
+
* 1. `docsUrl` is NEVER written out here. It is read off a real
|
|
20
|
+
* {@link TurbineError} instance, so the URL an agent is handed is
|
|
21
|
+
* byte-identical to the one on the error it actually caught. A second
|
|
22
|
+
* hand-maintained copy of `docsUrlForCode` is exactly the drift this repo
|
|
23
|
+
* has been bitten by before.
|
|
24
|
+
* 2. The catalog is keyed by `TurbineErrorCode`, exhaustively. Adding a code to
|
|
25
|
+
* `errors.ts` without adding a row here FAILS THE BUILD (the mapped type
|
|
26
|
+
* below requires every key), and a row for a code that no longer exists
|
|
27
|
+
* fails as an excess property. Same stance as `query/option-surface.ts`:
|
|
28
|
+
* a human classifies the new thing, the compiler notices when nobody did.
|
|
29
|
+
*/
|
|
30
|
+
import { type TurbineErrorCode } from '../errors.js';
|
|
31
|
+
/** One code's explanation, as `explain_error` returns it. */
|
|
32
|
+
export interface ErrorExplanation {
|
|
33
|
+
/** The canonical code, e.g. `TURBINE_E003`. */
|
|
34
|
+
code: TurbineErrorCode;
|
|
35
|
+
/** The exported class name, e.g. `ValidationError`. */
|
|
36
|
+
className: string;
|
|
37
|
+
/** Anchor into the published error table. Sourced from a real error instance. */
|
|
38
|
+
docsUrl: string;
|
|
39
|
+
/** True only for the two errors carrying `isRetryable: true as const`. */
|
|
40
|
+
retryable: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* How the error reaches you: raised by Turbine itself, or translated from a
|
|
43
|
+
* PostgreSQL SQLSTATE by `wrapPgError()`. An agent chasing a `wrapped` error
|
|
44
|
+
* should be reading the database's constraint, not Turbine's call site.
|
|
45
|
+
*/
|
|
46
|
+
origin: 'turbine' | 'wrapped-pg';
|
|
47
|
+
/** The SQLSTATE `wrapPgError()` maps, for `origin: 'wrapped-pg'`. */
|
|
48
|
+
sqlstate?: string;
|
|
49
|
+
/** One sentence: the condition that raises it. */
|
|
50
|
+
whenThrown: string;
|
|
51
|
+
/** The concrete situations that produce it, most common first. */
|
|
52
|
+
likelyCauses: string[];
|
|
53
|
+
/** What to change, in the same order. */
|
|
54
|
+
howToFix: string[];
|
|
55
|
+
/** Extra own properties the error carries beyond `code` / `message` / `docsUrl`. */
|
|
56
|
+
properties: string[];
|
|
57
|
+
}
|
|
58
|
+
/** Every code in the catalog, in code order. */
|
|
59
|
+
export declare const CATALOGUED_ERROR_CODES: TurbineErrorCode[];
|
|
60
|
+
/**
|
|
61
|
+
* Accept the spellings a caller actually types and return the canonical code,
|
|
62
|
+
* or `null`.
|
|
63
|
+
*
|
|
64
|
+
* A code arrives from a log line (`TURBINE_E003`), from a docs anchor (`e003`),
|
|
65
|
+
* from prose ("E3"), or as a bare number. All of them mean the same code, and
|
|
66
|
+
* refusing three of the four teaches an agent to give up rather than to
|
|
67
|
+
* normalize. What is NOT accepted is anything that resolves to no code at all:
|
|
68
|
+
* the caller gets `null` and a list, never a guess.
|
|
69
|
+
*/
|
|
70
|
+
export declare function normalizeErrorCode(input: string): TurbineErrorCode | null;
|
|
71
|
+
/**
|
|
72
|
+
* The full explanation for a code, or `null` when the input names no code.
|
|
73
|
+
*
|
|
74
|
+
* `docsUrl` is read off a real {@link TurbineError} rather than formatted here,
|
|
75
|
+
* so it cannot drift from the URL on the error an agent actually caught.
|
|
76
|
+
*/
|
|
77
|
+
export declare function explainErrorCode(input: string): ErrorExplanation | null;
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm CLI: the error-code catalog behind `explain_error`.
|
|
3
|
+
*
|
|
4
|
+
* A pure leaf module, the same role `cli/rate-limit.ts`, `cli/destructive.ts`
|
|
5
|
+
* and `cli/pii-predicate-guard.ts` play: no I/O, no state, and no import from a
|
|
6
|
+
* sibling CLI module. Its ONE dependency is `../errors.js`, which is where the
|
|
7
|
+
* codes and the docs URL actually live.
|
|
8
|
+
*
|
|
9
|
+
* WHY IT EXISTS. An agent that catches `[TURBINE_E003] Unknown column "titel"`
|
|
10
|
+
* has the code and nothing else. `errors.ts` knows the class and the docs
|
|
11
|
+
* anchor but carries no prose about causes or repairs, and the prose that does
|
|
12
|
+
* exist lives on the docs site, which an offline agent cannot read and which a
|
|
13
|
+
* `WebFetch` of a marketing page is a poor way to consult. This turns "I got
|
|
14
|
+
* E015" into "this is an OptimisticLockError, here is why it fired, here is what
|
|
15
|
+
* to do", with no database, no network, and no tokens spent re-deriving it.
|
|
16
|
+
*
|
|
17
|
+
* TWO RULES KEEP IT HONEST.
|
|
18
|
+
*
|
|
19
|
+
* 1. `docsUrl` is NEVER written out here. It is read off a real
|
|
20
|
+
* {@link TurbineError} instance, so the URL an agent is handed is
|
|
21
|
+
* byte-identical to the one on the error it actually caught. A second
|
|
22
|
+
* hand-maintained copy of `docsUrlForCode` is exactly the drift this repo
|
|
23
|
+
* has been bitten by before.
|
|
24
|
+
* 2. The catalog is keyed by `TurbineErrorCode`, exhaustively. Adding a code to
|
|
25
|
+
* `errors.ts` without adding a row here FAILS THE BUILD (the mapped type
|
|
26
|
+
* below requires every key), and a row for a code that no longer exists
|
|
27
|
+
* fails as an excess property. Same stance as `query/option-surface.ts`:
|
|
28
|
+
* a human classifies the new thing, the compiler notices when nobody did.
|
|
29
|
+
*/
|
|
30
|
+
import { TurbineError } from '../errors.js';
|
|
31
|
+
/**
|
|
32
|
+
* Every code, explained. Exhaustive by construction: `Record<TurbineErrorCode,
|
|
33
|
+
* …>` means a new code in `errors.ts` does not compile until it is written up
|
|
34
|
+
* here, and a code removed there leaves an excess property that also does not
|
|
35
|
+
* compile.
|
|
36
|
+
*/
|
|
37
|
+
const CATALOG = {
|
|
38
|
+
TURBINE_E001: {
|
|
39
|
+
className: 'NotFoundError',
|
|
40
|
+
retryable: false,
|
|
41
|
+
origin: 'turbine',
|
|
42
|
+
whenThrown: 'A query that promises a row did not find one.',
|
|
43
|
+
likelyCauses: [
|
|
44
|
+
'`findUniqueOrThrow` / `findFirstOrThrow` matched no row.',
|
|
45
|
+
'`update` / `delete` addressed a row that does not exist, or that a global filter excludes.',
|
|
46
|
+
'The where clause names the right column but the wrong value (a stale id, a string/number mismatch on the key).',
|
|
47
|
+
'A tenant/global filter configured on the client narrowed the query to zero rows without the caller knowing.',
|
|
48
|
+
],
|
|
49
|
+
howToFix: [
|
|
50
|
+
'Use `findUnique` / `findFirst` and branch on `null` when absence is a normal outcome.',
|
|
51
|
+
'Read `err.table`, `err.where` and `err.operation` to see exactly what was addressed; the message redacts the VALUES unless the client is on `errorMessages: "verbose"`.',
|
|
52
|
+
'If a global filter is in play, re-run the same query with `skipGlobalFilters: UNSAFE` to confirm the row exists but is filtered.',
|
|
53
|
+
],
|
|
54
|
+
properties: ['table', 'where', 'operation'],
|
|
55
|
+
},
|
|
56
|
+
TURBINE_E002: {
|
|
57
|
+
className: 'TimeoutError',
|
|
58
|
+
retryable: false,
|
|
59
|
+
origin: 'turbine',
|
|
60
|
+
whenThrown: 'A query or transaction ran past its configured timeout.',
|
|
61
|
+
likelyCauses: [
|
|
62
|
+
'A missing index turned a relation probe or a where clause into a sequential scan.',
|
|
63
|
+
'A transaction held a lock another transaction was waiting on.',
|
|
64
|
+
'The timeout is simply lower than the work (a large `createMany`, an unbounded `findMany`).',
|
|
65
|
+
],
|
|
66
|
+
howToFix: [
|
|
67
|
+
'Run `doctor_report` for missing relation indexes, and `explain_query` on the shape that timed out.',
|
|
68
|
+
'Read `err.timeoutMs` for the limit that was hit; raise it per query with the `timeout` option rather than globally.',
|
|
69
|
+
'Bound the read: add a `limit`, or paginate.',
|
|
70
|
+
],
|
|
71
|
+
properties: ['timeoutMs'],
|
|
72
|
+
},
|
|
73
|
+
TURBINE_E003: {
|
|
74
|
+
className: 'ValidationError',
|
|
75
|
+
retryable: false,
|
|
76
|
+
origin: 'turbine',
|
|
77
|
+
whenThrown: 'The query args were rejected before any SQL ran.',
|
|
78
|
+
likelyCauses: [
|
|
79
|
+
'A name that resolves to no column: in `where`, `orderBy`, `distinct`, a `groupBy` `by` key, an aggregate target, create/update `data`, or (since 0.64) `select` / `omit` at any depth.',
|
|
80
|
+
'A relation named in `select` or `omit`. Relations load through `with`, which is a SIBLING of `select`, not a member of it.',
|
|
81
|
+
'A projection shape that selects nothing: an empty or all-false `select`, or `select` and `omit` on the same query.',
|
|
82
|
+
'The empty-where guard: `update` / `delete` / `updateMany` / `deleteMany` with `{}` or an all-undefined where.',
|
|
83
|
+
'An aggregate over a PII-tagged column (`_min` / `_max`, or a PII column as a `groupBy` key) with no `includePii`.',
|
|
84
|
+
'A privilege option (`includePii`, `skipGlobalFilters`, `allowFullTableScan`) passed `true` instead of the `UNSAFE` symbol.',
|
|
85
|
+
'`forceCustomPlan` against a client pinned to `planCacheMode: "force_generic_plan"`.',
|
|
86
|
+
],
|
|
87
|
+
howToFix: [
|
|
88
|
+
'Read the message: it names the table and, where it can, the column you probably meant.',
|
|
89
|
+
'For a relation, move it out of `select` and into `with: { name: true }`.',
|
|
90
|
+
'For a mass mutation you really do want, pass `allowFullTableScan: UNSAFE` explicitly.',
|
|
91
|
+
'Import `UNSAFE` from the package root for any privilege option; `JSON.parse` cannot produce a symbol, which is the point.',
|
|
92
|
+
],
|
|
93
|
+
properties: [],
|
|
94
|
+
},
|
|
95
|
+
TURBINE_E004: {
|
|
96
|
+
className: 'ConnectionError',
|
|
97
|
+
retryable: false,
|
|
98
|
+
origin: 'turbine',
|
|
99
|
+
whenThrown: 'The pool could not establish or keep a connection.',
|
|
100
|
+
likelyCauses: [
|
|
101
|
+
'Nothing is listening (ECONNREFUSED), DNS failed (ENOTFOUND / EAI_AGAIN), or the connect timed out (ETIMEDOUT).',
|
|
102
|
+
'TLS: a self-signed or expired certificate, or a hostname the certificate does not cover.',
|
|
103
|
+
'The connection string is malformed, or points at a pooler port the server is not on.',
|
|
104
|
+
'The server closed an idle pooled connection (proxy idle timeout, restart, failover).',
|
|
105
|
+
],
|
|
106
|
+
howToFix: [
|
|
107
|
+
'Read `err.sqlstate` and the hint in the message: each driver code carries its own next step.',
|
|
108
|
+
'Check the host, port and database name; for TLS, supply the CA via `ssl: { ca }` rather than disabling verification.',
|
|
109
|
+
'For a serverless driver, pass the external pool on `TurbineConfig.pool` instead of a connection string.',
|
|
110
|
+
],
|
|
111
|
+
properties: ['sqlstate'],
|
|
112
|
+
},
|
|
113
|
+
TURBINE_E005: {
|
|
114
|
+
className: 'RelationError',
|
|
115
|
+
retryable: false,
|
|
116
|
+
origin: 'turbine',
|
|
117
|
+
whenThrown: 'A `with` clause named a relation the schema does not declare.',
|
|
118
|
+
likelyCauses: [
|
|
119
|
+
'A typo, or a Prisma-style name that Turbine derives differently.',
|
|
120
|
+
'The generated metadata is stale: the relation exists in the database but `turbine generate` has not been re-run.',
|
|
121
|
+
'A UNIQUE foreign key, which Turbine derives as a singular `hasOne` (`user.profile`) rather than a plural `hasMany` (`user.profiles`).',
|
|
122
|
+
],
|
|
123
|
+
howToFix: [
|
|
124
|
+
'Call `relation_graph` (optionally scoped with `table`) and read the relation names Turbine actually derived, rather than guessing them.',
|
|
125
|
+
'Call `find_join_path` for the `with` clause to write, instead of assembling one by hand.',
|
|
126
|
+
'Re-run `turbine generate` if the database has changed.',
|
|
127
|
+
],
|
|
128
|
+
properties: [],
|
|
129
|
+
},
|
|
130
|
+
TURBINE_E006: {
|
|
131
|
+
className: 'MigrationError',
|
|
132
|
+
retryable: false,
|
|
133
|
+
origin: 'turbine',
|
|
134
|
+
whenThrown: 'A migration could not be parsed, verified, or applied.',
|
|
135
|
+
likelyCauses: [
|
|
136
|
+
'A checksum mismatch: an already-applied migration file was edited after the fact.',
|
|
137
|
+
'A migration file with no `-- UP` section, or an unparseable one.',
|
|
138
|
+
'The advisory migration lock was already held by a concurrent runner.',
|
|
139
|
+
'The SQL itself failed; the migration is rolled back, and the underlying error is the `cause`.',
|
|
140
|
+
],
|
|
141
|
+
howToFix: [
|
|
142
|
+
'Run `migrate_status` to see applied / pending / drifted counts and which file drifted.',
|
|
143
|
+
'Never edit an applied migration. Write a new one that makes the correction.',
|
|
144
|
+
'For lock contention, wait for the other runner; `migrate deploy` is the no-prompt CI form.',
|
|
145
|
+
],
|
|
146
|
+
properties: [],
|
|
147
|
+
},
|
|
148
|
+
TURBINE_E007: {
|
|
149
|
+
className: 'CircularRelationError',
|
|
150
|
+
retryable: false,
|
|
151
|
+
origin: 'turbine',
|
|
152
|
+
whenThrown: 'A `with` clause nested more than 10 relation levels deep.',
|
|
153
|
+
likelyCauses: [
|
|
154
|
+
'A back-reference walked in a loop: `user -> posts -> user -> posts -> …`.',
|
|
155
|
+
'A genuinely deep tree built by concatenating `with` fragments programmatically.',
|
|
156
|
+
],
|
|
157
|
+
howToFix: [
|
|
158
|
+
'Read `err.path` for the exact trail that hit the cap.',
|
|
159
|
+
'Back-references are legal; the cap is on DEPTH, so re-root the query at the level you actually need instead of walking back up.',
|
|
160
|
+
'Split one very deep read into two shallower queries.',
|
|
161
|
+
],
|
|
162
|
+
properties: ['path'],
|
|
163
|
+
},
|
|
164
|
+
TURBINE_E008: {
|
|
165
|
+
className: 'UniqueConstraintError',
|
|
166
|
+
retryable: false,
|
|
167
|
+
origin: 'wrapped-pg',
|
|
168
|
+
sqlstate: '23505',
|
|
169
|
+
whenThrown: 'A write violated a unique constraint or unique index.',
|
|
170
|
+
likelyCauses: [
|
|
171
|
+
'An insert of a value that already exists (the classic duplicate email).',
|
|
172
|
+
'An update that moved a row onto an existing key.',
|
|
173
|
+
'A race: two concurrent inserts of the same key, where a read-then-insert check passed in both.',
|
|
174
|
+
],
|
|
175
|
+
howToFix: [
|
|
176
|
+
'Read `err.constraint`, `err.columns` and `err.table` and map the constraint to a user-facing message (HTTP 409).',
|
|
177
|
+
'Use `upsert`, or `createMany({ skipDuplicates: true })`, instead of check-then-insert.',
|
|
178
|
+
'Never branch on the message text; branch on `err.code` or `instanceof UniqueConstraintError`.',
|
|
179
|
+
],
|
|
180
|
+
properties: ['constraint', 'columns', 'table'],
|
|
181
|
+
},
|
|
182
|
+
TURBINE_E009: {
|
|
183
|
+
className: 'ForeignKeyError',
|
|
184
|
+
retryable: false,
|
|
185
|
+
origin: 'wrapped-pg',
|
|
186
|
+
sqlstate: '23503',
|
|
187
|
+
whenThrown: 'A write violated a foreign key constraint.',
|
|
188
|
+
likelyCauses: [
|
|
189
|
+
'An insert or update pointing a foreign key at a parent row that does not exist.',
|
|
190
|
+
'A delete of a parent row that still has children, where the constraint is `NO ACTION` / `RESTRICT`.',
|
|
191
|
+
'Rows written in the wrong order inside a transaction.',
|
|
192
|
+
],
|
|
193
|
+
howToFix: [
|
|
194
|
+
'Read `err.constraint` and `err.table` to see which side failed.',
|
|
195
|
+
'Use a nested write (`data: { child: { create: … } }`), which orders the inserts for you inside one transaction.',
|
|
196
|
+
'To delete a parent, delete or re-point its children first, or declare `ON DELETE CASCADE`.',
|
|
197
|
+
],
|
|
198
|
+
properties: ['constraint', 'table'],
|
|
199
|
+
},
|
|
200
|
+
TURBINE_E010: {
|
|
201
|
+
className: 'NotNullViolationError',
|
|
202
|
+
retryable: false,
|
|
203
|
+
origin: 'wrapped-pg',
|
|
204
|
+
sqlstate: '23502',
|
|
205
|
+
whenThrown: 'A write left a NOT NULL column with no value.',
|
|
206
|
+
likelyCauses: [
|
|
207
|
+
'A required column omitted from `data`.',
|
|
208
|
+
'An explicit `null` written to a NOT NULL column.',
|
|
209
|
+
'A migration that added a NOT NULL column with no default while old code still inserts without it.',
|
|
210
|
+
],
|
|
211
|
+
howToFix: [
|
|
212
|
+
'Read `err.column` and `err.table` for the exact column.',
|
|
213
|
+
'Supply the value, or give the column a database default.',
|
|
214
|
+
'For an existing table, add the column nullable, backfill, then `SET NOT NULL` (`turbine migrate create <name> --recipe backfill` scaffolds this).',
|
|
215
|
+
],
|
|
216
|
+
properties: ['column', 'table'],
|
|
217
|
+
},
|
|
218
|
+
TURBINE_E011: {
|
|
219
|
+
className: 'CheckConstraintError',
|
|
220
|
+
retryable: false,
|
|
221
|
+
origin: 'wrapped-pg',
|
|
222
|
+
sqlstate: '23514',
|
|
223
|
+
whenThrown: 'A write violated a CHECK constraint.',
|
|
224
|
+
likelyCauses: [
|
|
225
|
+
'A value outside the range the constraint allows (a negative quantity, an out-of-set status string).',
|
|
226
|
+
'An atomic update operator (`decrement`) driving a column past a bound the constraint enforces.',
|
|
227
|
+
],
|
|
228
|
+
howToFix: [
|
|
229
|
+
'Read `err.constraint` and `err.table`, then read the constraint body: `table_detail` reports named check constraints where the schema declares them.',
|
|
230
|
+
'Validate in application code before the write, so the user gets a field-level message rather than a 500.',
|
|
231
|
+
],
|
|
232
|
+
properties: ['constraint', 'table'],
|
|
233
|
+
},
|
|
234
|
+
TURBINE_E012: {
|
|
235
|
+
className: 'DeadlockError',
|
|
236
|
+
retryable: true,
|
|
237
|
+
origin: 'wrapped-pg',
|
|
238
|
+
sqlstate: '40P01',
|
|
239
|
+
whenThrown: 'PostgreSQL detected a deadlock and cancelled this transaction.',
|
|
240
|
+
likelyCauses: [
|
|
241
|
+
'Two transactions locking the same rows in opposite order.',
|
|
242
|
+
'A long transaction holding a lock while doing unrelated work.',
|
|
243
|
+
],
|
|
244
|
+
howToFix: [
|
|
245
|
+
'Retry. `err.isRetryable === true`, and `withRetry(fn)` / `db.$retry(fn)` retries exactly the errors carrying that flag.',
|
|
246
|
+
'Lock rows in a consistent order across code paths (for example, always ascending by primary key).',
|
|
247
|
+
'Shorten transactions: do the I/O and the computation outside, the writes inside.',
|
|
248
|
+
],
|
|
249
|
+
properties: ['isRetryable', 'constraint'],
|
|
250
|
+
},
|
|
251
|
+
TURBINE_E013: {
|
|
252
|
+
className: 'SerializationFailureError',
|
|
253
|
+
retryable: true,
|
|
254
|
+
origin: 'wrapped-pg',
|
|
255
|
+
sqlstate: '40001',
|
|
256
|
+
whenThrown: 'A SERIALIZABLE or REPEATABLE READ transaction could not be serialized.',
|
|
257
|
+
likelyCauses: [
|
|
258
|
+
'Concurrent transactions at `Serializable` touching an overlapping row set.',
|
|
259
|
+
'A read-modify-write on a hot row under `Repeatable Read`.',
|
|
260
|
+
],
|
|
261
|
+
howToFix: [
|
|
262
|
+
'Retry: this is the expected, designed outcome at these isolation levels, not a bug. `withRetry(fn)` / `db.$retry(fn)` handles it.',
|
|
263
|
+
'Where the operation is a pure increment, use an atomic update operator (`{ increment: 1 }`) instead of read-then-write.',
|
|
264
|
+
'Consider whether the transaction genuinely needs `Serializable`.',
|
|
265
|
+
],
|
|
266
|
+
properties: ['isRetryable'],
|
|
267
|
+
},
|
|
268
|
+
TURBINE_E014: {
|
|
269
|
+
className: 'PipelineError',
|
|
270
|
+
retryable: false,
|
|
271
|
+
origin: 'turbine',
|
|
272
|
+
whenThrown: 'A non-transactional pipeline (`{ transactional: false }`) had at least one failing query.',
|
|
273
|
+
likelyCauses: ['One query in the batch failed while others succeeded, so there is no single error to throw.'],
|
|
274
|
+
howToFix: [
|
|
275
|
+
'Read `err.results`: one slot per query, each `{ status: "ok", value }` or `{ status: "error", error }`, with the real typed error inside.',
|
|
276
|
+
'`err.failedIndex` and `err.failedTag` point at the first failure.',
|
|
277
|
+
'If partial success is not acceptable, drop `transactional: false`; a transactional pipeline either fully succeeds or rolls back.',
|
|
278
|
+
],
|
|
279
|
+
properties: ['results', 'failedIndex', 'failedTag'],
|
|
280
|
+
},
|
|
281
|
+
TURBINE_E015: {
|
|
282
|
+
className: 'OptimisticLockError',
|
|
283
|
+
retryable: false,
|
|
284
|
+
origin: 'turbine',
|
|
285
|
+
whenThrown: 'An `optimisticLock` update found no row at the expected version.',
|
|
286
|
+
likelyCauses: [
|
|
287
|
+
'Another transaction updated the row between your read and your write. This is the mechanism working.',
|
|
288
|
+
'The version value passed was stale (held across a user interaction, or cached).',
|
|
289
|
+
'The row was deleted.',
|
|
290
|
+
],
|
|
291
|
+
howToFix: [
|
|
292
|
+
'Re-read the row, re-apply the change to the fresh values, and write again. Do NOT blindly retry the same payload: the point of the check is that the underlying data moved.',
|
|
293
|
+
'Read `err.table`, `err.versionField` and `err.expectedVersion` to report the conflict to the user.',
|
|
294
|
+
'It is deliberately NOT flagged retryable: an automatic retry would defeat the guard.',
|
|
295
|
+
],
|
|
296
|
+
properties: ['table', 'versionField', 'expectedVersion'],
|
|
297
|
+
},
|
|
298
|
+
TURBINE_E016: {
|
|
299
|
+
className: 'ExclusionConstraintError',
|
|
300
|
+
retryable: false,
|
|
301
|
+
origin: 'wrapped-pg',
|
|
302
|
+
sqlstate: '23P01',
|
|
303
|
+
whenThrown: 'A write violated an EXCLUDE constraint.',
|
|
304
|
+
likelyCauses: [
|
|
305
|
+
'Overlapping ranges where the constraint forbids overlap (the canonical booking / reservation clash).',
|
|
306
|
+
],
|
|
307
|
+
howToFix: [
|
|
308
|
+
'Read `err.constraint` and `err.table`, then translate the clash into a domain message ("that slot is taken").',
|
|
309
|
+
'Query for the conflicting row with a range-overlap filter so the user can be shown WHAT it clashes with.',
|
|
310
|
+
],
|
|
311
|
+
properties: ['constraint', 'table'],
|
|
312
|
+
},
|
|
313
|
+
TURBINE_E017: {
|
|
314
|
+
className: 'UnsupportedFeatureError',
|
|
315
|
+
retryable: false,
|
|
316
|
+
origin: 'turbine',
|
|
317
|
+
whenThrown: 'This build cannot do that: a capability flag on the active dialect reports the feature unsupported, so Turbine refuses instead of emitting broken SQL.',
|
|
318
|
+
likelyCauses: [
|
|
319
|
+
'A Postgres-only feature on another engine: pgvector distance operators, `$listen` / `$notify`, RLS `sessionContext` / `$withSession`, `planCacheMode`.',
|
|
320
|
+
'NOT only a wrong-engine error, and reading it that way sends you looking in the wrong place. PostgreSQL raises it too: `relationLoadStrategy: "batched"` on a COMPOSITE-key relation refuses rather than loading a wrong set.',
|
|
321
|
+
'On PowDB: a nested `$transaction`, a re-entrant `$transaction`, or a feature above the connected engine version (the message carries the upgrade hint).',
|
|
322
|
+
'`limit` on `updateMany` / `deleteMany` through the prisma-compat adapter.',
|
|
323
|
+
],
|
|
324
|
+
howToFix: [
|
|
325
|
+
'Read `err.feature` and `err.dialect`: together they say exactly what was refused and by which engine.',
|
|
326
|
+
'For a composite-key relation, use the default join plan rather than `batched`.',
|
|
327
|
+
'For a version gate, upgrade the engine to the version named in the hint.',
|
|
328
|
+
],
|
|
329
|
+
properties: ['feature', 'dialect'],
|
|
330
|
+
},
|
|
331
|
+
TURBINE_E018: {
|
|
332
|
+
className: 'ReadOnlyError',
|
|
333
|
+
retryable: false,
|
|
334
|
+
origin: 'turbine',
|
|
335
|
+
whenThrown: 'A write was refused because the database or the connection is read-only.',
|
|
336
|
+
likelyCauses: [
|
|
337
|
+
'`reason: "snapshot"`: the client was opened `readonly: true`, or PowDB is serving a read-only snapshot.',
|
|
338
|
+
'`reason: "rbac"`: the connected role has no write privilege.',
|
|
339
|
+
],
|
|
340
|
+
howToFix: [
|
|
341
|
+
'Read `err.reason` first: the two causes have nothing in common except the refusal.',
|
|
342
|
+
'For `snapshot`, open a writable client; the read-only one is doing its job.',
|
|
343
|
+
'For `rbac`, grant the role the privilege, or connect as one that has it.',
|
|
344
|
+
],
|
|
345
|
+
properties: ['reason'],
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
/** Every code in the catalog, in code order. */
|
|
349
|
+
export const CATALOGUED_ERROR_CODES = Object.keys(CATALOG).sort();
|
|
350
|
+
/**
|
|
351
|
+
* Accept the spellings a caller actually types and return the canonical code,
|
|
352
|
+
* or `null`.
|
|
353
|
+
*
|
|
354
|
+
* A code arrives from a log line (`TURBINE_E003`), from a docs anchor (`e003`),
|
|
355
|
+
* from prose ("E3"), or as a bare number. All of them mean the same code, and
|
|
356
|
+
* refusing three of the four teaches an agent to give up rather than to
|
|
357
|
+
* normalize. What is NOT accepted is anything that resolves to no code at all:
|
|
358
|
+
* the caller gets `null` and a list, never a guess.
|
|
359
|
+
*/
|
|
360
|
+
export function normalizeErrorCode(input) {
|
|
361
|
+
const trimmed = input.trim().toUpperCase().replace(/\s+/g, '');
|
|
362
|
+
// `TURBINE_E003` / `TURBINE-E003` / `TURBINEE003` / `E003` / `E3` / `003` / `3`
|
|
363
|
+
const match = /^(?:TURBINE[_-]?)?E?(\d{1,4})$/.exec(trimmed);
|
|
364
|
+
if (!match)
|
|
365
|
+
return null;
|
|
366
|
+
const n = Number(match[1]);
|
|
367
|
+
if (!Number.isInteger(n) || n < 1)
|
|
368
|
+
return null;
|
|
369
|
+
const code = `TURBINE_E${String(n).padStart(3, '0')}`;
|
|
370
|
+
return Object.hasOwn(CATALOG, code) ? code : null;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The full explanation for a code, or `null` when the input names no code.
|
|
374
|
+
*
|
|
375
|
+
* `docsUrl` is read off a real {@link TurbineError} rather than formatted here,
|
|
376
|
+
* so it cannot drift from the URL on the error an agent actually caught.
|
|
377
|
+
*/
|
|
378
|
+
export function explainErrorCode(input) {
|
|
379
|
+
const code = normalizeErrorCode(input);
|
|
380
|
+
if (!code)
|
|
381
|
+
return null;
|
|
382
|
+
return { code, docsUrl: new TurbineError(code, '').docsUrl, ...CATALOG[code] };
|
|
383
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -3862,8 +3862,9 @@ function showMcpHelp() {
|
|
|
3862
3862
|
console.log(` ${bold('Usage:')}`);
|
|
3863
3863
|
console.log(` npx turbine mcp ${dim('[options]')}`);
|
|
3864
3864
|
newline();
|
|
3865
|
-
console.log(` Speaks newline-delimited JSON-RPC 2.0 on stdin/stdout and exposes`);
|
|
3866
|
-
console.log(` schema,
|
|
3865
|
+
console.log(` Speaks newline-delimited JSON-RPC 2.0 on stdin/stdout and exposes ten`);
|
|
3866
|
+
console.log(` read-only tools: schema, relation graph, join paths, migration status,`);
|
|
3867
|
+
console.log(` doctor, EXPLAIN, table stats, sample rows, and error lookup.`);
|
|
3867
3868
|
newline();
|
|
3868
3869
|
console.log(` ${bold('Options:')}`);
|
|
3869
3870
|
console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
|
package/dist/cli/mcp.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Readable, Writable } from 'node:stream';
|
|
2
|
-
import
|
|
3
|
-
import { type ColumnMetadata, type IndexMetadata, type RelationDef } from '../schema.js';
|
|
2
|
+
import type { PgCompatPool } from '../pg-types.js';
|
|
3
|
+
import { type ColumnMetadata, type IndexMetadata, type RelationDef, type SchemaMetadata } from '../schema.js';
|
|
4
4
|
export interface McpServerOptions {
|
|
5
5
|
url: string;
|
|
6
6
|
schema: string;
|
|
@@ -24,12 +24,28 @@ export interface McpTransport {
|
|
|
24
24
|
* reason Studio exports `handleRequest`). Production never sets it: the
|
|
25
25
|
* server builds its own pool from `options.url`.
|
|
26
26
|
*/
|
|
27
|
-
pool?:
|
|
27
|
+
pool?: PgCompatPool;
|
|
28
28
|
}
|
|
29
29
|
export interface McpServerHandle {
|
|
30
30
|
dispose(): Promise<void>;
|
|
31
31
|
}
|
|
32
32
|
export declare function startMcpServer(options: McpServerOptions, transport?: McpTransport): McpServerHandle;
|
|
33
|
+
/**
|
|
34
|
+
* Every SHORTEST relation chain from `from` to `to`, in deterministic order.
|
|
35
|
+
*
|
|
36
|
+
* Enumerated over BFS distances rather than by depth-first search with a visited
|
|
37
|
+
* set: only edges that advance the distance by exactly one are followed, so
|
|
38
|
+
* every chain returned is the same (minimum) length and no chain revisits a
|
|
39
|
+
* table. Two foreign keys to the same table therefore come back as two paths of
|
|
40
|
+
* equal length, which is the case the caller most needs to see, because picking
|
|
41
|
+
* one arbitrarily is how you silently join through `editor` when you meant
|
|
42
|
+
* `author`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function shortestJoinPaths(metadata: SchemaMetadata, from: string, to: string, maxDepth: number, maxPaths: number, nodeBudget?: number): {
|
|
45
|
+
paths: RelationDef[][];
|
|
46
|
+
truncated: boolean;
|
|
47
|
+
exhausted: boolean;
|
|
48
|
+
};
|
|
33
49
|
interface ForeignKeyRow {
|
|
34
50
|
/**
|
|
35
51
|
* `pg_constraint.oid` as text. THE grouping key: a constraint NAME is unique
|