turbine-orm 0.67.0 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/cli/error-catalog.d.ts +77 -0
- package/dist/cjs/cli/error-catalog.js +388 -0
- package/dist/cjs/cli/index.js +3 -2
- package/dist/cjs/cli/mcp.d.ts +19 -3
- package/dist/cjs/cli/mcp.js +709 -22
- package/dist/cjs/cli/migrate.d.ts +19 -2
- package/dist/cjs/cli/observe.d.ts +2 -2
- package/dist/cjs/cli/observe.js +20 -2
- package/dist/cjs/cli/pii-predicate-guard.d.ts +6 -2
- package/dist/cjs/cli/pii-predicate-guard.js +6 -2
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/client.d.ts +30 -75
- package/dist/cjs/client.js +31 -11
- package/dist/cjs/introspect.d.ts +113 -17
- package/dist/cjs/introspect.js +229 -33
- package/dist/cjs/pg-types.d.ts +153 -0
- package/dist/cjs/pg-types.js +38 -0
- package/dist/cjs/pipeline.d.ts +3 -3
- package/dist/cjs/query/batched-loader.d.ts +2 -2
- package/dist/cjs/query/builder.d.ts +2 -2
- package/dist/cjs/query/builder.js +10 -1
- package/dist/cjs/query/deferred.d.ts +6 -6
- package/dist/cjs/query/filters.d.ts +13 -7
- package/dist/cjs/query/filters.js +13 -14
- package/dist/cjs/query/where.d.ts +2 -2
- package/dist/cjs/schema-sql.d.ts +18 -0
- package/dist/cjs/schema-sql.js +18 -0
- package/dist/cli/error-catalog.d.ts +77 -0
- package/dist/cli/error-catalog.js +383 -0
- package/dist/cli/index.js +3 -2
- package/dist/cli/mcp.d.ts +19 -3
- package/dist/cli/mcp.js +709 -23
- package/dist/cli/migrate.d.ts +19 -2
- package/dist/cli/observe.d.ts +2 -2
- package/dist/cli/observe.js +20 -2
- package/dist/cli/pii-predicate-guard.d.ts +6 -2
- package/dist/cli/pii-predicate-guard.js +6 -2
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/client.d.ts +30 -75
- package/dist/client.js +31 -11
- package/dist/introspect.d.ts +113 -17
- package/dist/introspect.js +227 -33
- package/dist/pg-types.d.ts +153 -0
- package/dist/pg-types.js +37 -0
- package/dist/pipeline.d.ts +3 -3
- package/dist/query/batched-loader.d.ts +2 -2
- package/dist/query/builder.d.ts +2 -2
- package/dist/query/builder.js +10 -1
- package/dist/query/deferred.d.ts +6 -6
- package/dist/query/filters.d.ts +13 -7
- package/dist/query/filters.js +13 -13
- package/dist/query/where-compile.js +1 -1
- package/dist/query/where.d.ts +2 -2
- package/dist/schema-sql.d.ts +18 -0
- package/dist/schema-sql.js +18 -0
- package/package.json +19 -7
package/dist/cjs/cli/mcp.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
553
|
-
const missing = (0, index_advisor_js_1.findMissingRelationIndexes)(metadata).sort((a, b) => (rowCounts.get(b.table)
|
|
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)
|
|
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
|
-
|
|
1123
|
-
|
|
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'
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
-
|
|
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
|
}
|