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.
Files changed (56) hide show
  1. package/dist/cjs/cli/error-catalog.d.ts +77 -0
  2. package/dist/cjs/cli/error-catalog.js +388 -0
  3. package/dist/cjs/cli/index.js +3 -2
  4. package/dist/cjs/cli/mcp.d.ts +19 -3
  5. package/dist/cjs/cli/mcp.js +709 -22
  6. package/dist/cjs/cli/migrate.d.ts +19 -2
  7. package/dist/cjs/cli/observe.d.ts +2 -2
  8. package/dist/cjs/cli/observe.js +20 -2
  9. package/dist/cjs/cli/pii-predicate-guard.d.ts +6 -2
  10. package/dist/cjs/cli/pii-predicate-guard.js +6 -2
  11. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  12. package/dist/cjs/client.d.ts +30 -75
  13. package/dist/cjs/client.js +31 -11
  14. package/dist/cjs/introspect.d.ts +113 -17
  15. package/dist/cjs/introspect.js +229 -33
  16. package/dist/cjs/pg-types.d.ts +153 -0
  17. package/dist/cjs/pg-types.js +38 -0
  18. package/dist/cjs/pipeline.d.ts +3 -3
  19. package/dist/cjs/query/batched-loader.d.ts +2 -2
  20. package/dist/cjs/query/builder.d.ts +2 -2
  21. package/dist/cjs/query/builder.js +10 -1
  22. package/dist/cjs/query/deferred.d.ts +6 -6
  23. package/dist/cjs/query/filters.d.ts +13 -7
  24. package/dist/cjs/query/filters.js +13 -14
  25. package/dist/cjs/query/where.d.ts +2 -2
  26. package/dist/cjs/schema-sql.d.ts +18 -0
  27. package/dist/cjs/schema-sql.js +18 -0
  28. package/dist/cli/error-catalog.d.ts +77 -0
  29. package/dist/cli/error-catalog.js +383 -0
  30. package/dist/cli/index.js +3 -2
  31. package/dist/cli/mcp.d.ts +19 -3
  32. package/dist/cli/mcp.js +709 -23
  33. package/dist/cli/migrate.d.ts +19 -2
  34. package/dist/cli/observe.d.ts +2 -2
  35. package/dist/cli/observe.js +20 -2
  36. package/dist/cli/pii-predicate-guard.d.ts +6 -2
  37. package/dist/cli/pii-predicate-guard.js +6 -2
  38. package/dist/cli/studio-ui.generated.js +1 -1
  39. package/dist/client.d.ts +30 -75
  40. package/dist/client.js +31 -11
  41. package/dist/introspect.d.ts +113 -17
  42. package/dist/introspect.js +227 -33
  43. package/dist/pg-types.d.ts +153 -0
  44. package/dist/pg-types.js +37 -0
  45. package/dist/pipeline.d.ts +3 -3
  46. package/dist/query/batched-loader.d.ts +2 -2
  47. package/dist/query/builder.d.ts +2 -2
  48. package/dist/query/builder.js +10 -1
  49. package/dist/query/deferred.d.ts +6 -6
  50. package/dist/query/filters.d.ts +13 -7
  51. package/dist/query/filters.js +13 -13
  52. package/dist/query/where-compile.js +1 -1
  53. package/dist/query/where.d.ts +2 -2
  54. package/dist/schema-sql.d.ts +18 -0
  55. package/dist/schema-sql.js +18 -0
  56. 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: pg.QueryResult): unknown;
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';
@@ -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
  /**
@@ -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, migration status, doctor, EXPLAIN, and sample-row tools.`);
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 pg from 'pg';
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?: pg.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