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
package/dist/cli/mcp.js CHANGED
@@ -3,10 +3,12 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import pg from 'pg';
5
5
  import { findMissingRelationIndexes } from '../index-advisor.js';
6
- import { deriveCatalogRelations } from '../introspect.js';
6
+ import { formatBytes } from '../index-stats.js';
7
+ import { deriveCatalogRelations, indexKeyColumn, parseIndexKeyEntries } from '../introspect.js';
7
8
  import { QueryInterface, quoteIdent } from '../query/index.js';
8
9
  import { ownLookup, registerUtcTemporalParsers } from '../query/utils.js';
9
10
  import { isDateType, pgArrayType, pgTypeToTs, snakeToCamel, } from '../schema.js';
11
+ import { CATALOGUED_ERROR_CODES, explainErrorCode } from './error-catalog.js';
10
12
  import { listMigrationFiles } from './migrate.js';
11
13
  import { assertNoPiiPredicates as assertNoPiiPredicatesShared } from './pii-predicate-guard.js';
12
14
  import { applyPiiTags, loadPiiTags } from './pii-tags.js';
@@ -169,6 +171,68 @@ const TOOLS = [
169
171
  additionalProperties: false,
170
172
  },
171
173
  },
174
+ {
175
+ name: 'relation_graph',
176
+ description: 'The relation graph Turbine derived for the schema: for every table, each relation name with its cardinality (hasMany / hasOne / belongsTo / manyToMany), target table, join keys, and the junction table for many-to-many. These are the EXACT names a `with` clause accepts, so read them here instead of guessing from column names. Pass `table` to get only that table and what is reachable from it, and `depth` to bound the hops. No row values are read or returned. Column NAMES are returned in full, including the name of a PII-tagged or secret-named join key: a name is schema shape, and table_detail returns the same names. Row VALUES on such a column are protected where values are served, by sample_rows and explain_query.',
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {
180
+ table: {
181
+ type: 'string',
182
+ description: 'Optional: return only this table and the tables reachable from it within `depth` hops.',
183
+ },
184
+ depth: {
185
+ type: 'number',
186
+ minimum: 1,
187
+ maximum: 10,
188
+ description: 'Max hops from `table` (default 2). Ignored when `table` is omitted.',
189
+ },
190
+ },
191
+ additionalProperties: false,
192
+ },
193
+ },
194
+ {
195
+ name: 'find_join_path',
196
+ description: 'Shortest relation chain from one table to another, WITH the nested `with` clause to write, as code. Returns every equal-shortest path when there is more than one (two foreign keys to the same table produce two). A many-to-many hop counts as one hop and needs no junction table in the query. Returns cleanly with `found: false` when no chain exists; it does not throw.',
197
+ inputSchema: {
198
+ type: 'object',
199
+ properties: {
200
+ from: { type: 'string', description: 'The table the query starts at (the one you call findMany on).' },
201
+ to: { type: 'string', description: 'The table you need to reach.' },
202
+ maxDepth: { type: 'number', minimum: 1, maximum: 10, description: 'Max hops to search (default 6).' },
203
+ maxPaths: {
204
+ type: 'number',
205
+ minimum: 1,
206
+ maximum: 25,
207
+ description: 'Max equal-length paths to return (default 5).',
208
+ },
209
+ },
210
+ required: ['from', 'to'],
211
+ additionalProperties: false,
212
+ },
213
+ },
214
+ {
215
+ name: 'table_stats',
216
+ description: "Size and index shape for one table: the planner's row ESTIMATE (pg_class.reltuples, which is maintained by ANALYZE and is NOT an exact count, never present it as one), the page count, on-disk bytes, and every index with its columns. Returns no row values. Index definitions ARE stripped of literal values, because a partial index predicate embeds real stored data. Column NAMES are returned in full, PII-tagged and secret-named ones included: a name is schema shape, and table_detail returns the same names.",
217
+ inputSchema: {
218
+ type: 'object',
219
+ properties: { table: { type: 'string' } },
220
+ required: ['table'],
221
+ additionalProperties: false,
222
+ },
223
+ },
224
+ {
225
+ name: 'explain_error',
226
+ description: 'Explain a Turbine error code: the class name, when it is thrown, the likely causes, how to fix it, the extra properties the error carries, and its docs URL. Accepts `TURBINE_E003`, `E003`, or `3`. Needs no database and reads none.',
227
+ inputSchema: {
228
+ type: 'object',
229
+ properties: {
230
+ code: { type: 'string', description: 'A Turbine error code, e.g. "TURBINE_E003", "E003", or "3".' },
231
+ },
232
+ required: ['code'],
233
+ additionalProperties: false,
234
+ },
235
+ },
172
236
  ];
173
237
  export function startMcpServer(options, transport = {}) {
174
238
  const input = transport.input ?? process.stdin;
@@ -189,7 +253,9 @@ export function startMcpServer(options, transport = {}) {
189
253
  // the JSON-RPC framing channel and one stray line desynchronizes the client.
190
254
  // The message is redacted because pg echoes the connection string into some
191
255
  // connection failures, and this text is written where a user can see it.
192
- ctx.pool.on('error', (err) => {
256
+ // Optional call: `on` is a pg-family capability, and the perimeter tests hand
257
+ // in a minimal `PgCompatPool` fake that has no event surface at all.
258
+ ctx.pool.on?.('error', (err) => {
193
259
  process.stderr.write(`[turbine] mcp pool error: ${redactUrl(err.message)}\n`);
194
260
  });
195
261
  announcePiiTags(options);
@@ -324,6 +390,19 @@ async function callTool(params, ctx) {
324
390
  case 'sample_rows':
325
391
  result = await sampleRows(ctx, requiredString(args, 'table'), optionalLimit(args.limit));
326
392
  break;
393
+ case 'relation_graph':
394
+ result = await relationGraph(ctx, args);
395
+ break;
396
+ case 'find_join_path':
397
+ result = await findJoinPath(ctx, args);
398
+ break;
399
+ case 'table_stats':
400
+ result = await tableStats(ctx, requiredString(args, 'table'));
401
+ break;
402
+ case 'explain_error':
403
+ // No database read at all: the catalog is a pure lookup over errors.ts.
404
+ result = explainError(requiredString(args, 'code'));
405
+ break;
327
406
  default:
328
407
  throw jsonRpcError(-32602, `Unknown tool: ${params.name}`);
329
408
  }
@@ -334,12 +413,12 @@ async function callTool(params, ctx) {
334
413
  async function schemaOverview(ctx) {
335
414
  return withReadOnly(ctx, async (client) => {
336
415
  const { metadata } = await loadSchemaMetadata(client, ctx.options);
337
- const rowCounts = await estimateRows(client, ctx.options.schema);
416
+ const rowCounts = await collectTableStats(client, ctx.options.schema);
338
417
  return {
339
418
  schema: ctx.options.schema,
340
419
  tables: Object.values(metadata.tables).map((table) => ({
341
420
  name: table.name,
342
- estimatedRows: rowCounts.get(table.name) ?? 0,
421
+ estimatedRows: estimatedRowCount(rowCounts.get(table.name)),
343
422
  columns: table.columns.length,
344
423
  primaryKey: table.primaryKey,
345
424
  indexes: table.indexes.length,
@@ -441,11 +520,34 @@ function sanitizeIndex(index) {
441
520
  // IS the literal. Only entries that are a bare identifier survive, and only
442
521
  // on the expression path, so an ordinary index (including one with a quoted
443
522
  // identifier holding a space) is untouched.
444
- const columns = keysHoldLiteral ? index.columns.filter((column) => PLAIN_IDENTIFIER.test(column)) : index.columns;
523
+ //
524
+ // PARTITIONED IN ONE PASS, and the flag is read off the partition rather than
525
+ // recomputed. `columnsWithheld` used to be a LENGTH COMPARISON against
526
+ // `index.columns`, which is only true of the list as it stands at this exact
527
+ // line: a later same-length transform of `columns` (the column-NAME masking
528
+ // this file used to apply on top) left the flag reading `false` while the
529
+ // reply displayed a withholding marker. A derived flag cannot drift from the
530
+ // list it describes.
531
+ //
532
+ // SEEDED from `keys === null`, i.e. an UNPARSABLE definition, because that is
533
+ // the one input where the loop below cannot speak for the answer: the column
534
+ // list was derived from the same definition, so it arrives empty and every
535
+ // per-column test passes vacuously. `columns: [], columnsWithheld: false`
536
+ // reads as the FACT "this index has no columns", which no unreadable
537
+ // definition supports. Not knowing is a withholding like any other here, and
538
+ // it is labelled like one.
539
+ const columns = [];
540
+ let columnsWithheld = keys === null;
541
+ for (const column of index.columns) {
542
+ if (keysHoldLiteral && !PLAIN_IDENTIFIER.test(column))
543
+ columnsWithheld = true;
544
+ else
545
+ columns.push(column);
546
+ }
445
547
  return {
446
548
  name: index.name,
447
549
  columns,
448
- columnsWithheld: columns.length !== index.columns.length,
550
+ columnsWithheld,
449
551
  unique: index.unique,
450
552
  partial,
451
553
  // Withholding is LABELLED, never expressed by dropping the field:
@@ -541,14 +643,14 @@ async function migrationStatus(ctx) {
541
643
  async function doctorReport(ctx) {
542
644
  return withReadOnly(ctx, async (client) => {
543
645
  const { metadata } = await loadSchemaMetadata(client, ctx.options);
544
- const rowCounts = await estimateRows(client, ctx.options.schema);
545
- const missing = findMissingRelationIndexes(metadata).sort((a, b) => (rowCounts.get(b.table) ?? 0) - (rowCounts.get(a.table) ?? 0));
646
+ const rowCounts = await collectTableStats(client, ctx.options.schema);
647
+ const missing = findMissingRelationIndexes(metadata).sort((a, b) => estimatedRowCount(rowCounts.get(b.table)) - estimatedRowCount(rowCounts.get(a.table)));
546
648
  return {
547
649
  schema: ctx.options.schema,
548
650
  ok: missing.length === 0,
549
651
  missingRelationIndexes: missing.map((entry) => ({
550
652
  table: entry.table,
551
- estimatedRows: rowCounts.get(entry.table) ?? 0,
653
+ estimatedRows: estimatedRowCount(rowCounts.get(entry.table)),
552
654
  columns: entry.columns,
553
655
  probes: entry.probes,
554
656
  suggestedIndexName: entry.indexName,
@@ -783,6 +885,507 @@ async function sampleRows(ctx, tableName, limit) {
783
885
  };
784
886
  });
785
887
  }
888
+ // ---------------------------------------------------------------------------
889
+ // Agent-facing graph / stats / error tools
890
+ // ---------------------------------------------------------------------------
891
+ /**
892
+ * COLUMN NAMES ARE NOT WITHHELD BY THE GRAPH AND STATS TOOLS, and this comment
893
+ * is where that decision is recorded, because an earlier cut of these tools did
894
+ * withhold them.
895
+ *
896
+ * A column NAME is not an oracle for the VALUE stored in it. It discloses schema
897
+ * SHAPE, which an agent must have to write a query at all, and which
898
+ * `table_detail`, `schema_overview` and `sample_rows` already publish in full
899
+ * (they redact VALUES and label the column redacted, they do not hide the name).
900
+ * Masking the same name inside a relation edge or an index key list therefore
901
+ * protected nothing: the identical name came back in the same reply through the
902
+ * index `definition`, through the index `name`, through the `redactedColumns`
903
+ * list that reported the masking, and through the relation NAME itself, since
904
+ * Turbine derives `session` from `session_id`. `primaryKey` was never masked at
905
+ * all.
906
+ *
907
+ * What the masking DID do is make two tool descriptions promise a protection the
908
+ * server did not have, which is worse than not having it. So it is gone, and
909
+ * every VALUE protection is untouched:
910
+ *
911
+ * - `sample_rows` never FETCHES a hidden column (SQL-level projection).
912
+ * - `explain_query` refuses a where/orderBy on a hidden column, because a row
913
+ * estimate is an extraction oracle (`assertNoPiiPredicates`).
914
+ * - `sanitizeIndex` strips literal values out of an index definition, since a
915
+ * partial index's predicate embeds real stored data.
916
+ *
917
+ * All three fail CLOSED when the PII tag scan fails. That is the boundary; the
918
+ * name masking never was one.
919
+ */
920
+ /**
921
+ * A table's relations in a stable, name-sorted order (catalog order is not one).
922
+ *
923
+ * MEMOIZED PER SCHEMA OBJECT, because the path enumeration below re-sorts a
924
+ * table's relations on EVERY visit and a table on many equal-length paths is
925
+ * visited many times. A WeakMap keyed on the metadata object (not a module-level
926
+ * cache keyed on the table name) so a re-introspection after a schema change
927
+ * cannot be served a stale list, and so nothing is retained once the reply is
928
+ * built.
929
+ *
930
+ * `metadata` is REQUIRED, and that is the whole guard. It was optional, with an
931
+ * unmemoized fallback when omitted, and two of the four call sites then simply
932
+ * did not pass it: the cache was declared and half bypassed, silently, because
933
+ * omitting an optional argument is not an error. Every caller has the metadata
934
+ * object in scope, so nothing needed the fallback and only the bypass survived
935
+ * it.
936
+ */
937
+ const relationOrderCache = new WeakMap();
938
+ function sortedRelations(table, metadata) {
939
+ const sort = () => Object.values(table.relations).sort((a, b) => a.name.localeCompare(b.name));
940
+ let byTable = relationOrderCache.get(metadata);
941
+ if (!byTable) {
942
+ byTable = new Map();
943
+ relationOrderCache.set(metadata, byTable);
944
+ }
945
+ const cached = byTable.get(table.name);
946
+ if (cached)
947
+ return cached;
948
+ const sorted = sort();
949
+ byTable.set(table.name, sorted);
950
+ return sorted;
951
+ }
952
+ /** One relation edge, as both graph tools report it. Join keys are schema shape, not values. */
953
+ function describeEdge(relation) {
954
+ return {
955
+ name: relation.name,
956
+ type: relation.type,
957
+ from: relation.from,
958
+ to: relation.to,
959
+ foreignKey: relation.foreignKey,
960
+ referenceKey: relation.referenceKey,
961
+ through: relation.through
962
+ ? {
963
+ table: relation.through.table,
964
+ sourceKey: relation.through.sourceKey,
965
+ targetKey: relation.through.targetKey,
966
+ }
967
+ : null,
968
+ selfRelation: relation.from === relation.to,
969
+ onDelete: relation.onDelete ?? null,
970
+ onUpdate: relation.onUpdate ?? null,
971
+ };
972
+ }
973
+ /** Tables reachable from `root` within `maxHops`, with their hop distance. */
974
+ function bfsDistances(metadata, root, maxHops) {
975
+ const dist = new Map([[root, 0]]);
976
+ let frontier = [root];
977
+ for (let hop = 0; hop < maxHops && frontier.length > 0; hop++) {
978
+ const next = [];
979
+ for (const name of frontier) {
980
+ const table = ownLookup(metadata.tables, name);
981
+ if (!table)
982
+ continue;
983
+ for (const relation of sortedRelations(table, metadata)) {
984
+ // A relation whose target was filtered out by --include/--exclude is not
985
+ // traversable from this server's view of the schema.
986
+ if (!ownLookup(metadata.tables, relation.to))
987
+ continue;
988
+ if (dist.has(relation.to))
989
+ continue;
990
+ dist.set(relation.to, hop + 1);
991
+ next.push(relation.to);
992
+ }
993
+ }
994
+ frontier = next;
995
+ }
996
+ return dist;
997
+ }
998
+ /**
999
+ * The relation graph, whole or rooted at one table.
1000
+ *
1001
+ * This is the single biggest token sink an agent hits on an unfamiliar schema:
1002
+ * without it, the only way to learn that `with: { author: true }` is spelled
1003
+ * `author` and not `users` or `user_id` is to call `table_detail` per table.
1004
+ * Relation NAMES are what a `with` clause accepts, and Turbine derives them
1005
+ * (Id-stripping, unique-FK singularization, auto-m2m), so they are not
1006
+ * guessable from the catalog.
1007
+ */
1008
+ async function relationGraph(ctx, args) {
1009
+ const root = optionalString(args, 'table');
1010
+ const depth = optionalInteger(args.depth, 'depth', 1, 10) ?? 2;
1011
+ return withReadOnly(ctx, async (client) => {
1012
+ const { metadata } = await loadSchemaMetadata(client, ctx.options);
1013
+ let included;
1014
+ let hops = null;
1015
+ let rootName = null;
1016
+ if (root === undefined) {
1017
+ included = Object.keys(metadata.tables).sort();
1018
+ }
1019
+ else {
1020
+ const rootTable = requireTable(metadata, root);
1021
+ rootName = rootTable.name;
1022
+ const distances = bfsDistances(metadata, rootTable.name, depth);
1023
+ hops = distances;
1024
+ included = [...distances.keys()].sort((a, b) => (distances.get(a) ?? 0) - (distances.get(b) ?? 0) || a.localeCompare(b));
1025
+ }
1026
+ // Targets one hop past the cap: named, not silently absent, so the agent can
1027
+ // tell "nothing there" from "not expanded".
1028
+ const omitted = new Set();
1029
+ let relationCount = 0;
1030
+ const tables = included.map((name) => {
1031
+ const table = requireTable(metadata, name);
1032
+ const relations = sortedRelations(table, metadata);
1033
+ relationCount += relations.length;
1034
+ for (const relation of relations) {
1035
+ if (hops && ownLookup(metadata.tables, relation.to) && !hops.has(relation.to))
1036
+ omitted.add(relation.to);
1037
+ }
1038
+ return {
1039
+ table: table.name,
1040
+ hops: hops?.get(name) ?? null,
1041
+ primaryKey: table.primaryKey,
1042
+ relationCount: relations.length,
1043
+ relations: relations.map(describeEdge),
1044
+ };
1045
+ });
1046
+ return {
1047
+ schema: ctx.options.schema,
1048
+ root: rootName,
1049
+ depth: rootName === null ? null : depth,
1050
+ tableCount: tables.length,
1051
+ relationCount,
1052
+ tables,
1053
+ omittedBeyondDepth: [...omitted].sort(),
1054
+ note: 'A relation `name` is what a `with` clause accepts; `type` is its cardinality (hasMany / manyToMany return arrays, ' +
1055
+ 'hasOne / belongsTo return one object or null). A manyToMany relation is written as one `with` entry: the junction ' +
1056
+ 'table in `through` is joined for you and must NOT appear in the query. Call find_join_path for the clause to write.',
1057
+ valueNote: 'This tool reads no row values and returns none. Column names ARE returned: they are schema shape, and ' +
1058
+ 'table_detail publishes the same names. Row values on a PII-tagged or secret-named column are protected ' +
1059
+ 'where values are actually served, by sample_rows and explain_query.',
1060
+ };
1061
+ });
1062
+ }
1063
+ /**
1064
+ * Whether a table name can be written as a `db.<name>` property.
1065
+ *
1066
+ * `TurbineClient` defines an accessor per table under the camelCase form of the
1067
+ * table name (`post_tags` -> `postTags`), and falls back to `db.table('name')`
1068
+ * for anything that is not a plain identifier. The emitted code has to make the
1069
+ * same choice, or it does not run.
1070
+ */
1071
+ function clientAccessor(table) {
1072
+ const camel = table.replace(/_([a-z])/g, (_, char) => char.toUpperCase());
1073
+ return PLAIN_IDENTIFIER.test(camel) ? `db.${camel}` : `db.table(${JSON.stringify(table)})`;
1074
+ }
1075
+ /**
1076
+ * Render a chain of relation names as a nested `with` object literal.
1077
+ *
1078
+ * The innermost hop is `{ name: true }` and every outer hop wraps it in
1079
+ * `{ name: { with: … } }`, which is exactly the shape `FindManyArgs` takes. It
1080
+ * is emitted as CODE rather than described in prose because a description is
1081
+ * something the agent then has to compile, and compiling it is where the
1082
+ * spelling errors come from.
1083
+ */
1084
+ function renderWithObject(names, indent) {
1085
+ const [head, ...rest] = names;
1086
+ if (head === undefined)
1087
+ return '{}';
1088
+ if (rest.length === 0)
1089
+ return `{ ${head}: true }`;
1090
+ const inner = renderWithObject(rest, `${indent} `);
1091
+ return `{\n${indent} ${head}: {\n${indent} with: ${inner},\n${indent} },\n${indent}}`;
1092
+ }
1093
+ /** The full `findMany` call for a path, ready to paste. */
1094
+ function renderJoinCode(from, names) {
1095
+ return `await ${clientAccessor(from)}.findMany({\n with: ${renderWithObject(names, ' ')},\n});`;
1096
+ }
1097
+ /** `comments[].post.user.org`: where the joined rows land on the result. */
1098
+ function renderResultShape(from, path) {
1099
+ let shape = `${from}[]`;
1100
+ for (const relation of path) {
1101
+ shape += `.${relation.name}`;
1102
+ if (relation.type === 'hasMany' || relation.type === 'manyToMany')
1103
+ shape += '[]';
1104
+ }
1105
+ return shape;
1106
+ }
1107
+ /** Serialize one path into the reply, code included. */
1108
+ function describePath(from, path) {
1109
+ const names = path.map((relation) => relation.name);
1110
+ return {
1111
+ hops: path.length,
1112
+ relations: path.map(describeEdge),
1113
+ relationNames: names,
1114
+ withClause: renderWithObject(names, ' '),
1115
+ code: renderJoinCode(from, names),
1116
+ resultShape: renderResultShape(from, path),
1117
+ crossesManyToMany: path.some((relation) => relation.type === 'manyToMany'),
1118
+ returnsArray: path.some((relation) => relation.type === 'hasMany' || relation.type === 'manyToMany'),
1119
+ };
1120
+ }
1121
+ /**
1122
+ * Every SHORTEST relation chain from `from` to `to`, in deterministic order.
1123
+ *
1124
+ * Enumerated over BFS distances rather than by depth-first search with a visited
1125
+ * set: only edges that advance the distance by exactly one are followed, so
1126
+ * every chain returned is the same (minimum) length and no chain revisits a
1127
+ * table. Two foreign keys to the same table therefore come back as two paths of
1128
+ * equal length, which is the case the caller most needs to see, because picking
1129
+ * one arbitrarily is how you silently join through `editor` when you meant
1130
+ * `author`.
1131
+ */
1132
+ export function shortestJoinPaths(metadata, from, to, maxDepth, maxPaths,
1133
+ // @internal, and injectable for ONE reason: the production budget is sized so
1134
+ // no real schema reaches it, which would leave the branch that stops the walk
1135
+ // permanently untested. A test passes a small budget instead of constructing a
1136
+ // 200,000-node fixture to reach the real one.
1137
+ nodeBudget = JOIN_PATH_NODE_BUDGET) {
1138
+ const dist = bfsDistances(metadata, from, maxDepth);
1139
+ const target = dist.get(to);
1140
+ if (target === undefined || target === 0)
1141
+ return { paths: [], truncated: false, exhausted: false };
1142
+ const cap = maxPaths + 1;
1143
+ const found = [];
1144
+ const acc = [];
1145
+ // A NODE BUDGET on top of the path cap, because the two bound different
1146
+ // things. `maxPaths` stops once enough COMPLETE chains exist; it does not
1147
+ // bound the search that fails to complete them, and a dense schema at
1148
+ // `maxDepth: 10` can expand a large number of distance-advancing prefixes that
1149
+ // dead-end before reaching the target. This runs inside an open
1150
+ // `BEGIN READ ONLY` on a max:2 pool with an agent on the other end, so the
1151
+ // walk holding a connection is the cost, not the CPU. Measured at 7ms on a
1152
+ // 35-table / 150-FK schema, so this is a ceiling nothing normal approaches;
1153
+ // exhausting it is reported, never silently returned as "no path".
1154
+ let budget = nodeBudget;
1155
+ let exhausted = false;
1156
+ const walk = (current) => {
1157
+ if (found.length >= cap || exhausted)
1158
+ return;
1159
+ if (budget-- <= 0) {
1160
+ exhausted = true;
1161
+ return;
1162
+ }
1163
+ if (current === to) {
1164
+ found.push([...acc]);
1165
+ return;
1166
+ }
1167
+ const here = dist.get(current);
1168
+ const table = ownLookup(metadata.tables, current);
1169
+ if (here === undefined || !table)
1170
+ return;
1171
+ for (const relation of sortedRelations(table, metadata)) {
1172
+ if (!ownLookup(metadata.tables, relation.to))
1173
+ continue;
1174
+ if (dist.get(relation.to) !== here + 1)
1175
+ continue;
1176
+ acc.push(relation);
1177
+ walk(relation.to);
1178
+ acc.pop();
1179
+ if (found.length >= cap || exhausted)
1180
+ return;
1181
+ }
1182
+ };
1183
+ walk(from);
1184
+ return { paths: found.slice(0, maxPaths), truncated: found.length > maxPaths || exhausted, exhausted };
1185
+ }
1186
+ /**
1187
+ * Nodes {@link shortestJoinPaths} may visit before it stops enumerating.
1188
+ *
1189
+ * Sized so no real schema meets it: the search only follows edges that advance
1190
+ * the BFS distance by exactly one, so it is already far cheaper than a general
1191
+ * path enumeration, and 200k visits is orders of magnitude past the ~1k a dense
1192
+ * 35-table schema needs at depth 10.
1193
+ */
1194
+ const JOIN_PATH_NODE_BUDGET = 200_000;
1195
+ /**
1196
+ * The shortest relation chain between two tables, and the code that walks it.
1197
+ *
1198
+ * NEVER THROWS FOR "no path": an agent asking whether two tables are connected
1199
+ * gets `found: false` and a reason, because "there is no path" is an ANSWER,
1200
+ * and turning it into an error makes the agent retry the same question with
1201
+ * different spellings. Only a table name that does not exist is an error, and
1202
+ * that one lists the tables that do.
1203
+ */
1204
+ async function findJoinPath(ctx, args) {
1205
+ const from = requiredString(args, 'from');
1206
+ const to = requiredString(args, 'to');
1207
+ const maxDepth = optionalInteger(args.maxDepth, 'maxDepth', 1, 10) ?? 6;
1208
+ const maxPaths = optionalInteger(args.maxPaths, 'maxPaths', 1, 25) ?? 5;
1209
+ return withReadOnly(ctx, async (client) => {
1210
+ const { metadata } = await loadSchemaMetadata(client, ctx.options);
1211
+ const fromTable = requireTable(metadata, from);
1212
+ const toTable = requireTable(metadata, to);
1213
+ const base = {
1214
+ from: fromTable.name,
1215
+ to: toTable.name,
1216
+ searchedDepth: maxDepth,
1217
+ };
1218
+ // Same table: a join is not what is wanted, and pretending a 0-hop path is a
1219
+ // path would emit `with: {}`. The useful answer is the table's SELF-relations
1220
+ // (`manager`, `parent`), which are the only way to join a table to itself.
1221
+ if (fromTable.name === toTable.name) {
1222
+ const selfRelations = sortedRelations(fromTable, metadata).filter((relation) => relation.to === fromTable.name);
1223
+ const paths = selfRelations.map((relation) => describePath(fromTable.name, [relation]));
1224
+ return {
1225
+ ...base,
1226
+ found: true,
1227
+ sameTable: true,
1228
+ hops: paths.length > 0 ? 1 : 0,
1229
+ pathCount: paths.length,
1230
+ paths,
1231
+ pathsTruncated: false,
1232
+ notes: [
1233
+ `"${fromTable.name}" is both ends of this query, so no join is needed to read its own columns.`,
1234
+ paths.length > 0
1235
+ ? 'The paths below are its SELF-relations: a relation whose target is the same table, which is the only way to join it to itself.'
1236
+ : 'It declares no self-relation, so there is nothing to join it to itself through.',
1237
+ ],
1238
+ };
1239
+ }
1240
+ const { paths, truncated, exhausted } = shortestJoinPaths(metadata, fromTable.name, toTable.name, maxDepth, maxPaths);
1241
+ if (paths.length === 0) {
1242
+ // A budget exhaustion is NOT "these tables are not connected", and saying
1243
+ // so would send the agent off to change its schema. Reported as its own
1244
+ // answer, with the knob that makes the search finish.
1245
+ return {
1246
+ ...base,
1247
+ found: false,
1248
+ sameTable: false,
1249
+ hops: null,
1250
+ pathCount: 0,
1251
+ paths: [],
1252
+ pathsTruncated: false,
1253
+ searchExhausted: exhausted,
1254
+ reason: exhausted
1255
+ ? `The search for a chain from "${fromTable.name}" to "${toTable.name}" hit this tool's node budget before ` +
1256
+ `it finished, so this is NOT an answer that they are unconnected. Lower maxDepth (it is ${maxDepth}) to ` +
1257
+ `bound the search, or call relation_graph on "${fromTable.name}" and walk it a hop at a time.`
1258
+ : `No relation chain connects "${fromTable.name}" to "${toTable.name}" within ${maxDepth} hop(s). Either the ` +
1259
+ `schema declares no foreign key path between them, or the path is longer than the search depth. Raise ` +
1260
+ `maxDepth, or call relation_graph on "${fromTable.name}" to see what it does reach.`,
1261
+ notes: exhausted
1262
+ ? ['The search did not complete. Do not report these tables as unconnected on the strength of this reply.']
1263
+ : [
1264
+ 'This is an answer, not a failure: the tables are not connected by declared foreign keys as far as this search went.',
1265
+ ],
1266
+ };
1267
+ }
1268
+ const described = paths.map((path) => describePath(fromTable.name, path));
1269
+ const notes = [];
1270
+ if (described.length > 1) {
1271
+ notes.push(`${described.length} chains of equal length connect these tables. They are different joins, not duplicates: ` +
1272
+ `pick by relation name (two foreign keys to the same table, e.g. author and editor, both appear here).`);
1273
+ }
1274
+ if (paths.some((path) => path.some((relation) => relation.type === 'manyToMany'))) {
1275
+ notes.push('A manyToMany hop is ONE hop in the `with` clause. The junction table is joined for you and must not appear in the query.');
1276
+ }
1277
+ if (exhausted) {
1278
+ notes.push(`The search hit this tool's node budget and stopped early, so the chains below are the ones found before ` +
1279
+ `that, not necessarily every equal-length chain. Lower maxDepth (it is ${maxDepth}) to bound the search.`);
1280
+ }
1281
+ else if (truncated) {
1282
+ notes.push(`More equal-length chains exist; ${maxPaths} were returned. Raise maxPaths to see the rest.`);
1283
+ }
1284
+ notes.push('A to-one relation (belongsTo / hasOne) is `T | null` when its foreign key is nullable.');
1285
+ return {
1286
+ ...base,
1287
+ found: true,
1288
+ sameTable: false,
1289
+ hops: paths[0]?.length ?? null,
1290
+ pathCount: described.length,
1291
+ paths: described,
1292
+ pathsTruncated: truncated,
1293
+ searchExhausted: exhausted,
1294
+ notes,
1295
+ };
1296
+ });
1297
+ }
1298
+ /**
1299
+ * Size and index shape for one table.
1300
+ *
1301
+ * NOT collected through `collectStatsSnapshot` in ../index-stats.ts, and the
1302
+ * reason is worth stating because that IS the natural reuse. That collector
1303
+ * opens its own `pg.Pool` from a connection string and issues a SESSION-level
1304
+ * `SET statement_timeout`; through a transaction-pooling proxy (PgBouncer,
1305
+ * Neon's `-pooler` endpoint) a bare `SET` attaches to a shared server backend
1306
+ * that is handed back out to other callers. `turbine doctor` runs once and
1307
+ * exits; this server is long-lived and agent-driven, so it reads through the
1308
+ * connection it already holds, inside the same `BEGIN READ ONLY` as every other
1309
+ * tool. What IS reused is the pure half: {@link TableStats} as the row type and
1310
+ * {@link formatBytes} for the human sizes.
1311
+ *
1312
+ * `reltuples` is labelled an ESTIMATE in three places (the tool description, the
1313
+ * field name, and a note on the value) because an agent that reports it as a row
1314
+ * count is worse than one that reports nothing: it is maintained by
1315
+ * ANALYZE/autovacuum, and is -1 (never analyzed) or arbitrarily stale otherwise.
1316
+ */
1317
+ async function tableStats(ctx, tableName) {
1318
+ return withReadOnly(ctx, async (client) => {
1319
+ const { metadata } = await loadSchemaMetadata(client, ctx.options);
1320
+ const table = requireTable(metadata, tableName);
1321
+ const stats = (await collectTableStats(client, ctx.options.schema)).get(table.name);
1322
+ // reltuples is -1 for a table that has never been analyzed on PG >= 14, and
1323
+ // 0 on older ones. Neither is a row count, so both report as unknown rather
1324
+ // than as "empty table", which is the wrong claim an agent would act on.
1325
+ const reltuples = stats?.reltuples;
1326
+ const analyzed = reltuples !== undefined && reltuples > 0;
1327
+ return {
1328
+ table: table.name,
1329
+ schema: ctx.options.schema,
1330
+ rowEstimate: {
1331
+ estimatedRows: analyzed ? Math.round(reltuples) : null,
1332
+ analyzed,
1333
+ source: 'pg_class.reltuples',
1334
+ note: analyzed
1335
+ ? 'ESTIMATE, not a count. pg_class.reltuples is maintained by ANALYZE and autovacuum and can be arbitrarily stale. Do not report it as a row count; run an explicit count if an exact number matters.'
1336
+ : 'Unknown: this table has never been ANALYZEd (reltuples is 0 or -1), so the planner has no row estimate for it. This is NOT the same as an empty table. Run ANALYZE, then ask again.',
1337
+ },
1338
+ storage: {
1339
+ relpages: stats?.relpages ?? null,
1340
+ relpagesNote: 'Planner page count for the heap, refreshed by ANALYZE/VACUUM. Paired with reltuples it is what plan cost is computed from.',
1341
+ heapBytes: stats?.tableSizeBytes ?? null,
1342
+ heapSize: formatBytes(stats?.tableSizeBytes),
1343
+ totalBytes: stats?.totalSizeBytes ?? null,
1344
+ totalSize: formatBytes(stats?.totalSizeBytes),
1345
+ totalNote: 'Total is pg_total_relation_size: heap plus every index plus TOAST.',
1346
+ },
1347
+ indexCount: stats?.existingIndexCount ?? table.indexes.length,
1348
+ primaryKey: table.primaryKey,
1349
+ indexes: table.indexes.map(sanitizeIndex),
1350
+ note: 'No row values are read by this tool. Index definitions are stripped of literal values before they are ' +
1351
+ 'returned, because a partial index predicate embeds real stored data. Column NAMES are returned in full: ' +
1352
+ 'they are schema shape, and table_detail publishes the same names.',
1353
+ };
1354
+ });
1355
+ }
1356
+ /**
1357
+ * Explain one Turbine error code. Reads no database and opens no transaction:
1358
+ * the catalog is a pure lookup over `errors.ts`, so this answers with the pool
1359
+ * unreachable, which is frequently the situation an agent is in when it is
1360
+ * holding a `TURBINE_E004`.
1361
+ */
1362
+ function explainError(input) {
1363
+ const explanation = explainErrorCode(input);
1364
+ if (!explanation) {
1365
+ throw jsonRpcError(-32602, `"${input}" is not a Turbine error code. Known codes: ${CATALOGUED_ERROR_CODES.join(', ')}. ` +
1366
+ `Any of "TURBINE_E003", "E003" or "3" is accepted.`);
1367
+ }
1368
+ const propertyLines = explanation.properties.map((property) => ` // err.${property}`).join('\n');
1369
+ return {
1370
+ ...explanation,
1371
+ catchExample: [
1372
+ `import { ${explanation.className} } from 'turbine-orm';`,
1373
+ '',
1374
+ 'try {',
1375
+ ' // the call that threw',
1376
+ '} catch (err) {',
1377
+ ` if (err instanceof ${explanation.className}) {`,
1378
+ ` // err.code === '${explanation.code}'`,
1379
+ ` // err.docsUrl === '${explanation.docsUrl}'`,
1380
+ ...(propertyLines ? [propertyLines] : []),
1381
+ ' }',
1382
+ ' throw err;',
1383
+ '}',
1384
+ ].join('\n'),
1385
+ note: 'Branch on `err.code` or `instanceof`, never on the message text: message wording is explicitly not part of the ' +
1386
+ 'stability contract, while the code and docsUrl are.',
1387
+ };
1388
+ }
786
1389
  async function withReadOnly(ctx, fn) {
787
1390
  const client = await ctx.pool.connect();
788
1391
  try {
@@ -1111,15 +1714,59 @@ export function buildRelations(tableNames, columnsByTable, pkByTable, rows, uniq
1111
1714
  enums,
1112
1715
  });
1113
1716
  }
1114
- async function estimateRows(client, schema) {
1115
- const result = await client.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
1717
+ /**
1718
+ * Per-table planner statistics and on-disk size, for every table in the schema.
1719
+ *
1720
+ * ONE query, and it is the same pg_class shape `collectStatsSnapshot` in
1721
+ * ../index-stats.ts reads (down to the `::bigint::text` casts, which keep a
1722
+ * count past 2^53 out of a lossy JS number on the way in), typed with that
1723
+ * module's {@link TableStats}. It is issued here rather than by calling that
1724
+ * collector because the collector opens its own pool and sets a session-level
1725
+ * `SET statement_timeout`; see the note on {@link tableStats}.
1726
+ *
1727
+ * Every column past `relname` is read defensively: a role without permission to
1728
+ * call `pg_total_relation_size`, or a wire-compatible engine that does not have
1729
+ * it, leaves the field absent rather than turning `Number(undefined)` into a
1730
+ * `NaN` that serializes as `null` with no explanation.
1731
+ */
1732
+ async function collectTableStats(client, schema) {
1733
+ const result = await client.query(`SELECT c.relname,
1734
+ c.reltuples::bigint::text AS reltuples,
1735
+ c.relpages::bigint::text AS relpages,
1736
+ pg_total_relation_size(c.oid)::text AS total_size,
1737
+ pg_relation_size(c.oid)::text AS table_size,
1738
+ (SELECT count(*) FROM pg_index i WHERE i.indrelid = c.oid)::text AS index_count
1116
1739
  FROM pg_class c
1117
1740
  JOIN pg_namespace n ON n.oid = c.relnamespace
1118
- WHERE n.nspname = $1 AND c.relkind = 'r'`, [schema]);
1119
- const counts = new Map();
1120
- for (const row of result.rows)
1121
- counts.set(row.relname, Math.max(0, Number(row.reltuples)));
1122
- return counts;
1741
+ WHERE n.nspname = $1 AND c.relkind = 'r'
1742
+ ORDER BY c.relname`, [schema]);
1743
+ const num = (value) => {
1744
+ if (value === null || value === undefined)
1745
+ return undefined;
1746
+ const parsed = Number(value);
1747
+ return Number.isFinite(parsed) ? parsed : undefined;
1748
+ };
1749
+ const stats = new Map();
1750
+ for (const row of result.rows) {
1751
+ stats.set(row.relname, {
1752
+ table: row.relname,
1753
+ reltuples: num(row.reltuples) ?? 0,
1754
+ relpages: num(row.relpages),
1755
+ totalSizeBytes: num(row.total_size),
1756
+ tableSizeBytes: num(row.table_size),
1757
+ existingIndexCount: num(row.index_count),
1758
+ });
1759
+ }
1760
+ return stats;
1761
+ }
1762
+ /**
1763
+ * The row estimate the schema tools print: `reltuples` floored at 0, because a
1764
+ * never-analyzed table reports -1 and "-1 rows" is not a thing to show anyone.
1765
+ * `table_stats` deliberately does NOT go through this: it reports the unknown
1766
+ * as unknown rather than as zero.
1767
+ */
1768
+ function estimatedRowCount(stats) {
1769
+ return Math.max(0, stats?.reltuples ?? 0);
1123
1770
  }
1124
1771
  function requireTable(metadata, tableName) {
1125
1772
  const table = ownLookup(metadata.tables, tableName);
@@ -1129,14 +1776,30 @@ function requireTable(metadata, tableName) {
1129
1776
  }
1130
1777
  return table;
1131
1778
  }
1779
+ /**
1780
+ * The key-list entries of an index definition, for this server's copy of the
1781
+ * catalog.
1782
+ *
1783
+ * ONE PARSER, SHARED WITH `turbine generate`. This used to be a second,
1784
+ * independently written implementation, and it drifted from
1785
+ * {@link parseIndexKeyEntries} in ways that mattered: on
1786
+ * `USING btree (id) INCLUDE (email)` it answered `['id) INCLUDE (email']` where
1787
+ * introspection answered `['id']`, and the same for `WITH (fillfactor=…)`. Those
1788
+ * columns are handed to {@link deriveCatalogRelations}, which decides
1789
+ * hasOne-vs-hasMany and auto-m2m from unique-index coverage, so a UNIQUE index
1790
+ * with INCLUDE columns was visible to `turbine generate` and invisible here, and
1791
+ * `relation_graph` / `find_join_path` omitted a relation the ORM accepts.
1792
+ * Duplication is also what produced the predicate leak this function's history
1793
+ * records (`name) WHERE (email = 'ceo@example.com'`, verbatim, in `columns`).
1794
+ *
1795
+ * The one thing this caller wants differently is an EXPRESSION entry.
1796
+ * `parseIndexColumns` drops it, since generated metadata has nowhere to say a
1797
+ * key was not a column; here it is KEPT verbatim so {@link sanitizeIndex} can
1798
+ * report `columnsWithheld: true` rather than silently returning a shorter list.
1799
+ * That difference is now one `??` rather than a second implementation.
1800
+ */
1132
1801
  function extractIndexColumns(indexdef) {
1133
- const match = indexdef.match(/\((.+)\)/);
1134
- if (!match)
1135
- return [];
1136
- return match[1].split(',').map((column) => column
1137
- .trim()
1138
- .replace(/ (ASC|DESC)$/i, '')
1139
- .replace(/^"|"$/g, ''));
1802
+ return parseIndexKeyEntries(indexdef).map((entry) => indexKeyColumn(entry) ?? entry);
1140
1803
  }
1141
1804
  function optionalLimit(value) {
1142
1805
  if (value === undefined)
@@ -1153,6 +1816,29 @@ function requiredString(args, key) {
1153
1816
  }
1154
1817
  return value;
1155
1818
  }
1819
+ /**
1820
+ * An optional string argument. An EMPTY string is refused rather than treated as
1821
+ * absent: `{ table: '' }` is a caller that meant to pass a table and computed
1822
+ * nothing, and silently answering the whole-schema question instead hides that.
1823
+ */
1824
+ function optionalString(args, key) {
1825
+ const value = args[key];
1826
+ if (value === undefined || value === null)
1827
+ return undefined;
1828
+ if (typeof value !== 'string' || value.trim() === '') {
1829
+ throw jsonRpcError(-32602, `${key} must be a non-empty string when provided`);
1830
+ }
1831
+ return value;
1832
+ }
1833
+ /** An optional bounded integer argument, refused (never clamped) when out of range. */
1834
+ function optionalInteger(value, key, min, max) {
1835
+ if (value === undefined || value === null)
1836
+ return undefined;
1837
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
1838
+ throw jsonRpcError(-32602, `${key} must be an integer between ${min} and ${max}`);
1839
+ }
1840
+ return value;
1841
+ }
1156
1842
  function sha256(content) {
1157
1843
  return createHash('sha256').update(content, 'utf-8').digest('hex');
1158
1844
  }