turbine-orm 0.60.1 → 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.
Files changed (84) hide show
  1. package/README.md +71 -27
  2. package/dist/cjs/cli/config.d.ts +40 -0
  3. package/dist/cjs/cli/config.js +74 -2
  4. package/dist/cjs/cli/index.d.ts +85 -1
  5. package/dist/cjs/cli/index.js +374 -24
  6. package/dist/cjs/cli/mcp.d.ts +8 -0
  7. package/dist/cjs/cli/mcp.js +448 -29
  8. package/dist/cjs/cli/pii-tags.d.ts +64 -9
  9. package/dist/cjs/cli/pii-tags.js +218 -39
  10. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  11. package/dist/cjs/cli/studio.d.ts +23 -0
  12. package/dist/cjs/cli/studio.js +126 -53
  13. package/dist/cjs/cli/ui.d.ts +15 -1
  14. package/dist/cjs/cli/ui.js +19 -5
  15. package/dist/cjs/client.js +248 -11
  16. package/dist/cjs/errors.d.ts +38 -1
  17. package/dist/cjs/errors.js +235 -24
  18. package/dist/cjs/index.d.ts +2 -2
  19. package/dist/cjs/index.js +7 -2
  20. package/dist/cjs/pipeline-submittable.js +26 -3
  21. package/dist/cjs/pipeline.js +15 -2
  22. package/dist/cjs/powql.d.ts +12 -0
  23. package/dist/cjs/powql.js +46 -21
  24. package/dist/cjs/prisma-compat.d.ts +15 -5
  25. package/dist/cjs/prisma-compat.js +273 -78
  26. package/dist/cjs/query/aggregates.d.ts +1 -1
  27. package/dist/cjs/query/aggregates.js +24 -10
  28. package/dist/cjs/query/batched-loader.d.ts +9 -4
  29. package/dist/cjs/query/batched-loader.js +4 -1
  30. package/dist/cjs/query/builder.d.ts +47 -0
  31. package/dist/cjs/query/builder.js +149 -21
  32. package/dist/cjs/query/index.d.ts +3 -1
  33. package/dist/cjs/query/index.js +7 -1
  34. package/dist/cjs/query/option-surface.d.ts +11 -0
  35. package/dist/cjs/query/option-surface.js +13 -0
  36. package/dist/cjs/query/relations.d.ts +8 -0
  37. package/dist/cjs/query/relations.js +21 -1
  38. package/dist/cjs/query/types.d.ts +152 -18
  39. package/dist/cjs/query/types.js +212 -1
  40. package/dist/cjs/query/where.d.ts +3 -3
  41. package/dist/cjs/query/where.js +8 -2
  42. package/dist/cjs/query/writes.js +10 -9
  43. package/dist/cli/config.d.ts +40 -0
  44. package/dist/cli/config.js +73 -2
  45. package/dist/cli/index.d.ts +85 -1
  46. package/dist/cli/index.js +373 -27
  47. package/dist/cli/mcp.d.ts +8 -0
  48. package/dist/cli/mcp.js +448 -29
  49. package/dist/cli/pii-tags.d.ts +64 -9
  50. package/dist/cli/pii-tags.js +217 -39
  51. package/dist/cli/studio-ui.generated.js +1 -1
  52. package/dist/cli/studio.d.ts +23 -0
  53. package/dist/cli/studio.js +125 -53
  54. package/dist/cli/ui.d.ts +15 -1
  55. package/dist/cli/ui.js +18 -4
  56. package/dist/client.js +250 -13
  57. package/dist/errors.d.ts +38 -1
  58. package/dist/errors.js +234 -23
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.js +5 -2
  61. package/dist/pipeline-submittable.js +26 -3
  62. package/dist/pipeline.js +15 -2
  63. package/dist/powql.d.ts +12 -0
  64. package/dist/powql.js +46 -21
  65. package/dist/prisma-compat.d.ts +15 -5
  66. package/dist/prisma-compat.js +274 -79
  67. package/dist/query/aggregates.d.ts +1 -1
  68. package/dist/query/aggregates.js +24 -10
  69. package/dist/query/batched-loader.d.ts +9 -4
  70. package/dist/query/batched-loader.js +4 -1
  71. package/dist/query/builder.d.ts +47 -0
  72. package/dist/query/builder.js +148 -21
  73. package/dist/query/index.d.ts +3 -1
  74. package/dist/query/index.js +2 -0
  75. package/dist/query/option-surface.d.ts +11 -0
  76. package/dist/query/option-surface.js +13 -0
  77. package/dist/query/relations.d.ts +8 -0
  78. package/dist/query/relations.js +21 -1
  79. package/dist/query/types.d.ts +152 -18
  80. package/dist/query/types.js +207 -2
  81. package/dist/query/where.d.ts +3 -3
  82. package/dist/query/where.js +8 -2
  83. package/dist/query/writes.js +10 -9
  84. package/package.json +13 -3
@@ -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
- const trackingExists = await client.query(`SELECT to_regclass($1)::text IS NOT NULL AS exists`, [TRACKING_TABLE]);
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
- const result = await client.query(`SELECT * FROM ${qualifiedTable} LIMIT $1`, [limit]);
420
- // Sample rows go straight into an LLM context, so PII-tagged values are
421
- // replaced before serialization, the same stance Studio's Data tab takes.
422
- const piiColumns = new Set(table.columns.filter((c) => c.pii).map((c) => c.name));
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: [...piiColumns],
427
- columns: result.fields.map((field) => ({ name: field.name, dataTypeID: field.dataTypeID })),
428
- rows: piiColumns.size === 0 ? result.rows : result.rows.map((row) => redactRow(row, piiColumns)),
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
- (0, pii_tags_js_1.applyPiiTags)(metadata, source.tags);
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);