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