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