turbine-orm 0.61.0 → 0.62.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/README.md +65 -21
- package/dist/cjs/cli/config.d.ts +40 -0
- package/dist/cjs/cli/config.js +74 -2
- package/dist/cjs/cli/index.d.ts +85 -1
- package/dist/cjs/cli/index.js +323 -24
- package/dist/cjs/cli/mcp.d.ts +8 -0
- package/dist/cjs/cli/mcp.js +448 -29
- package/dist/cjs/cli/pii-tags.d.ts +64 -9
- package/dist/cjs/cli/pii-tags.js +218 -39
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.d.ts +23 -0
- package/dist/cjs/cli/studio.js +126 -53
- package/dist/cjs/cli/ui.d.ts +15 -1
- package/dist/cjs/cli/ui.js +19 -5
- package/dist/cjs/client.js +186 -3
- package/dist/cjs/errors.d.ts +38 -1
- package/dist/cjs/errors.js +235 -24
- package/dist/cjs/index.d.ts +2 -2
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/pipeline.js +15 -2
- package/dist/cjs/powql.d.ts +12 -0
- package/dist/cjs/powql.js +46 -21
- package/dist/cjs/prisma-compat.d.ts +15 -5
- package/dist/cjs/prisma-compat.js +273 -78
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +24 -10
- package/dist/cjs/query/batched-loader.d.ts +9 -4
- package/dist/cjs/query/batched-loader.js +4 -1
- package/dist/cjs/query/builder.d.ts +47 -0
- package/dist/cjs/query/builder.js +125 -21
- package/dist/cjs/query/index.d.ts +3 -1
- package/dist/cjs/query/index.js +7 -1
- package/dist/cjs/query/option-surface.d.ts +11 -0
- package/dist/cjs/query/option-surface.js +13 -0
- package/dist/cjs/query/relations.d.ts +8 -0
- package/dist/cjs/query/relations.js +21 -1
- package/dist/cjs/query/types.d.ts +152 -18
- package/dist/cjs/query/types.js +212 -1
- package/dist/cjs/query/where.d.ts +3 -3
- package/dist/cjs/query/where.js +8 -2
- package/dist/cjs/query/writes.js +10 -9
- package/dist/cli/config.d.ts +40 -0
- package/dist/cli/config.js +73 -2
- package/dist/cli/index.d.ts +85 -1
- package/dist/cli/index.js +321 -26
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +448 -29
- package/dist/cli/pii-tags.d.ts +64 -9
- package/dist/cli/pii-tags.js +217 -39
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +23 -0
- package/dist/cli/studio.js +125 -53
- package/dist/cli/ui.d.ts +15 -1
- package/dist/cli/ui.js +18 -4
- package/dist/client.js +187 -4
- package/dist/errors.d.ts +38 -1
- package/dist/errors.js +234 -23
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -2
- package/dist/pipeline.js +15 -2
- package/dist/powql.d.ts +12 -0
- package/dist/powql.js +46 -21
- package/dist/prisma-compat.d.ts +15 -5
- package/dist/prisma-compat.js +274 -79
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +24 -10
- package/dist/query/batched-loader.d.ts +9 -4
- package/dist/query/batched-loader.js +4 -1
- package/dist/query/builder.d.ts +47 -0
- package/dist/query/builder.js +124 -21
- package/dist/query/index.d.ts +3 -1
- package/dist/query/index.js +2 -0
- package/dist/query/option-surface.d.ts +11 -0
- package/dist/query/option-surface.js +13 -0
- package/dist/query/relations.d.ts +8 -0
- package/dist/query/relations.js +21 -1
- package/dist/query/types.d.ts +152 -18
- package/dist/query/types.js +207 -2
- package/dist/query/where.d.ts +3 -3
- package/dist/query/where.js +8 -2
- package/dist/query/writes.js +10 -9
- package/package.json +13 -3
package/dist/cjs/cli/mcp.js
CHANGED
|
@@ -17,6 +17,7 @@ const utils_js_1 = require("../query/utils.js");
|
|
|
17
17
|
const schema_js_1 = require("../schema.js");
|
|
18
18
|
const migrate_js_1 = require("./migrate.js");
|
|
19
19
|
const pii_tags_js_1 = require("./pii-tags.js");
|
|
20
|
+
const ui_js_1 = require("./ui.js");
|
|
20
21
|
/**
|
|
21
22
|
* Walk up from the running script to find turbine-orm's own package.json.
|
|
22
23
|
* Uses process.argv[1] instead of import.meta.url so the same code compiles
|
|
@@ -53,6 +54,67 @@ function readOwnVersion() {
|
|
|
53
54
|
const PROTOCOL_VERSION = '2025-06-18';
|
|
54
55
|
const STATEMENT_TIMEOUT = '30s';
|
|
55
56
|
const TRACKING_TABLE = '_turbine_migrations';
|
|
57
|
+
/**
|
|
58
|
+
* Marker written in place of a hidden cell. Never `null` and never the value,
|
|
59
|
+
* so the agent can tell "hidden" from "empty" (same string Studio uses).
|
|
60
|
+
*/
|
|
61
|
+
const REDACTED = '•• redacted ••';
|
|
62
|
+
/**
|
|
63
|
+
* Column names treated as secret on NAME ALONE, on top of the code-first `pii`
|
|
64
|
+
* tags. Applies to BOTH value-bearing paths: `sample_rows` never fetches such a
|
|
65
|
+
* column, and `explain_query` refuses to filter or sort on one.
|
|
66
|
+
*
|
|
67
|
+
* `introspect.ts` deliberately never GUESSES that a column holds personal data,
|
|
68
|
+
* because a wrong guess there would write a durable tag into metadata. This
|
|
69
|
+
* list is the opposite trade and is why the rule differs: it never touches
|
|
70
|
+
* metadata, it only decides whether the raw value can reach an LLM context
|
|
71
|
+
* window. Over-redacting a column called `password_hash` costs an agent one
|
|
72
|
+
* uninteresting sample value; under-redacting it hands out a credential.
|
|
73
|
+
*
|
|
74
|
+
* It covers the two paths TOGETHER on purpose. Hiding the bytes in one tool
|
|
75
|
+
* while letting the other walk them out of the planner's row estimate is not a
|
|
76
|
+
* weaker perimeter, it is no perimeter: the oracle path is the cheaper of the
|
|
77
|
+
* two, since it needs no read privilege on the row and returns an answer per
|
|
78
|
+
* guessed character. Both tools report exactly what they refused, so neither
|
|
79
|
+
* redaction is silent.
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* Column names that look like they hold a credential.
|
|
83
|
+
*
|
|
84
|
+
* Anchored to IDENTIFIER SEGMENT boundaries (start, end, or an underscore), not
|
|
85
|
+
* a bare substring. Unanchored, `secret` matched `secretary_id` and `token`
|
|
86
|
+
* matched a perfectly ordinary `token_count`, so the guard refused legitimate
|
|
87
|
+
* queries: a false refusal is not free here, it degrades the tool and trains
|
|
88
|
+
* people to route around it.
|
|
89
|
+
*
|
|
90
|
+
* Deliberately still conservative in the other direction. A column whose name
|
|
91
|
+
* genuinely carries a segment like `token` or `secret` is refused even when it
|
|
92
|
+
* holds nothing sensitive, because this gate decides whether raw bytes, or a
|
|
93
|
+
* row-count oracle over them, reach an LLM context. Renaming the column is a
|
|
94
|
+
* cheaper fix than the disclosure it prevents.
|
|
95
|
+
*/
|
|
96
|
+
const SECRET_WORDS = [
|
|
97
|
+
'passwd',
|
|
98
|
+
'password',
|
|
99
|
+
'secret',
|
|
100
|
+
'token',
|
|
101
|
+
'apikey',
|
|
102
|
+
'api_key',
|
|
103
|
+
'privatekey',
|
|
104
|
+
'private_key',
|
|
105
|
+
'credential',
|
|
106
|
+
'credentials',
|
|
107
|
+
'sessionid',
|
|
108
|
+
'session_id',
|
|
109
|
+
'otp',
|
|
110
|
+
'mfa',
|
|
111
|
+
'totp',
|
|
112
|
+
];
|
|
113
|
+
const SECRET_NAME_PATTERN = new RegExp(`(^|_)(${SECRET_WORDS.join('|')})(_|$)`, 'i');
|
|
114
|
+
/** True when tags could not be read, so nothing may be assumed to be non-PII. */
|
|
115
|
+
function tagsUnreadable(status) {
|
|
116
|
+
return status.state === 'tags-unreadable';
|
|
117
|
+
}
|
|
56
118
|
const TOOLS = [
|
|
57
119
|
{
|
|
58
120
|
name: 'schema_overview',
|
|
@@ -81,7 +143,7 @@ const TOOLS = [
|
|
|
81
143
|
},
|
|
82
144
|
{
|
|
83
145
|
name: 'explain_query',
|
|
84
|
-
description: 'Run EXPLAIN (FORMAT JSON) for a schema-validated findMany query. Pass table + optional where/orderBy/limit/select, free-form SQL is rejected.',
|
|
146
|
+
description: 'Run EXPLAIN (FORMAT JSON) for a schema-validated findMany query. Pass table + optional where/orderBy/limit/select, free-form SQL is rejected. A where or orderBy on a PII-tagged or secret-named column is refused: the planner row estimate would leak the value. Naming such a column in select is allowed, since EXPLAIN returns no rows.',
|
|
85
147
|
inputSchema: {
|
|
86
148
|
type: 'object',
|
|
87
149
|
properties: {
|
|
@@ -106,7 +168,7 @@ const TOOLS = [
|
|
|
106
168
|
},
|
|
107
169
|
{
|
|
108
170
|
name: 'sample_rows',
|
|
109
|
-
description: 'Read up to 50 rows from a validated table.',
|
|
171
|
+
description: 'Read up to 50 rows from a validated table. PII-tagged and secret-named columns are never fetched; the reply lists exactly what was hidden and where the PII tags came from.',
|
|
110
172
|
inputSchema: {
|
|
111
173
|
type: 'object',
|
|
112
174
|
properties: { table: { type: 'string' }, limit: { type: 'number', minimum: 1, maximum: 50 } },
|
|
@@ -125,8 +187,19 @@ function startMcpServer(options, transport = {}) {
|
|
|
125
187
|
(0, utils_js_1.registerUtcTemporalParsers)();
|
|
126
188
|
const ctx = {
|
|
127
189
|
options,
|
|
128
|
-
pool: new pg_1.default.Pool({ connectionString: options.url, max: 2, idleTimeoutMillis: 10_000 }),
|
|
190
|
+
pool: transport.pool ?? new pg_1.default.Pool({ connectionString: options.url, max: 2, idleTimeoutMillis: 10_000 }),
|
|
129
191
|
};
|
|
192
|
+
// An idle pooled connection that dies (server restart, proxy idle timeout)
|
|
193
|
+
// emits 'error' on the POOL, which has no default listener: unhandled, it
|
|
194
|
+
// takes the whole CLI process down mid-session, and the agent sees the stdio
|
|
195
|
+
// transport vanish with no message. Log to stderr, never stdout: stdout is
|
|
196
|
+
// the JSON-RPC framing channel and one stray line desynchronizes the client.
|
|
197
|
+
// The message is redacted because pg echoes the connection string into some
|
|
198
|
+
// connection failures, and this text is written where a user can see it.
|
|
199
|
+
ctx.pool.on('error', (err) => {
|
|
200
|
+
process.stderr.write(`[turbine] mcp pool error: ${(0, ui_js_1.redactUrl)(err.message)}\n`);
|
|
201
|
+
});
|
|
202
|
+
announcePiiTags(options);
|
|
130
203
|
let buffer = '';
|
|
131
204
|
let disposed = false;
|
|
132
205
|
const write = (payload) => {
|
|
@@ -157,6 +230,35 @@ function startMcpServer(options, transport = {}) {
|
|
|
157
230
|
},
|
|
158
231
|
};
|
|
159
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Say, once at startup, what the redaction is actually running on.
|
|
235
|
+
*
|
|
236
|
+
* `turbine studio` prints its tag count and source path; this server printed
|
|
237
|
+
* nothing at all, so an operator had no way to notice that the tags they
|
|
238
|
+
* declared were not in force. Everything goes to STDERR, never stdout: stdout
|
|
239
|
+
* carries the JSON-RPC framing and one stray line desynchronizes the client.
|
|
240
|
+
*/
|
|
241
|
+
function announcePiiTags(options) {
|
|
242
|
+
if (!options.metadataDir) {
|
|
243
|
+
process.stderr.write('[turbine] mcp: no metadata directory configured, so code-first PII tags are not loaded and ' +
|
|
244
|
+
'sample_rows redacts only on column name.\n');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const source = (0, pii_tags_js_1.loadPiiTags)(options.metadataDir);
|
|
248
|
+
if (!source) {
|
|
249
|
+
process.stderr.write(`[turbine] mcp: no generated metadata found in ${options.metadataDir}, so code-first PII tags are not ` +
|
|
250
|
+
'loaded. Run `turbine generate` if your schema tags PII columns.\n');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (!source.scan.ok) {
|
|
254
|
+
// Loud, because this is the state that used to look like success.
|
|
255
|
+
process.stderr.write(`[turbine] mcp WARNING: ${source.path} exists but its PII tags could not be read (${source.scan.reason}). ` +
|
|
256
|
+
'Failing closed: sample_rows will redact EVERY column and explain_query will refuse where/orderBy. ' +
|
|
257
|
+
'Re-run `turbine generate` to fix this.\n');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
process.stderr.write(`[turbine] mcp: ${source.count} PII-tagged column(s) loaded from ${source.path}\n`);
|
|
261
|
+
}
|
|
160
262
|
async function handleLine(line, ctx, write) {
|
|
161
263
|
let message;
|
|
162
264
|
try {
|
|
@@ -238,7 +340,7 @@ async function callTool(params, ctx) {
|
|
|
238
340
|
}
|
|
239
341
|
async function schemaOverview(ctx) {
|
|
240
342
|
return withReadOnly(ctx, async (client) => {
|
|
241
|
-
const metadata = await loadSchemaMetadata(client, ctx.options);
|
|
343
|
+
const { metadata } = await loadSchemaMetadata(client, ctx.options);
|
|
242
344
|
const rowCounts = await estimateRows(client, ctx.options.schema);
|
|
243
345
|
return {
|
|
244
346
|
schema: ctx.options.schema,
|
|
@@ -256,7 +358,7 @@ async function schemaOverview(ctx) {
|
|
|
256
358
|
}
|
|
257
359
|
async function tableDetail(ctx, tableName) {
|
|
258
360
|
return withReadOnly(ctx, async (client) => {
|
|
259
|
-
const metadata = await loadSchemaMetadata(client, ctx.options);
|
|
361
|
+
const { metadata } = await loadSchemaMetadata(client, ctx.options);
|
|
260
362
|
const table = requireTable(metadata, tableName);
|
|
261
363
|
return {
|
|
262
364
|
name: table.name,
|
|
@@ -272,7 +374,7 @@ async function tableDetail(ctx, tableName) {
|
|
|
272
374
|
isArray: column.isArray,
|
|
273
375
|
maxLength: column.maxLength,
|
|
274
376
|
})),
|
|
275
|
-
indexes: table.indexes,
|
|
377
|
+
indexes: table.indexes.map(sanitizeIndex),
|
|
276
378
|
relations: Object.values(table.relations).map((relation) => ({
|
|
277
379
|
name: relation.name,
|
|
278
380
|
type: relation.type,
|
|
@@ -285,10 +387,131 @@ async function tableDetail(ctx, tableName) {
|
|
|
285
387
|
};
|
|
286
388
|
});
|
|
287
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* Index of the predicate-introducing ` WHERE `, ignoring any that sits INSIDE a
|
|
392
|
+
* single-quoted string literal.
|
|
393
|
+
*
|
|
394
|
+
* A plain `indexOf(' WHERE ')` matches the first occurrence anywhere, so an
|
|
395
|
+
* expression index whose key list embeds the text (`((email || ' WHERE '))`)
|
|
396
|
+
* gets cut mid-literal. The head then no longer contains a balanced quote, the
|
|
397
|
+
* literal check on it reads clean, and the literal ships. That is the exact
|
|
398
|
+
* failure this function exists to prevent, arriving through the parser rather
|
|
399
|
+
* than through the branch.
|
|
400
|
+
*
|
|
401
|
+
* Postgres escapes an embedded quote by doubling it, and a doubled quote toggles
|
|
402
|
+
* the flag twice, so it needs no special case.
|
|
403
|
+
*/
|
|
404
|
+
function predicateStart(definition) {
|
|
405
|
+
let inLiteral = false;
|
|
406
|
+
for (let i = 0; i < definition.length; i++) {
|
|
407
|
+
if (definition[i] === "'") {
|
|
408
|
+
inLiteral = !inLiteral;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (!inLiteral && definition.startsWith(' WHERE ', i))
|
|
412
|
+
return i;
|
|
413
|
+
}
|
|
414
|
+
return -1;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Strip literal values out of an index definition before returning it.
|
|
418
|
+
*
|
|
419
|
+
* `pg_indexes.indexdef` is raw DDL, and a PARTIAL index carries its predicate
|
|
420
|
+
* verbatim: `CREATE INDEX ... WHERE (email = 'ceo@example.com')` puts a real
|
|
421
|
+
* stored value in the reply, and an expression index can do the same inside the
|
|
422
|
+
* key list. Column names are already public in this tool's own output, so the
|
|
423
|
+
* useful part is kept and only the value-bearing tail is dropped. The predicate
|
|
424
|
+
* is reported as PRESENT, so the shape of the index is still legible.
|
|
425
|
+
*/
|
|
426
|
+
function sanitizeIndex(index) {
|
|
427
|
+
const definition = index.definition ?? '';
|
|
428
|
+
// pg renders the predicate as a trailing ` WHERE ...` on one line, so the tail
|
|
429
|
+
// from the keyword onwards is the whole predicate. Matched case-sensitively
|
|
430
|
+
// and OUTSIDE string literals (see predicateStart): pg normalizes the keyword
|
|
431
|
+
// to upper case, so a lower-case `where` in a quoted identifier cannot trigger
|
|
432
|
+
// it, and an upper-case one inside a literal no longer can either.
|
|
433
|
+
const whereAt = predicateStart(definition);
|
|
434
|
+
const partial = whereAt !== -1;
|
|
435
|
+
// Everything before the predicate, which is where the KEY LIST lives. Checked
|
|
436
|
+
// for both index shapes: an expression index can embed a literal
|
|
437
|
+
// (`lower(email || 'x')`), and it can be partial at the same time, in which
|
|
438
|
+
// case dropping only the predicate still ships the literal in the key list.
|
|
439
|
+
// The key-list check used to sit in the non-partial branch alone, so exactly
|
|
440
|
+
// the combination this function was written for went out verbatim.
|
|
441
|
+
// Double quotes are NOT a trigger: those delimit an identifier, and
|
|
442
|
+
// identifiers are already returned in `columns`.
|
|
443
|
+
const head = partial ? definition.slice(0, whereAt) : definition;
|
|
444
|
+
const keys = keyList(head);
|
|
445
|
+
const keysHoldLiteral = keys === null || /[(']/.test(keys);
|
|
446
|
+
// `columns` is not a safe passthrough for an expression index: it is the key
|
|
447
|
+
// list split on commas, so for `((email || 'ceo@example.com'))` the "column"
|
|
448
|
+
// IS the literal. Only entries that are a bare identifier survive, and only
|
|
449
|
+
// on the expression path, so an ordinary index (including one with a quoted
|
|
450
|
+
// identifier holding a space) is untouched.
|
|
451
|
+
const columns = keysHoldLiteral ? index.columns.filter((column) => PLAIN_IDENTIFIER.test(column)) : index.columns;
|
|
452
|
+
return {
|
|
453
|
+
name: index.name,
|
|
454
|
+
columns,
|
|
455
|
+
columnsWithheld: columns.length !== index.columns.length,
|
|
456
|
+
unique: index.unique,
|
|
457
|
+
partial,
|
|
458
|
+
// Withholding is LABELLED, never expressed by dropping the field:
|
|
459
|
+
// `JSON.stringify` omits an `undefined` value, so the agent would see an
|
|
460
|
+
// index with no definition and no reason, which reads identically to an
|
|
461
|
+
// index whose definition was never collected. Every other withholding in
|
|
462
|
+
// this file names itself, and so does this one.
|
|
463
|
+
definitionWithheld: keysHoldLiteral,
|
|
464
|
+
definition: keysHoldLiteral
|
|
465
|
+
? keys === null
|
|
466
|
+
? '(definition withheld: the index definition could not be parsed, so it cannot be shown to hold no literal values)'
|
|
467
|
+
: '(definition withheld: the index key list is an expression that may embed literal values)'
|
|
468
|
+
: partial
|
|
469
|
+
? `${head} WHERE (predicate withheld)`
|
|
470
|
+
: definition,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
/** A key-list entry that is a bare column name, so it can hold no literal. */
|
|
474
|
+
const PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
475
|
+
/**
|
|
476
|
+
* The parenthesized key list of an index definition, or `null` when the
|
|
477
|
+
* definition does not parse as one.
|
|
478
|
+
*
|
|
479
|
+
* `null` is not the same answer as an empty key list, and the caller must not
|
|
480
|
+
* collapse them. This used to return `''`, which scanned clean for literals and
|
|
481
|
+
* shipped the whole definition verbatim: the ONE input this function cannot
|
|
482
|
+
* read is the one it waved through. Unparsable now means WITHHELD.
|
|
483
|
+
*/
|
|
484
|
+
function keyList(definition) {
|
|
485
|
+
const open = definition.indexOf('(');
|
|
486
|
+
const close = definition.lastIndexOf(')');
|
|
487
|
+
return open === -1 || close <= open ? null : definition.slice(open + 1, close);
|
|
488
|
+
}
|
|
288
489
|
async function migrationStatus(ctx) {
|
|
289
490
|
return withReadOnly(ctx, async (client) => {
|
|
290
491
|
const files = (0, migrate_js_1.listMigrationFiles)(ctx.options.migrationsDir);
|
|
291
|
-
|
|
492
|
+
// DELIBERATELY UNPINNED, unlike every other tool here.
|
|
493
|
+
//
|
|
494
|
+
// This tool's whole job is to report what `turbine migrate status` would
|
|
495
|
+
// report, and the runner in cli/migrate.ts names `_turbine_migrations`
|
|
496
|
+
// unqualified with no search_path of its own, so the tracking table lives
|
|
497
|
+
// wherever the connecting role's search_path put it, which is frequently
|
|
498
|
+
// `public` even for a project whose data schema is something else. Pinning
|
|
499
|
+
// `search_path` to `--schema` here does not harden that, it ANSWERS A
|
|
500
|
+
// DIFFERENT QUESTION: `turbine migrate status` would say "applied" while
|
|
501
|
+
// `migrate_status` said the tracking table did not exist. Between agreeing
|
|
502
|
+
// with the migration runner and imposing a rule the runner does not follow,
|
|
503
|
+
// agreeing is the only one that can be right.
|
|
504
|
+
//
|
|
505
|
+
// The resolution is DISCLOSED instead: the reply names the schema the
|
|
506
|
+
// tracking table actually resolved in, plus a note when that is not the
|
|
507
|
+
// configured schema, so the divergence is visible rather than silently
|
|
508
|
+
// decided either way. (`explain_query` and `sample_rows` still pin, because
|
|
509
|
+
// they read the schema's own tables, not the runner's bookkeeping.)
|
|
510
|
+
const trackingExists = await client.query(`SELECT reg.oid IS NOT NULL AS exists, n.nspname AS table_schema
|
|
511
|
+
FROM (SELECT to_regclass($1) AS oid) reg
|
|
512
|
+
LEFT JOIN pg_class c ON c.oid = reg.oid
|
|
513
|
+
LEFT JOIN pg_namespace n ON n.oid = c.relnamespace`, [TRACKING_TABLE]);
|
|
514
|
+
const trackingSchema = trackingExists.rows[0]?.table_schema ?? null;
|
|
292
515
|
const applied = new Map();
|
|
293
516
|
if (trackingExists.rows[0]?.exists) {
|
|
294
517
|
const result = await client.query(`SELECT name, applied_at, checksum FROM ${(0, index_js_1.quoteIdent)(TRACKING_TABLE)} ORDER BY name`);
|
|
@@ -309,6 +532,12 @@ async function migrationStatus(ctx) {
|
|
|
309
532
|
return {
|
|
310
533
|
migrationsDir: ctx.options.migrationsDir,
|
|
311
534
|
trackingTableExists: trackingExists.rows[0]?.exists ?? false,
|
|
535
|
+
trackingTableSchema: trackingSchema,
|
|
536
|
+
trackingTableNote: trackingSchema !== null && trackingSchema !== ctx.options.schema
|
|
537
|
+
? `Migrations are tracked in "${trackingSchema}", not the configured schema "${ctx.options.schema}". ` +
|
|
538
|
+
`This is what \`turbine migrate status\` reads too: the runner resolves the tracking table through ` +
|
|
539
|
+
`the connection's search_path rather than the --schema flag.`
|
|
540
|
+
: undefined,
|
|
312
541
|
applied: statuses.filter((status) => status.applied).length,
|
|
313
542
|
pending: statuses.filter((status) => !status.applied).length,
|
|
314
543
|
drifted: statuses.filter((status) => status.checksumValid === false).length,
|
|
@@ -318,7 +547,7 @@ async function migrationStatus(ctx) {
|
|
|
318
547
|
}
|
|
319
548
|
async function doctorReport(ctx) {
|
|
320
549
|
return withReadOnly(ctx, async (client) => {
|
|
321
|
-
const metadata = await loadSchemaMetadata(client, ctx.options);
|
|
550
|
+
const { metadata } = await loadSchemaMetadata(client, ctx.options);
|
|
322
551
|
const rowCounts = await estimateRows(client, ctx.options.schema);
|
|
323
552
|
const missing = (0, index_advisor_js_1.findMissingRelationIndexes)(metadata).sort((a, b) => (rowCounts.get(b.table) ?? 0) - (rowCounts.get(a.table) ?? 0));
|
|
324
553
|
return {
|
|
@@ -349,8 +578,9 @@ async function explainQuery(ctx, args) {
|
|
|
349
578
|
const tableName = requiredString(args, 'table');
|
|
350
579
|
const findManyArgs = parseExplainFindManyArgs(args);
|
|
351
580
|
return withReadOnly(ctx, async (client) => {
|
|
352
|
-
const metadata = await loadSchemaMetadata(client, ctx.options);
|
|
581
|
+
const { metadata, piiTags } = await loadSchemaMetadata(client, ctx.options);
|
|
353
582
|
const table = requireTable(metadata, tableName);
|
|
583
|
+
assertNoPiiPredicates(findManyArgs, table, metadata, piiTags);
|
|
354
584
|
let deferred;
|
|
355
585
|
try {
|
|
356
586
|
// Build-only: pool is unused for SQL generation (mirrors Studio).
|
|
@@ -376,6 +606,133 @@ async function explainQuery(ctx, args) {
|
|
|
376
606
|
};
|
|
377
607
|
});
|
|
378
608
|
}
|
|
609
|
+
/** Relation-filter wrappers whose body is a clause against the relation's target. */
|
|
610
|
+
const RELATION_FILTER_WRAPPERS = ['some', 'none', 'every', 'is', 'isNot'];
|
|
611
|
+
/**
|
|
612
|
+
* Recursion bound for the PII guard walk. Reaching it REFUSES the request, it
|
|
613
|
+
* is not a quiet stop: returning at the cap would mean a payload padded with
|
|
614
|
+
* enough nested `NOT` wrappers walks the guard off the end of its own recursion
|
|
615
|
+
* and then hands the untouched predicate to the builder. Sits far above the
|
|
616
|
+
* builder's own depth-10 relation cap, so nothing buildable is refused for
|
|
617
|
+
* depth alone.
|
|
618
|
+
*/
|
|
619
|
+
const PII_GUARD_MAX_DEPTH = 32;
|
|
620
|
+
/**
|
|
621
|
+
* Refuse an `explain_query` that filters or sorts on a hidden column: one that
|
|
622
|
+
* is PII-tagged, or one whose NAME matches {@link SECRET_NAME_PATTERN}.
|
|
623
|
+
*
|
|
624
|
+
* The name half is not a second-best approximation of the tag half, it is the
|
|
625
|
+
* same rule `sample_rows` applies, applied to the other value-bearing path.
|
|
626
|
+
* Refusing to fetch `api_key` while planning `apiKey startsWith 'sk-a'` leaves
|
|
627
|
+
* the value extractable through a cheaper channel than the one that was closed.
|
|
628
|
+
*
|
|
629
|
+
* WHY REFUSE THE PREDICATE RATHER THAN SUPPRESS THE ESTIMATES. `EXPLAIN` on a
|
|
630
|
+
* PII predicate is a character-by-character extraction oracle: `startsWith: 'a'`
|
|
631
|
+
* plans 412 rows, `'aa'` plans 3, and the caller here is an LLM acting on
|
|
632
|
+
* attacker-influenceable input, with no execution and no rate limit to slow the
|
|
633
|
+
* walk down. Suppression was the other option and is strictly weaker: the row
|
|
634
|
+
* estimate is not one field to delete but the thing the whole plan is built out
|
|
635
|
+
* of, and it is recoverable from `Total Cost`, from `Plan Width` x rows, from
|
|
636
|
+
* the join order, and from whether the planner picked an index at all. Deleting
|
|
637
|
+
* all of that leaves a tool with nothing to report, so the narrower loss is to
|
|
638
|
+
* refuse the predicate. It matches the rule the rest of the codebase already
|
|
639
|
+
* states: predicates on PII are allowed IN THE ORM because they return no
|
|
640
|
+
* value, and that reasoning stops holding the moment the query's SELECTIVITY is
|
|
641
|
+
* itself the reply. Studio drew the same line for the same reason
|
|
642
|
+
* (`assertNoPiiPredicates`, and its `filters` param refuses even `isNull`,
|
|
643
|
+
* because null-ness is an oracle too).
|
|
644
|
+
*
|
|
645
|
+
* `select` is NOT refused: explain returns no rows, and naming a column reveals
|
|
646
|
+
* nothing about its contents.
|
|
647
|
+
*
|
|
648
|
+
* FAILS CLOSED when the tag scan failed: with no trustworthy tag list, no
|
|
649
|
+
* column can be shown to be safe, so every where/orderBy is refused rather than
|
|
650
|
+
* assumed harmless.
|
|
651
|
+
*/
|
|
652
|
+
function assertNoPiiPredicates(args, table, metadata, piiTags) {
|
|
653
|
+
const hasPredicate = args.where !== undefined || args.orderBy !== undefined;
|
|
654
|
+
if (tagsUnreadable(piiTags) && hasPredicate) {
|
|
655
|
+
throw jsonRpcError(-32602, `PII tags could not be read from ${piiTags.path} (${piiTags.reason}), so explain_query cannot prove this ` +
|
|
656
|
+
`query does not filter or sort on a PII column, and row estimates on such a column are an extraction ` +
|
|
657
|
+
`oracle. Re-run \`turbine generate\`, or call explain_query without where/orderBy.`);
|
|
658
|
+
}
|
|
659
|
+
const assertWithinDepth = (depth) => {
|
|
660
|
+
if (depth <= PII_GUARD_MAX_DEPTH)
|
|
661
|
+
return;
|
|
662
|
+
throw jsonRpcError(-32602, `Query is nested more than ${PII_GUARD_MAX_DEPTH} levels deep, past the point where the PII guard can ` +
|
|
663
|
+
`prove it does not filter or sort on a tagged column, so it is refused. Flatten the query.`);
|
|
664
|
+
};
|
|
665
|
+
const refuse = (owner, column, why) => {
|
|
666
|
+
throw jsonRpcError(-32602, `Column "${column}" on "${owner.name}" ${why}, so it cannot be used in a where or orderBy here: ` +
|
|
667
|
+
`EXPLAIN reports the planner's row estimate, and the estimate for a predicate on a hidden value ` +
|
|
668
|
+
`reveals that value one character at a time. Filter on a visible column instead.`);
|
|
669
|
+
};
|
|
670
|
+
/**
|
|
671
|
+
* Why this column may not appear in a predicate, or null when it may.
|
|
672
|
+
*
|
|
673
|
+
* The two reasons are the two `sample_rows` already refuses to FETCH
|
|
674
|
+
* (`classifyHiddenColumns`), and they are deliberately the same set: a column
|
|
675
|
+
* whose bytes are too sensitive to sample is too sensitive to binary-search
|
|
676
|
+
* out of the planner. A column absent from the table is not judged here, the
|
|
677
|
+
* builder rejects it by name a moment later.
|
|
678
|
+
*/
|
|
679
|
+
const hiddenReason = (owner, column) => {
|
|
680
|
+
if (owner.columns.some((col) => col.name === column && col.pii === true))
|
|
681
|
+
return 'is PII-tagged';
|
|
682
|
+
if (SECRET_NAME_PATTERN.test(column))
|
|
683
|
+
return 'has a secret-looking name';
|
|
684
|
+
return null;
|
|
685
|
+
};
|
|
686
|
+
const visitClause = (node, owner, depth) => {
|
|
687
|
+
assertWithinDepth(depth);
|
|
688
|
+
if (!owner || node === null || typeof node !== 'object')
|
|
689
|
+
return;
|
|
690
|
+
// `orderBy` accepts an array of single-key objects, and so does a `NOT`
|
|
691
|
+
// list. Element order carries no nesting, so the depth is unchanged.
|
|
692
|
+
if (Array.isArray(node)) {
|
|
693
|
+
for (const item of node)
|
|
694
|
+
visitClause(item, owner, depth);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
for (const [key, value] of Object.entries(node)) {
|
|
698
|
+
if (key === 'AND' || key === 'OR' || key === 'NOT') {
|
|
699
|
+
visitClause(value, owner, depth + 1);
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
const relation = Object.hasOwn(owner.relations, key) ? owner.relations[key] : undefined;
|
|
703
|
+
if (relation) {
|
|
704
|
+
visitRelationValue(value, metadata.tables[relation.to], depth + 1);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
// A predicate may name a column by its camelCase field OR by its real
|
|
708
|
+
// column name; both compile to the same SQL, so both have to be checked.
|
|
709
|
+
const column = Object.hasOwn(owner.columnMap, key) ? owner.columnMap[key] : key;
|
|
710
|
+
const why = hiddenReason(owner, column);
|
|
711
|
+
if (why)
|
|
712
|
+
refuse(owner, column, why);
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
/**
|
|
716
|
+
* A relation predicate arrives either bare (`{ user: { email: {...} } }`) or
|
|
717
|
+
* wrapped in a cardinality operator (`{ user: { is: { email: {...} } } }`),
|
|
718
|
+
* and BOTH resolve against the relation's target. Walking the wrapper as if
|
|
719
|
+
* `is` were a column of the target would skip the inner clause entirely, so
|
|
720
|
+
* descend into every wrapper member AND into the node itself.
|
|
721
|
+
*/
|
|
722
|
+
const visitRelationValue = (value, target, depth) => {
|
|
723
|
+
assertWithinDepth(depth);
|
|
724
|
+
if (!target || value === null || typeof value !== 'object')
|
|
725
|
+
return;
|
|
726
|
+
const node = value;
|
|
727
|
+
for (const wrapper of RELATION_FILTER_WRAPPERS) {
|
|
728
|
+
if (Object.hasOwn(node, wrapper))
|
|
729
|
+
visitClause(node[wrapper], target, depth + 1);
|
|
730
|
+
}
|
|
731
|
+
visitClause(node, target, depth);
|
|
732
|
+
};
|
|
733
|
+
visitClause(args.where, table, 0);
|
|
734
|
+
visitClause(args.orderBy, table, 0);
|
|
735
|
+
}
|
|
379
736
|
/**
|
|
380
737
|
* Extract the allowed findMany subset for explain_query (no `with` / raw SQL).
|
|
381
738
|
* Returns a plain object cast at the buildFindMany call site, same pattern as Studio.
|
|
@@ -411,33 +768,75 @@ function parseExplainFindManyArgs(args) {
|
|
|
411
768
|
}
|
|
412
769
|
return findManyArgs;
|
|
413
770
|
}
|
|
771
|
+
/**
|
|
772
|
+
* Decide which of a table's columns must not leave the process, and why.
|
|
773
|
+
*
|
|
774
|
+
* Three independent reasons, all reported so nothing is hidden silently:
|
|
775
|
+
* `pii` (a code-first tag), `secret-name` (the name-only denylist above), and
|
|
776
|
+
* `tags-unreadable` (a generated metadata file exists and did not parse, so no
|
|
777
|
+
* column can be shown to be untagged and EVERY column is hidden). That last one
|
|
778
|
+
* is the fail-closed branch: the old code returned `redactedColumns: []` in
|
|
779
|
+
* exactly that situation, which reads identically to "this table has no PII".
|
|
780
|
+
*/
|
|
781
|
+
function classifyHiddenColumns(table, piiTags) {
|
|
782
|
+
const hidden = new Set();
|
|
783
|
+
const reasons = {};
|
|
784
|
+
const unreadable = tagsUnreadable(piiTags);
|
|
785
|
+
for (const col of table.columns) {
|
|
786
|
+
const reason = unreadable
|
|
787
|
+
? 'tags-unreadable'
|
|
788
|
+
: col.pii === true
|
|
789
|
+
? 'pii'
|
|
790
|
+
: SECRET_NAME_PATTERN.test(col.name)
|
|
791
|
+
? 'secret-name'
|
|
792
|
+
: null;
|
|
793
|
+
if (reason) {
|
|
794
|
+
hidden.add(col.name);
|
|
795
|
+
reasons[col.name] = reason;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return { hidden, reasons };
|
|
799
|
+
}
|
|
414
800
|
async function sampleRows(ctx, tableName, limit) {
|
|
415
801
|
return withReadOnly(ctx, async (client) => {
|
|
416
|
-
const metadata = await loadSchemaMetadata(client, ctx.options);
|
|
802
|
+
const { metadata, piiTags } = await loadSchemaMetadata(client, ctx.options);
|
|
417
803
|
const table = requireTable(metadata, tableName);
|
|
418
804
|
const qualifiedTable = `${(0, index_js_1.quoteIdent)(ctx.options.schema)}.${(0, index_js_1.quoteIdent)(table.name)}`;
|
|
419
|
-
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
805
|
+
// Sample rows go straight into an LLM context, so hidden values are never
|
|
806
|
+
// FETCHED, not merely masked after the fact. `SELECT *` used to pull every
|
|
807
|
+
// column into this process and mask on the way out, which meant one missed
|
|
808
|
+
// branch anywhere downstream (an error path echoing the row, a future
|
|
809
|
+
// serializer) leaked the real bytes. Projecting at the SQL level is the same
|
|
810
|
+
// stance `writeReturningColumns` takes for write returns.
|
|
811
|
+
const { hidden, reasons } = classifyHiddenColumns(table, piiTags);
|
|
812
|
+
const visible = table.columns.filter((col) => !hidden.has(col.name));
|
|
813
|
+
// Every column hidden: still report the row count, without selecting data.
|
|
814
|
+
const selectList = visible.length > 0 ? visible.map((col) => (0, index_js_1.quoteIdent)(col.name)).join(', ') : '1 AS "_"';
|
|
815
|
+
const result = await client.query(`SELECT ${selectList} FROM ${qualifiedTable} LIMIT $1`, [limit]);
|
|
816
|
+
// Rebuild each row in the table's own column order, so a hidden column is
|
|
817
|
+
// visibly present-and-withheld rather than absent (an absent key reads like
|
|
818
|
+
// "no such column" to the agent, which is a different and wrong claim).
|
|
819
|
+
const rows = result.rows.map((row) => {
|
|
820
|
+
const out = {};
|
|
821
|
+
for (const col of table.columns) {
|
|
822
|
+
out[col.name] = hidden.has(col.name) ? REDACTED : row[col.name];
|
|
823
|
+
}
|
|
824
|
+
return out;
|
|
825
|
+
});
|
|
423
826
|
return {
|
|
424
827
|
table: table.name,
|
|
425
828
|
limit,
|
|
426
|
-
redactedColumns: [...
|
|
427
|
-
|
|
428
|
-
|
|
829
|
+
redactedColumns: [...hidden],
|
|
830
|
+
redactionReasons: reasons,
|
|
831
|
+
// Explicit so `redactedColumns: []` can never be read as "checked, and
|
|
832
|
+
// this table holds no PII" when the truth is "nothing was ever checked".
|
|
833
|
+
piiTagSource: piiTags,
|
|
834
|
+
columns: table.columns.map((col) => ({ name: col.name, pgType: col.pgType, redacted: hidden.has(col.name) })),
|
|
835
|
+
rows,
|
|
429
836
|
rowCount: result.rowCount ?? result.rows.length,
|
|
430
837
|
};
|
|
431
838
|
});
|
|
432
839
|
}
|
|
433
|
-
/** Replace PII-tagged cells with a fixed marker (never the value, never null). */
|
|
434
|
-
function redactRow(row, piiColumns) {
|
|
435
|
-
const out = {};
|
|
436
|
-
for (const [key, value] of Object.entries(row)) {
|
|
437
|
-
out[key] = piiColumns.has(key) ? '•• redacted ••' : value;
|
|
438
|
-
}
|
|
439
|
-
return out;
|
|
440
|
-
}
|
|
441
840
|
async function withReadOnly(ctx, fn) {
|
|
442
841
|
const client = await ctx.pool.connect();
|
|
443
842
|
try {
|
|
@@ -618,12 +1017,26 @@ async function loadSchemaMetadata(client, options) {
|
|
|
618
1017
|
const metadata = { tables, enums };
|
|
619
1018
|
// Code-first PII tags, layered onto the live catalog. Without this the
|
|
620
1019
|
// redaction below has nothing to act on (introspection never infers a tag).
|
|
1020
|
+
//
|
|
1021
|
+
// The OUTCOME is returned, not discarded. The three no-tag outcomes are not
|
|
1022
|
+
// interchangeable: "the user never generated metadata" is a normal state,
|
|
1023
|
+
// while "a metadata file is sitting right there and did not parse" means the
|
|
1024
|
+
// user believes tags are in force while nothing is being hidden. Callers fail
|
|
1025
|
+
// closed on the latter (`tagsUnreadable`).
|
|
1026
|
+
let piiTags = { state: 'not-configured' };
|
|
621
1027
|
if (options.metadataDir) {
|
|
622
1028
|
const source = (0, pii_tags_js_1.loadPiiTags)(options.metadataDir);
|
|
623
|
-
if (source)
|
|
624
|
-
|
|
1029
|
+
if (!source) {
|
|
1030
|
+
piiTags = { state: 'no-metadata-file', dir: options.metadataDir };
|
|
1031
|
+
}
|
|
1032
|
+
else if (!source.scan.ok) {
|
|
1033
|
+
piiTags = { state: 'tags-unreadable', path: source.path, reason: source.scan.reason ?? 'unrecognized shape' };
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
piiTags = { state: 'ok', path: source.path, taggedColumns: (0, pii_tags_js_1.applyPiiTags)(metadata, source.tags) };
|
|
1037
|
+
}
|
|
625
1038
|
}
|
|
626
|
-
return metadata;
|
|
1039
|
+
return { metadata, piiTags };
|
|
627
1040
|
}
|
|
628
1041
|
/**
|
|
629
1042
|
* Group raw FK rows into constraint-level entries and delegate relation
|
|
@@ -714,8 +1127,14 @@ function isJsonRpcRequest(value) {
|
|
|
714
1127
|
function isObject(value) {
|
|
715
1128
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
716
1129
|
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Error text for a JSON-RPC payload. Redacted because a connection failure from
|
|
1132
|
+
* pg quotes the connection string back verbatim, and every byte returned here
|
|
1133
|
+
* lands in an LLM context the operator does not control. The rest of the CLI
|
|
1134
|
+
* already runs its printed errors through `redactUrl`; this path did not.
|
|
1135
|
+
*/
|
|
717
1136
|
function errorMessage(err) {
|
|
718
|
-
return err instanceof Error ? err.message : String(err);
|
|
1137
|
+
return (0, ui_js_1.redactUrl)(err instanceof Error ? err.message : String(err));
|
|
719
1138
|
}
|
|
720
1139
|
function jsonRpcError(code, message, data) {
|
|
721
1140
|
const err = new Error(message);
|