turbine-orm 0.48.0 → 0.49.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 (65) hide show
  1. package/README.md +58 -39
  2. package/dist/cjs/cli/destructive.js +233 -18
  3. package/dist/cjs/cli/index.js +56 -12
  4. package/dist/cjs/cli/mcp.js +23 -2
  5. package/dist/cjs/cli/migrate.js +28 -1
  6. package/dist/cjs/cli/pii-tags.js +111 -0
  7. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  8. package/dist/cjs/cli/studio.js +158 -0
  9. package/dist/cjs/cli/ui.js +8 -3
  10. package/dist/cjs/client.js +21 -1
  11. package/dist/cjs/dialect.js +2 -0
  12. package/dist/cjs/index-advisor.js +0 -0
  13. package/dist/cjs/index-stats.js +118 -6
  14. package/dist/cjs/mssql.js +5 -0
  15. package/dist/cjs/mysql.js +5 -0
  16. package/dist/cjs/nested-write.js +248 -18
  17. package/dist/cjs/observe.js +21 -15
  18. package/dist/cjs/powdb.js +3 -0
  19. package/dist/cjs/powql.js +13 -0
  20. package/dist/cjs/prisma-compat.js +9 -0
  21. package/dist/cjs/query/aggregates.js +41 -1
  22. package/dist/cjs/query/batched-loader.js +70 -6
  23. package/dist/cjs/query/builder.js +3 -3
  24. package/dist/cjs/query/relations.js +12 -2
  25. package/dist/cjs/query/where.js +36 -1
  26. package/dist/cjs/sqlite.js +5 -0
  27. package/dist/cli/destructive.d.ts +9 -3
  28. package/dist/cli/destructive.js +233 -18
  29. package/dist/cli/index.js +57 -13
  30. package/dist/cli/mcp.d.ts +7 -0
  31. package/dist/cli/mcp.js +23 -2
  32. package/dist/cli/migrate.d.ts +2 -1
  33. package/dist/cli/migrate.js +28 -1
  34. package/dist/cli/pii-tags.d.ts +53 -0
  35. package/dist/cli/pii-tags.js +106 -0
  36. package/dist/cli/studio-ui.generated.js +1 -1
  37. package/dist/cli/studio.d.ts +42 -0
  38. package/dist/cli/studio.js +157 -0
  39. package/dist/cli/ui.js +8 -3
  40. package/dist/client.js +21 -1
  41. package/dist/dialect.d.ts +19 -0
  42. package/dist/dialect.js +2 -0
  43. package/dist/index-advisor.d.ts +7 -0
  44. package/dist/index-advisor.js +0 -0
  45. package/dist/index-stats.d.ts +52 -1
  46. package/dist/index-stats.js +117 -5
  47. package/dist/mssql.js +5 -0
  48. package/dist/mysql.js +5 -0
  49. package/dist/nested-write.js +249 -19
  50. package/dist/observe.d.ts +0 -1
  51. package/dist/observe.js +21 -15
  52. package/dist/powdb.js +3 -0
  53. package/dist/powql.js +13 -0
  54. package/dist/prisma-compat.js +9 -0
  55. package/dist/query/aggregates.d.ts +18 -0
  56. package/dist/query/aggregates.js +40 -1
  57. package/dist/query/batched-loader.d.ts +29 -1
  58. package/dist/query/batched-loader.js +69 -6
  59. package/dist/query/builder.js +4 -4
  60. package/dist/query/relations.js +12 -2
  61. package/dist/query/types.d.ts +16 -0
  62. package/dist/query/where.d.ts +18 -1
  63. package/dist/query/where.js +34 -1
  64. package/dist/sqlite.js +5 -0
  65. package/package.json +3 -2
@@ -45,6 +45,7 @@ exports.PII_REDACTED = void 0;
45
45
  exports.startStudio = startStudio;
46
46
  exports.handleRequest = handleRequest;
47
47
  exports.apiDemoMode = apiDemoMode;
48
+ exports.relationLinksForTable = relationLinksForTable;
48
49
  exports.apiTableRows = apiTableRows;
49
50
  exports.resolveColumnName = resolveColumnName;
50
51
  exports.isTextishType = isTextishType;
@@ -61,8 +62,10 @@ const node_http_1 = require("node:http");
61
62
  const node_os_1 = require("node:os");
62
63
  const node_path_1 = require("node:path");
63
64
  const pg_1 = __importDefault(require("pg"));
65
+ const errors_js_1 = require("../errors.js");
64
66
  const introspect_js_1 = require("../introspect.js");
65
67
  const index_js_1 = require("../query/index.js");
68
+ const pii_tags_js_1 = require("./pii-tags.js");
66
69
  const studio_demo_js_1 = require("./studio-demo.js");
67
70
  const studio_ui_generated_js_1 = require("./studio-ui.generated.js");
68
71
  // ---------------------------------------------------------------------------
@@ -83,6 +86,7 @@ async function startStudio(options) {
83
86
  let metadata;
84
87
  let dialect;
85
88
  let statementTimeout;
89
+ let piiTags = null;
86
90
  if (demo) {
87
91
  // Seeded in-memory SQLite store: no DATABASE_URL, no network. Each launch
88
92
  // starts pristine and nothing is ever persisted.
@@ -116,6 +120,14 @@ async function startStudio(options) {
116
120
  include: options.include,
117
121
  exclude: options.exclude,
118
122
  });
123
+ // PII tags are code-first only, so a live-introspected schema carries none.
124
+ // Layer them on from the generated metadata when there is one, and record
125
+ // what happened so the CLI can be explicit about it at startup.
126
+ if (options.metadataDir) {
127
+ const source = (0, pii_tags_js_1.loadPiiTags)(options.metadataDir);
128
+ if (source)
129
+ piiTags = { path: source.path, applied: (0, pii_tags_js_1.applyPiiTags)(metadata, source.tags) };
130
+ }
119
131
  statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
120
132
  // Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
121
133
  // syntax error). `set_config(name, value, is_local=true)` is the
@@ -165,6 +177,7 @@ async function startStudio(options) {
165
177
  return {
166
178
  authToken,
167
179
  url,
180
+ piiTags,
168
181
  dispose: async () => {
169
182
  await new Promise((resolve) => server.close(() => resolve()));
170
183
  await pool.end();
@@ -386,6 +399,8 @@ async function apiSchema(res, ctx) {
386
399
  foreignKey: rel.foreignKey,
387
400
  referenceKey: rel.referenceKey,
388
401
  })),
402
+ // Click-through navigation targets, derived from relation metadata only.
403
+ ...relationLinksForTable(tbl, ctx.metadata, ctx.showPii === true),
389
404
  }));
390
405
  // Row counts (cheap enough to fetch inline).
391
406
  const counts = new Map();
@@ -422,6 +437,91 @@ async function apiSchema(res, ctx) {
422
437
  demo: ctx.demo === true,
423
438
  });
424
439
  }
440
+ /** Own-property lookup: a key like `constructor` must not resolve off the prototype. */
441
+ function ownLookup(map, key) {
442
+ return Object.hasOwn(map, key) ? map[key] : undefined;
443
+ }
444
+ /** True when `columnName` on `table` is PII-tagged and currently redacted. */
445
+ function isRedactedColumn(table, columnName, showPii) {
446
+ if (showPii)
447
+ return false;
448
+ const col = table.columns.find((c) => c.name === columnName);
449
+ return col?.pii === true;
450
+ }
451
+ /**
452
+ * Resolve a relation key to a single column name on `table`, or `null`. Composite
453
+ * (array) keys return null: a composite reference has no single cell to click, so
454
+ * those relations simply produce no link rather than a half-working one.
455
+ */
456
+ function singleRelationColumn(table, key) {
457
+ if (typeof key !== 'string')
458
+ return null;
459
+ return resolveColumnName(table, key);
460
+ }
461
+ function relationLinksForTable(table, metadata, showPii) {
462
+ const foreignKeys = [];
463
+ const referencedBy = [];
464
+ const seenFkColumns = new Set();
465
+ for (const [name, rel] of Object.entries(table.relations)) {
466
+ const target = metadata.tables[rel.to];
467
+ if (!target)
468
+ continue;
469
+ if (rel.type === 'belongsTo') {
470
+ // The FK lives on this table; the reference key on the target.
471
+ const column = singleRelationColumn(table, rel.foreignKey);
472
+ const targetColumn = singleRelationColumn(target, rel.referenceKey);
473
+ if (!column || !targetColumn)
474
+ continue;
475
+ if (seenFkColumns.has(column))
476
+ continue;
477
+ if (isRedactedColumn(table, column, showPii) || isRedactedColumn(target, targetColumn, showPii))
478
+ continue;
479
+ seenFkColumns.add(column);
480
+ foreignKeys.push({ column, relation: name, targetTable: target.name, targetColumn });
481
+ continue;
482
+ }
483
+ if (rel.type === 'hasMany' || rel.type === 'hasOne') {
484
+ // The FK lives on the child table; the reference key on this one.
485
+ const column = singleRelationColumn(table, rel.referenceKey);
486
+ const targetColumn = singleRelationColumn(target, rel.foreignKey);
487
+ if (!column || !targetColumn)
488
+ continue;
489
+ if (isRedactedColumn(table, column, showPii) || isRedactedColumn(target, targetColumn, showPii))
490
+ continue;
491
+ referencedBy.push({ column, relation: name, targetTable: target.name, targetColumn });
492
+ }
493
+ // manyToMany deliberately produces no link: navigating it means traversing a
494
+ // junction table, which the single-column filter path cannot express.
495
+ }
496
+ // Inbound references declared only on the OTHER side. A `defineSchema` author
497
+ // routinely writes `comments.user -> users` without also declaring
498
+ // `users.comments`, and scanning only this table's own hasMany/hasOne made
499
+ // those children unreachable even though the child grid visibly renders the
500
+ // FK. Scan every other table's belongsTo relations that point here.
501
+ const seenInbound = new Set(referencedBy.map((r) => `${r.targetTable}.${r.targetColumn}`));
502
+ for (const other of Object.values(metadata.tables)) {
503
+ if (other.name === table.name)
504
+ continue;
505
+ for (const [name, rel] of Object.entries(other.relations)) {
506
+ if (rel.type !== 'belongsTo' || rel.to !== table.name)
507
+ continue;
508
+ const column = singleRelationColumn(table, rel.referenceKey);
509
+ const targetColumn = singleRelationColumn(other, rel.foreignKey);
510
+ if (!column || !targetColumn)
511
+ continue;
512
+ if (seenInbound.has(`${other.name}.${targetColumn}`))
513
+ continue;
514
+ if (isRedactedColumn(table, column, showPii) || isRedactedColumn(other, targetColumn, showPii))
515
+ continue;
516
+ seenInbound.add(`${other.name}.${targetColumn}`);
517
+ // Named for the direction the user travels: from this row, to the rows of
518
+ // `other` that reference it. `relation` is the child's own relation name,
519
+ // which is what the child table calls this link.
520
+ referencedBy.push({ column, relation: name, targetTable: other.name, targetColumn });
521
+ }
522
+ }
523
+ return { foreignKeys, referencedBy };
524
+ }
425
525
  // ---------------------------------------------------------------------------
426
526
  // API: /api/tables/:name?limit=&offset=&orderBy=&dir=
427
527
  // ---------------------------------------------------------------------------
@@ -663,6 +763,63 @@ function parseTableFilters(raw, table, redactedPii) {
663
763
  // ---------------------------------------------------------------------------
664
764
  // API: /api/builder — Turbine ORM findMany spec runner
665
765
  // ---------------------------------------------------------------------------
766
+ /**
767
+ * Refuse a builder query that FILTERS or SORTS on a redacted PII column.
768
+ *
769
+ * Redacting the cells is not enough on its own: `where: { email: { startsWith:
770
+ * 'a' } }` answers a question about the hidden value, and so does an `isNull`,
771
+ * and so does an `orderBy`. The Data tab already refuses all three
772
+ * (`parseTableFilters`); the builder route accepted them, which mattered the
773
+ * moment PII tags actually started reaching Studio's metadata.
774
+ *
775
+ * Walks the whole args tree: top-level `where` / `orderBy`, boolean
776
+ * combinators, and each `with` level against that relation's target table.
777
+ * `select` is NOT refused: it returns values, and those values are redacted on
778
+ * the way out.
779
+ */
780
+ function assertNoPiiPredicates(args, tableName, metadata, showPii) {
781
+ if (showPii)
782
+ return;
783
+ const visitClause = (node, table, depth) => {
784
+ if (!table || depth > 10 || node === null || typeof node !== 'object')
785
+ return;
786
+ for (const [key, value] of Object.entries(node)) {
787
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
788
+ for (const item of Array.isArray(value) ? value : [value])
789
+ visitClause(item, table, depth + 1);
790
+ continue;
791
+ }
792
+ const relation = Object.hasOwn(table.relations, key) ? table.relations[key] : undefined;
793
+ if (relation) {
794
+ // some / none / every / is / isNot wrappers all resolve against the target.
795
+ visitClause(value, metadata.tables[relation.to], depth + 1);
796
+ continue;
797
+ }
798
+ const column = ownLookup(table.columnMap, key) ?? key;
799
+ if (isRedactedColumn(table, column, showPii)) {
800
+ throw new errors_js_1.ValidationError(`[turbine] Column "${column}" on "${table.name}" is PII-tagged and redacted, so it cannot be used ` +
801
+ `in a where or orderBy: filtering or sorting on a hidden value reveals it. ` +
802
+ `Restart Studio with --show-pii to query it.`);
803
+ }
804
+ }
805
+ };
806
+ const visitLevel = (level, table, depth) => {
807
+ if (!table || depth > 10)
808
+ return;
809
+ visitClause(level.where, table, depth);
810
+ visitClause(level.orderBy, table, depth);
811
+ const withClause = level.with;
812
+ if (!withClause || typeof withClause !== 'object')
813
+ return;
814
+ for (const [relName, spec] of Object.entries(withClause)) {
815
+ const relation = Object.hasOwn(table.relations, relName) ? table.relations[relName] : undefined;
816
+ if (!relation || spec === true || spec === null || typeof spec !== 'object')
817
+ continue;
818
+ visitLevel(spec, metadata.tables[relation.to], depth + 1);
819
+ }
820
+ };
821
+ visitLevel(args, metadata.tables[tableName], 0);
822
+ }
666
823
  async function apiBuilder(req, res, ctx) {
667
824
  const body = await readJsonBody(req);
668
825
  const tableName = typeof body?.table === 'string' ? body.table : '';
@@ -681,6 +838,7 @@ async function apiBuilder(req, res, ctx) {
681
838
  // when unset.
682
839
  dialect: ctx.dialect,
683
840
  });
841
+ assertNoPiiPredicates(args, tableName, ctx.metadata, ctx.showPii === true);
684
842
  deferred = qi.buildFindMany(args);
685
843
  }
686
844
  catch (err) {
@@ -230,9 +230,14 @@ function stripAnsi(s) {
230
230
  // ---------------------------------------------------------------------------
231
231
  function redactUrl(url) {
232
232
  return (url
233
- // Userinfo credentials: `:secret@` in any authority (global: a string may
234
- // carry more than one URL, e.g. a primary + replica connection pair).
235
- .replace(/:([^@/:]+)@/g, ':***@')
233
+ // Userinfo credentials. Anchored on `<scheme>://<user>:` and consuming up
234
+ // to the LAST `@` before the next `/` (or end of authority), because a
235
+ // password may legally contain `:`, `/`, and even `@` in percent-decoded
236
+ // form. The previous `:([^@/:]+)@` could not span any of those, so
237
+ // `postgres://u:pa/ss@host/db` came through completely unredacted and
238
+ // `postgres://u:a@b@host/db` leaked the tail. Global: one string may
239
+ // carry several URLs (a primary + replica pair).
240
+ .replace(/(\w+:\/\/[^/@\s]*?:)[^\s]*?@(?=[^@\s]*(?:[/?#]|$))/g, '$1***@')
236
241
  // Query-string password params: `password=`, `sslpassword=`, and similar,
237
242
  // case-insensitive. Value runs up to the next `&`, `#`, or end of string.
238
243
  .replace(/([?&][^=&#]*password)=([^&#]*)/gi, '$1=***'));
@@ -314,9 +314,29 @@ class TurbineClient {
314
314
  // constructor with an opaque "Cannot read properties of undefined
315
315
  // (reading 'tables')". Fail fast with an actionable message instead.
316
316
  if (!schema || typeof schema !== 'object' || !schema.tables) {
317
+ // A `defineSchema()` result is the most common wrong shape here: it is a
318
+ // SchemaDef (`{ tables: { users: { columns: { id: ... } } } }`-ish builder
319
+ // output), not runtime SchemaMetadata, so name the conversion rather than
320
+ // just the requirement.
321
+ const looksLikeSchemaDef = schema !== null && typeof schema === 'object' && Object.hasOwn(schema, 'name') === false;
317
322
  throw new errors_js_1.ValidationError('[turbine] TurbineClient requires schema metadata as its second argument. ' +
318
323
  'Run `npx turbine generate` and use the generated client (`turbine()` from your output dir), ' +
319
- 'or pass the generated `schemaMetadata` object: new TurbineClient(config, schemaMetadata).');
324
+ 'or pass the generated `schemaMetadata` object: new TurbineClient(config, schemaMetadata).' +
325
+ (looksLikeSchemaDef
326
+ ? ' If you have a `defineSchema()` result, convert it first with `schemaDefToMetadata(def)`.'
327
+ : ''));
328
+ }
329
+ // A wrong-SHAPED schema (a `defineSchema()` result, whose tables carry no
330
+ // `columns` array) used to survive this check and die later as
331
+ // `TypeError: this.tableMeta.columns is not iterable`, several frames from
332
+ // the cause. Validate one table's shape here, where the fix is obvious.
333
+ for (const [name, meta] of Object.entries(schema.tables)) {
334
+ if (!meta || typeof meta !== 'object' || !Array.isArray(meta.columns)) {
335
+ throw new errors_js_1.ValidationError(`[turbine] Table "${name}" in the schema passed to TurbineClient has no \`columns\` array, so this is ` +
336
+ 'not runtime SchemaMetadata. A `defineSchema()` result is a SchemaDef: convert it with ' +
337
+ '`schemaDefToMetadata(def)`, or use the metadata emitted by `npx turbine generate`.');
338
+ }
339
+ break;
320
340
  }
321
341
  /**
322
342
  * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
@@ -55,6 +55,8 @@ exports.postgresDialect = {
55
55
  nullJsonLiteral: 'NULL',
56
56
  aggSupportsInlineOrderBy: true,
57
57
  supportsVector: true,
58
+ supportsFullTextSearch: true,
59
+ supportsArrayColumns: true,
58
60
  supportsListenNotify: true,
59
61
  supportsRLS: true,
60
62
  supportsAdvisoryLock: true,
Binary file
@@ -60,7 +60,7 @@ var __importStar = (this && this.__importStar) || (function () {
60
60
  };
61
61
  })();
62
62
  Object.defineProperty(exports, "__esModule", { value: true });
63
- exports.STATS_THRESHOLDS = void 0;
63
+ exports.EXPRESSION_COLUMN = exports.STATS_THRESHOLDS = void 0;
64
64
  exports.emptyStatsSnapshot = emptyStatsSnapshot;
65
65
  exports.formatBytes = formatBytes;
66
66
  exports.scoreMissingIndex = scoreMissingIndex;
@@ -131,6 +131,14 @@ exports.STATS_THRESHOLDS = {
131
131
  */
132
132
  heatMinQueriesPerMin: 1,
133
133
  };
134
+ /**
135
+ * Placeholder for an index column that is an EXPRESSION, not a plain column
136
+ * (`pg_index.indkey` stores 0 for those, and no `pg_attribute` row has attnum 0).
137
+ * It keeps expression POSITIONS in `IndexStat.columns` instead of silently
138
+ * collapsing `(tenant_id, lower(email))` to `['tenant_id']`, which would make a
139
+ * functional index look like a droppable prefix of an unrelated plain index.
140
+ */
141
+ exports.EXPRESSION_COLUMN = '(expression)';
134
142
  /** Build an empty (fully unavailable) snapshot - the honest "no stats" baseline. */
135
143
  function emptyStatsSnapshot(notices = []) {
136
144
  return {
@@ -335,6 +343,30 @@ function findInvalidIndexes(snapshot) {
335
343
  function isConstraintBacking(idx) {
336
344
  return idx.isPrimary || idx.isUnique || idx.isExclusion === true || idx.isReplicaIdent;
337
345
  }
346
+ /** The structured counterpart of {@link describeIndexShape}. */
347
+ function indexShape(idx) {
348
+ const kinds = [];
349
+ if (idx.hasExpressions === true || idx.columns.includes(exports.EXPRESSION_COLUMN))
350
+ kinds.push('expression');
351
+ if (idx.predicate != null)
352
+ kinds.push('partial');
353
+ if (idx.accessMethod !== undefined && idx.accessMethod !== 'btree')
354
+ kinds.push('non-btree');
355
+ return { kinds, accessMethod: idx.accessMethod ?? null, definition: idx.indexDef ?? null };
356
+ }
357
+ /** Describe an index's non-plain-btree properties, or null when it is plain. */
358
+ function describeIndexShape(idx) {
359
+ const parts = [];
360
+ if (idx.hasExpressions === true || idx.columns.includes(exports.EXPRESSION_COLUMN))
361
+ parts.push('expression index');
362
+ if (idx.predicate != null)
363
+ parts.push(`partial index (WHERE ${idx.predicate})`);
364
+ if (idx.accessMethod !== undefined && idx.accessMethod !== 'btree')
365
+ parts.push(`${idx.accessMethod} index`);
366
+ if (parts.length === 0)
367
+ return null;
368
+ return `${parts.join('; ')}${idx.indexDef ? `: ${idx.indexDef}` : ''}`;
369
+ }
338
370
  /**
339
371
  * Indexes never (or barely) scanned since the last stats reset. Report-only:
340
372
  * counters reset on a crash/reset and REPLICA READS NEVER FEED PRIMARY COUNTERS,
@@ -344,9 +376,11 @@ function isConstraintBacking(idx) {
344
376
  */
345
377
  function findUnusedIndexes(snapshot, options = {}) {
346
378
  const minScans = options.minScans ?? exports.STATS_THRESHOLDS.unusedMinScans;
379
+ const probes = options.relationProbes ?? [];
347
380
  return snapshot.indexes
348
381
  .filter((idx) => idx.isValid && !isConstraintBacking(idx))
349
382
  .filter((idx) => idx.idxScan !== undefined && idx.idxScan < minScans)
383
+ .filter((idx) => !servesRelationProbe(idx, probes))
350
384
  .map((idx) => ({
351
385
  table: idx.table,
352
386
  indexName: idx.indexName,
@@ -354,6 +388,8 @@ function findUnusedIndexes(snapshot, options = {}) {
354
388
  idxScan: idx.idxScan ?? 0,
355
389
  sizeBytes: idx.sizeBytes ?? null,
356
390
  dropSql: (0, index_advisor_js_1.buildDropIndexSql)(idx.indexName, { concurrently: true }),
391
+ caveat: describeIndexShape(idx),
392
+ shape: indexShape(idx),
357
393
  }))
358
394
  .sort((a, b) => (b.sizeBytes ?? 0) - (a.sizeBytes ?? 0) || a.indexName.localeCompare(b.indexName));
359
395
  }
@@ -363,6 +399,52 @@ function isLeadingPrefix(prefix, columns) {
363
399
  return false;
364
400
  return prefix.every((c, i) => columns[i] === c);
365
401
  }
402
+ /** Whether two column lists are identical, in order. */
403
+ function sameColumns(a, b) {
404
+ return a.length === b.length && a.every((c, i) => b[i] === c);
405
+ }
406
+ /**
407
+ * Whether this index answers a relation probe Turbine actually issues: the
408
+ * probe's columns are the index's leading columns (an exact match, or a wider
409
+ * index whose prefix serves the probe). Such an index is never handed a DROP,
410
+ * because the missing-index half of the same report demands it.
411
+ */
412
+ function servesRelationProbe(idx, probes) {
413
+ if (probes.length === 0)
414
+ return false;
415
+ if (idx.accessMethod !== undefined && idx.accessMethod !== 'btree')
416
+ return false;
417
+ if (idx.predicate != null)
418
+ return false;
419
+ return probes.some((p) => p.table === idx.table && (sameColumns(p.columns, idx.columns) || isLeadingPrefix(p.columns, idx.columns)));
420
+ }
421
+ /**
422
+ * Whether prefix coverage is even a meaningful question for this pair. Only a
423
+ * plain btree has leading-prefix semantics, and only two indexes of the SAME
424
+ * shape are interchangeable:
425
+ *
426
+ * - access method must be btree on BOTH (a GIN index on a column answers
427
+ * queries a btree cannot, and vice versa);
428
+ * - neither may contain an expression column (`lower(email)` is a different
429
+ * lookup from `email`, and an unresolved expression slot makes the column
430
+ * list an unreliable basis for a drop verdict);
431
+ * - the partial predicates must be IDENTICAL (both absent, or the same text).
432
+ * A full index does technically answer a partial index's lookups, but it is
433
+ * not the same object and the partial one exists to be small.
434
+ *
435
+ * Anything unknown (an access method or expression flag the collector could not
436
+ * read) answers "not comparable": doctor stays silent rather than suggesting a
437
+ * drop it cannot justify.
438
+ */
439
+ function isCoverageComparable(narrow, wider) {
440
+ if (narrow.accessMethod !== 'btree' || wider.accessMethod !== 'btree')
441
+ return false;
442
+ if (narrow.hasExpressions !== false || wider.hasExpressions !== false)
443
+ return false;
444
+ if (narrow.columns.includes(exports.EXPRESSION_COLUMN) || wider.columns.includes(exports.EXPRESSION_COLUMN))
445
+ return false;
446
+ return (narrow.predicate ?? null) === (wider.predicate ?? null);
447
+ }
366
448
  /**
367
449
  * Non-unique indexes whose column list is a leading prefix of a WIDER index on
368
450
  * the same table. A btree serves any leading-prefix lookup, so the narrow index
@@ -371,6 +453,9 @@ function isLeadingPrefix(prefix, columns) {
371
453
  * Uniqueness compatibility: only a NON-unique index is ever reported. A unique
372
454
  * or primary-key prefix is load-bearing (it enforces a constraint), so it is
373
455
  * never called redundant even when a wider index shares its leading columns.
456
+ *
457
+ * Shape compatibility: see {@link isCoverageComparable}. A functional, partial,
458
+ * or non-btree index is never reported as covered by a plain btree.
374
459
  */
375
460
  function findRedundantIndexes(snapshot) {
376
461
  const byTable = new Map();
@@ -393,7 +478,18 @@ function findRedundantIndexes(snapshot) {
393
478
  continue;
394
479
  if (narrow.columns.length === 0)
395
480
  continue;
396
- const wider = list.find((w) => w.indexName !== narrow.indexName && isLeadingPrefix(narrow.columns, w.columns));
481
+ // Prefer a genuinely wider index; fall back to an exact duplicate, which
482
+ // is the most obvious index problem there is and which a strict
483
+ // leading-prefix test (prefix.length < columns.length) can never see. For
484
+ // a duplicate pair only ONE side is reported: the later name, so the
485
+ // report never tells you to drop both copies.
486
+ const wider = list.find((w) => w.indexName !== narrow.indexName &&
487
+ isCoverageComparable(narrow, w) &&
488
+ isLeadingPrefix(narrow.columns, w.columns)) ??
489
+ list.find((w) => w.indexName < narrow.indexName &&
490
+ !isConstraintBacking(w) &&
491
+ isCoverageComparable(narrow, w) &&
492
+ sameColumns(narrow.columns, w.columns));
397
493
  if (!wider)
398
494
  continue;
399
495
  out.push({
@@ -419,6 +515,7 @@ function findRedundantIndexes(snapshot) {
419
515
  */
420
516
  function auditDoctorIndexes(snapshot, doctorNames, options = {}) {
421
517
  const minScans = options.minScans ?? exports.STATS_THRESHOLDS.unusedMinScans;
518
+ const probes = options.relationProbes ?? [];
422
519
  const out = [];
423
520
  for (const idx of snapshot.indexes) {
424
521
  if (!idx.isValid || isConstraintBacking(idx))
@@ -428,14 +525,16 @@ function auditDoctorIndexes(snapshot, doctorNames, options = {}) {
428
525
  continue;
429
526
  if (idx.idxScan === undefined || idx.idxScan >= minScans)
430
527
  continue;
528
+ const stillProbed = servesRelationProbe(idx, probes);
431
529
  out.push({
432
530
  table: idx.table,
433
531
  indexName: idx.indexName,
434
532
  columns: idx.columns,
435
533
  idxScan: idx.idxScan,
436
534
  sizeBytes: idx.sizeBytes ?? null,
437
- dropSql: (0, index_advisor_js_1.buildDropIndexSql)(idx.indexName, { concurrently: true }),
535
+ dropSql: stillProbed ? null : (0, index_advisor_js_1.buildDropIndexSql)(idx.indexName, { concurrently: true }),
438
536
  ambiguous: candidates.length > 1,
537
+ stillProbed,
439
538
  });
440
539
  }
441
540
  return out.sort((a, b) => a.indexName.localeCompare(b.indexName));
@@ -538,19 +637,28 @@ async function collectStatsSnapshot(options) {
538
637
  }
539
638
  }
540
639
  // --- invalid + all indexes (whole schema, for invalid detection) -------
541
- const indexRows = await run('pg_index', `SELECT c.relname AS table_name,
640
+ const indexRows = await run('pg_index',
641
+ // The column list LEFT JOINs pg_attribute so an EXPRESSION slot (indkey = 0,
642
+ // which no pg_attribute row matches) survives as a marker instead of being
643
+ // dropped by an inner join, which silently shortened functional indexes.
644
+ `SELECT c.relname AS table_name,
542
645
  ic.relname AS index_name,
543
646
  i.indisvalid, i.indisunique, i.indisprimary, i.indisreplident,
544
647
  EXISTS (SELECT 1 FROM pg_constraint con
545
648
  WHERE con.conindid = i.indexrelid AND con.contype = 'x') AS is_exclusion,
546
649
  s.idx_scan::text AS idx_scan,
547
650
  pg_relation_size(i.indexrelid)::text AS index_size,
548
- (SELECT array_agg(a.attname::text ORDER BY k.ord)
651
+ am.amname AS access_method,
652
+ (i.indexprs IS NOT NULL) AS has_expressions,
653
+ pg_get_expr(i.indpred, i.indrelid) AS predicate,
654
+ pg_get_indexdef(i.indexrelid) AS index_def,
655
+ (SELECT array_agg(coalesce(a.attname::text, '${exports.EXPRESSION_COLUMN}') ORDER BY k.ord)
549
656
  FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
550
- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum) AS columns
657
+ LEFT JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum) AS columns
551
658
  FROM pg_index i
552
659
  JOIN pg_class c ON c.oid = i.indrelid
553
660
  JOIN pg_class ic ON ic.oid = i.indexrelid
661
+ JOIN pg_am am ON am.oid = ic.relam
554
662
  JOIN pg_namespace n ON n.oid = c.relnamespace
555
663
  LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.indexrelid
556
664
  WHERE n.nspname = $1`, [options.schema]);
@@ -560,6 +668,10 @@ async function collectStatsSnapshot(options) {
560
668
  table: row.table_name,
561
669
  indexName: row.index_name,
562
670
  columns: row.columns ?? [],
671
+ accessMethod: row.access_method,
672
+ hasExpressions: row.has_expressions,
673
+ predicate: row.predicate,
674
+ indexDef: row.index_def ?? undefined,
563
675
  idxScan: row.idx_scan == null ? undefined : Number(row.idx_scan),
564
676
  isValid: row.indisvalid,
565
677
  isUnique: row.indisunique,
package/dist/cjs/mssql.js CHANGED
@@ -493,6 +493,11 @@ exports.mssqlDialect = {
493
493
  supportsReturning: false,
494
494
  supportsILike: false,
495
495
  supportsVector: false,
496
+ // SQL Server full-text is `CONTAINS`/`FREETEXT` over a full-text catalog: a
497
+ // different surface with different semantics, not the emitted tsvector form.
498
+ supportsFullTextSearch: false,
499
+ // No array column type (a JSON column is not an array column).
500
+ supportsArrayColumns: false,
496
501
  supportsListenNotify: false,
497
502
  supportsRLS: false,
498
503
  // SQL Server has OUTER APPLY, not FROM-clause LATERAL: the lateral pick plan
package/dist/cjs/mysql.js CHANGED
@@ -377,6 +377,11 @@ exports.mysqlDialect = {
377
377
  supportsReturning: false,
378
378
  supportsILike: false,
379
379
  supportsVector: false,
380
+ // MySQL full-text is `MATCH(col) AGAINST(...)` over a FULLTEXT index: a
381
+ // different surface with different semantics, not the emitted tsvector form.
382
+ supportsFullTextSearch: false,
383
+ // No array column type (a JSON column is not an array column).
384
+ supportsArrayColumns: false,
380
385
  supportsListenNotify: false,
381
386
  supportsRLS: false,
382
387
  // MySQL 8.0.14+ supports LATERAL, but the opt-in lateral pick plan stays