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
@@ -68,10 +68,26 @@ export interface StudioOptions {
68
68
  * three-mode switcher (`/api/demo/mode`). Default `false`.
69
69
  */
70
70
  demo?: boolean;
71
+ /**
72
+ * Directory holding generated Turbine metadata (`turbine generate`'s `out`).
73
+ * PII tags are code-first declarations that introspection never sets, so
74
+ * without this Studio's redaction has nothing to redact against a real
75
+ * database. Read as text; nothing from it is executed. See `pii-tags.ts`.
76
+ */
77
+ metadataDir?: string;
71
78
  }
72
79
  export interface StudioHandle {
73
80
  /** Shut down the server + pool cleanly. */
74
81
  dispose: () => Promise<void>;
82
+ /**
83
+ * Where PII tags came from, so the CLI can say so at startup. `null` when no
84
+ * generated metadata was found: redaction is then inert and the user needs to
85
+ * know that rather than assume protection.
86
+ */
87
+ piiTags: {
88
+ path: string;
89
+ applied: number;
90
+ } | null;
75
91
  /** Random per-process session token the UI sends via cookie. */
76
92
  authToken: string;
77
93
  /** Full URL including `?token=...` — safe to print for the user. */
@@ -138,6 +154,32 @@ export interface StudioContext {
138
154
  export declare function startStudio(options: StudioOptions): Promise<StudioHandle>;
139
155
  export declare function handleRequest(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
140
156
  export declare function apiDemoMode(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
157
+ /** One clickable reference from a column on this table to a row in another. */
158
+ export interface ForeignKeyLink {
159
+ /** Column on THIS table whose cell becomes the link. */
160
+ column: string;
161
+ /** Relation the link was derived from. */
162
+ relation: string;
163
+ /** Table the reference points at. */
164
+ targetTable: string;
165
+ /** Column on the target table the value is matched against. */
166
+ targetColumn: string;
167
+ }
168
+ /** One reverse jump: rows in a child table that reference this table. */
169
+ export interface ReferencedByLink {
170
+ /** Column on THIS table whose value the child column matches (usually the PK). */
171
+ column: string;
172
+ /** Relation the link was derived from. */
173
+ relation: string;
174
+ /** Child table holding the referencing rows. */
175
+ targetTable: string;
176
+ /** Referencing column on the child table. */
177
+ targetColumn: string;
178
+ }
179
+ export declare function relationLinksForTable(table: TableMetadata, metadata: SchemaMetadata, showPii: boolean): {
180
+ foreignKeys: ForeignKeyLink[];
181
+ referencedBy: ReferencedByLink[];
182
+ };
141
183
  export declare function apiTableRows(res: ServerResponse, ctx: StudioContext, rawTableName: string, params: URLSearchParams): Promise<void>;
142
184
  export declare function resolveColumnName(table: TableMetadata, nameOrField: string): string | null;
143
185
  export declare function isTextishType(pgType: string): boolean;
@@ -43,8 +43,10 @@ import { createServer } from 'node:http';
43
43
  import { platform } from 'node:os';
44
44
  import { dirname, resolve as pathResolve } from 'node:path';
45
45
  import pg from 'pg';
46
+ import { ValidationError } from '../errors.js';
46
47
  import { introspect } from '../introspect.js';
47
48
  import { QueryInterface, quoteIdent } from '../query/index.js';
49
+ import { applyPiiTags, loadPiiTags } from './pii-tags.js';
48
50
  import { createDemoContext } from './studio-demo.js';
49
51
  import { STUDIO_HTML } from './studio-ui.generated.js';
50
52
  // ---------------------------------------------------------------------------
@@ -65,6 +67,7 @@ export async function startStudio(options) {
65
67
  let metadata;
66
68
  let dialect;
67
69
  let statementTimeout;
70
+ let piiTags = null;
68
71
  if (demo) {
69
72
  // Seeded in-memory SQLite store: no DATABASE_URL, no network. Each launch
70
73
  // starts pristine and nothing is ever persisted.
@@ -98,6 +101,14 @@ export async function startStudio(options) {
98
101
  include: options.include,
99
102
  exclude: options.exclude,
100
103
  });
104
+ // PII tags are code-first only, so a live-introspected schema carries none.
105
+ // Layer them on from the generated metadata when there is one, and record
106
+ // what happened so the CLI can be explicit about it at startup.
107
+ if (options.metadataDir) {
108
+ const source = loadPiiTags(options.metadataDir);
109
+ if (source)
110
+ piiTags = { path: source.path, applied: applyPiiTags(metadata, source.tags) };
111
+ }
101
112
  statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
102
113
  // Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
103
114
  // syntax error). `set_config(name, value, is_local=true)` is the
@@ -147,6 +158,7 @@ export async function startStudio(options) {
147
158
  return {
148
159
  authToken,
149
160
  url,
161
+ piiTags,
150
162
  dispose: async () => {
151
163
  await new Promise((resolve) => server.close(() => resolve()));
152
164
  await pool.end();
@@ -368,6 +380,8 @@ async function apiSchema(res, ctx) {
368
380
  foreignKey: rel.foreignKey,
369
381
  referenceKey: rel.referenceKey,
370
382
  })),
383
+ // Click-through navigation targets, derived from relation metadata only.
384
+ ...relationLinksForTable(tbl, ctx.metadata, ctx.showPii === true),
371
385
  }));
372
386
  // Row counts (cheap enough to fetch inline).
373
387
  const counts = new Map();
@@ -404,6 +418,91 @@ async function apiSchema(res, ctx) {
404
418
  demo: ctx.demo === true,
405
419
  });
406
420
  }
421
+ /** Own-property lookup: a key like `constructor` must not resolve off the prototype. */
422
+ function ownLookup(map, key) {
423
+ return Object.hasOwn(map, key) ? map[key] : undefined;
424
+ }
425
+ /** True when `columnName` on `table` is PII-tagged and currently redacted. */
426
+ function isRedactedColumn(table, columnName, showPii) {
427
+ if (showPii)
428
+ return false;
429
+ const col = table.columns.find((c) => c.name === columnName);
430
+ return col?.pii === true;
431
+ }
432
+ /**
433
+ * Resolve a relation key to a single column name on `table`, or `null`. Composite
434
+ * (array) keys return null: a composite reference has no single cell to click, so
435
+ * those relations simply produce no link rather than a half-working one.
436
+ */
437
+ function singleRelationColumn(table, key) {
438
+ if (typeof key !== 'string')
439
+ return null;
440
+ return resolveColumnName(table, key);
441
+ }
442
+ export function relationLinksForTable(table, metadata, showPii) {
443
+ const foreignKeys = [];
444
+ const referencedBy = [];
445
+ const seenFkColumns = new Set();
446
+ for (const [name, rel] of Object.entries(table.relations)) {
447
+ const target = metadata.tables[rel.to];
448
+ if (!target)
449
+ continue;
450
+ if (rel.type === 'belongsTo') {
451
+ // The FK lives on this table; the reference key on the target.
452
+ const column = singleRelationColumn(table, rel.foreignKey);
453
+ const targetColumn = singleRelationColumn(target, rel.referenceKey);
454
+ if (!column || !targetColumn)
455
+ continue;
456
+ if (seenFkColumns.has(column))
457
+ continue;
458
+ if (isRedactedColumn(table, column, showPii) || isRedactedColumn(target, targetColumn, showPii))
459
+ continue;
460
+ seenFkColumns.add(column);
461
+ foreignKeys.push({ column, relation: name, targetTable: target.name, targetColumn });
462
+ continue;
463
+ }
464
+ if (rel.type === 'hasMany' || rel.type === 'hasOne') {
465
+ // The FK lives on the child table; the reference key on this one.
466
+ const column = singleRelationColumn(table, rel.referenceKey);
467
+ const targetColumn = singleRelationColumn(target, rel.foreignKey);
468
+ if (!column || !targetColumn)
469
+ continue;
470
+ if (isRedactedColumn(table, column, showPii) || isRedactedColumn(target, targetColumn, showPii))
471
+ continue;
472
+ referencedBy.push({ column, relation: name, targetTable: target.name, targetColumn });
473
+ }
474
+ // manyToMany deliberately produces no link: navigating it means traversing a
475
+ // junction table, which the single-column filter path cannot express.
476
+ }
477
+ // Inbound references declared only on the OTHER side. A `defineSchema` author
478
+ // routinely writes `comments.user -> users` without also declaring
479
+ // `users.comments`, and scanning only this table's own hasMany/hasOne made
480
+ // those children unreachable even though the child grid visibly renders the
481
+ // FK. Scan every other table's belongsTo relations that point here.
482
+ const seenInbound = new Set(referencedBy.map((r) => `${r.targetTable}.${r.targetColumn}`));
483
+ for (const other of Object.values(metadata.tables)) {
484
+ if (other.name === table.name)
485
+ continue;
486
+ for (const [name, rel] of Object.entries(other.relations)) {
487
+ if (rel.type !== 'belongsTo' || rel.to !== table.name)
488
+ continue;
489
+ const column = singleRelationColumn(table, rel.referenceKey);
490
+ const targetColumn = singleRelationColumn(other, rel.foreignKey);
491
+ if (!column || !targetColumn)
492
+ continue;
493
+ if (seenInbound.has(`${other.name}.${targetColumn}`))
494
+ continue;
495
+ if (isRedactedColumn(table, column, showPii) || isRedactedColumn(other, targetColumn, showPii))
496
+ continue;
497
+ seenInbound.add(`${other.name}.${targetColumn}`);
498
+ // Named for the direction the user travels: from this row, to the rows of
499
+ // `other` that reference it. `relation` is the child's own relation name,
500
+ // which is what the child table calls this link.
501
+ referencedBy.push({ column, relation: name, targetTable: other.name, targetColumn });
502
+ }
503
+ }
504
+ return { foreignKeys, referencedBy };
505
+ }
407
506
  // ---------------------------------------------------------------------------
408
507
  // API: /api/tables/:name?limit=&offset=&orderBy=&dir=
409
508
  // ---------------------------------------------------------------------------
@@ -645,6 +744,63 @@ function parseTableFilters(raw, table, redactedPii) {
645
744
  // ---------------------------------------------------------------------------
646
745
  // API: /api/builder — Turbine ORM findMany spec runner
647
746
  // ---------------------------------------------------------------------------
747
+ /**
748
+ * Refuse a builder query that FILTERS or SORTS on a redacted PII column.
749
+ *
750
+ * Redacting the cells is not enough on its own: `where: { email: { startsWith:
751
+ * 'a' } }` answers a question about the hidden value, and so does an `isNull`,
752
+ * and so does an `orderBy`. The Data tab already refuses all three
753
+ * (`parseTableFilters`); the builder route accepted them, which mattered the
754
+ * moment PII tags actually started reaching Studio's metadata.
755
+ *
756
+ * Walks the whole args tree: top-level `where` / `orderBy`, boolean
757
+ * combinators, and each `with` level against that relation's target table.
758
+ * `select` is NOT refused: it returns values, and those values are redacted on
759
+ * the way out.
760
+ */
761
+ function assertNoPiiPredicates(args, tableName, metadata, showPii) {
762
+ if (showPii)
763
+ return;
764
+ const visitClause = (node, table, depth) => {
765
+ if (!table || depth > 10 || node === null || typeof node !== 'object')
766
+ return;
767
+ for (const [key, value] of Object.entries(node)) {
768
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
769
+ for (const item of Array.isArray(value) ? value : [value])
770
+ visitClause(item, table, depth + 1);
771
+ continue;
772
+ }
773
+ const relation = Object.hasOwn(table.relations, key) ? table.relations[key] : undefined;
774
+ if (relation) {
775
+ // some / none / every / is / isNot wrappers all resolve against the target.
776
+ visitClause(value, metadata.tables[relation.to], depth + 1);
777
+ continue;
778
+ }
779
+ const column = ownLookup(table.columnMap, key) ?? key;
780
+ if (isRedactedColumn(table, column, showPii)) {
781
+ throw new ValidationError(`[turbine] Column "${column}" on "${table.name}" is PII-tagged and redacted, so it cannot be used ` +
782
+ `in a where or orderBy: filtering or sorting on a hidden value reveals it. ` +
783
+ `Restart Studio with --show-pii to query it.`);
784
+ }
785
+ }
786
+ };
787
+ const visitLevel = (level, table, depth) => {
788
+ if (!table || depth > 10)
789
+ return;
790
+ visitClause(level.where, table, depth);
791
+ visitClause(level.orderBy, table, depth);
792
+ const withClause = level.with;
793
+ if (!withClause || typeof withClause !== 'object')
794
+ return;
795
+ for (const [relName, spec] of Object.entries(withClause)) {
796
+ const relation = Object.hasOwn(table.relations, relName) ? table.relations[relName] : undefined;
797
+ if (!relation || spec === true || spec === null || typeof spec !== 'object')
798
+ continue;
799
+ visitLevel(spec, metadata.tables[relation.to], depth + 1);
800
+ }
801
+ };
802
+ visitLevel(args, metadata.tables[tableName], 0);
803
+ }
648
804
  export async function apiBuilder(req, res, ctx) {
649
805
  const body = await readJsonBody(req);
650
806
  const tableName = typeof body?.table === 'string' ? body.table : '';
@@ -663,6 +819,7 @@ export async function apiBuilder(req, res, ctx) {
663
819
  // when unset.
664
820
  dialect: ctx.dialect,
665
821
  });
822
+ assertNoPiiPredicates(args, tableName, ctx.metadata, ctx.showPii === true);
666
823
  deferred = qi.buildFindMany(args);
667
824
  }
668
825
  catch (err) {
package/dist/cli/ui.js CHANGED
@@ -212,9 +212,14 @@ export function stripAnsi(s) {
212
212
  // ---------------------------------------------------------------------------
213
213
  export function redactUrl(url) {
214
214
  return (url
215
- // Userinfo credentials: `:secret@` in any authority (global: a string may
216
- // carry more than one URL, e.g. a primary + replica connection pair).
217
- .replace(/:([^@/:]+)@/g, ':***@')
215
+ // Userinfo credentials. Anchored on `<scheme>://<user>:` and consuming up
216
+ // to the LAST `@` before the next `/` (or end of authority), because a
217
+ // password may legally contain `:`, `/`, and even `@` in percent-decoded
218
+ // form. The previous `:([^@/:]+)@` could not span any of those, so
219
+ // `postgres://u:pa/ss@host/db` came through completely unredacted and
220
+ // `postgres://u:a@b@host/db` leaked the tail. Global: one string may
221
+ // carry several URLs (a primary + replica pair).
222
+ .replace(/(\w+:\/\/[^/@\s]*?:)[^\s]*?@(?=[^@\s]*(?:[/?#]|$))/g, '$1***@')
218
223
  // Query-string password params: `password=`, `sslpassword=`, and similar,
219
224
  // case-insensitive. Value runs up to the next `&`, `#`, or end of string.
220
225
  .replace(/([?&][^=&#]*password)=([^&#]*)/gi, '$1=***'));
package/dist/client.js CHANGED
@@ -306,9 +306,29 @@ export class TurbineClient {
306
306
  // constructor with an opaque "Cannot read properties of undefined
307
307
  // (reading 'tables')". Fail fast with an actionable message instead.
308
308
  if (!schema || typeof schema !== 'object' || !schema.tables) {
309
+ // A `defineSchema()` result is the most common wrong shape here: it is a
310
+ // SchemaDef (`{ tables: { users: { columns: { id: ... } } } }`-ish builder
311
+ // output), not runtime SchemaMetadata, so name the conversion rather than
312
+ // just the requirement.
313
+ const looksLikeSchemaDef = schema !== null && typeof schema === 'object' && Object.hasOwn(schema, 'name') === false;
309
314
  throw new ValidationError('[turbine] TurbineClient requires schema metadata as its second argument. ' +
310
315
  'Run `npx turbine generate` and use the generated client (`turbine()` from your output dir), ' +
311
- 'or pass the generated `schemaMetadata` object: new TurbineClient(config, schemaMetadata).');
316
+ 'or pass the generated `schemaMetadata` object: new TurbineClient(config, schemaMetadata).' +
317
+ (looksLikeSchemaDef
318
+ ? ' If you have a `defineSchema()` result, convert it first with `schemaDefToMetadata(def)`.'
319
+ : ''));
320
+ }
321
+ // A wrong-SHAPED schema (a `defineSchema()` result, whose tables carry no
322
+ // `columns` array) used to survive this check and die later as
323
+ // `TypeError: this.tableMeta.columns is not iterable`, several frames from
324
+ // the cause. Validate one table's shape here, where the fix is obvious.
325
+ for (const [name, meta] of Object.entries(schema.tables)) {
326
+ if (!meta || typeof meta !== 'object' || !Array.isArray(meta.columns)) {
327
+ throw new ValidationError(`[turbine] Table "${name}" in the schema passed to TurbineClient has no \`columns\` array, so this is ` +
328
+ 'not runtime SchemaMetadata. A `defineSchema()` result is a SchemaDef: convert it with ' +
329
+ '`schemaDefToMetadata(def)`, or use the metadata emitted by `npx turbine generate`.');
330
+ }
331
+ break;
312
332
  }
313
333
  /**
314
334
  * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
package/dist/dialect.d.ts CHANGED
@@ -298,6 +298,25 @@ export interface Dialect {
298
298
  readonly supportsUpsertUpdateWhere?: boolean;
299
299
  /** Whether this dialect/engine supports pgvector distance ops (KNN / distance WHERE). */
300
300
  readonly supportsVector: boolean;
301
+ /**
302
+ * Whether this dialect/engine supports the PostgreSQL full-text `search`
303
+ * filter (`to_tsvector(...) @@ to_tsquery(...)`). Optional: absent is treated
304
+ * as `false`, so only dialects that set it true admit a `search` filter and
305
+ * every other engine throws {@link UnsupportedFeatureError} (E017) instead of
306
+ * emitting SQL its parser cannot read. MySQL `MATCH … AGAINST` and SQL Server
307
+ * `CONTAINS` are different surfaces with different index requirements, so they
308
+ * deliberately stay `false` rather than silently changing the semantics.
309
+ */
310
+ readonly supportsFullTextSearch?: boolean;
311
+ /**
312
+ * Whether this dialect/engine supports native array columns and the array
313
+ * filter operators built on them (`has` → `= ANY(col)`, `hasEvery` → `@>`,
314
+ * `hasSome` → `&&`, `isEmpty` → `cardinality(col)`). Optional: absent is
315
+ * treated as `false`, so SQLite / MySQL / SQL Server (which have no array
316
+ * column type; a JSON column is not the same thing) throw
317
+ * {@link UnsupportedFeatureError} (E017) instead of emitting `ANY(...)`.
318
+ */
319
+ readonly supportsArrayColumns?: boolean;
301
320
  /** Whether this dialect/engine supports LISTEN/NOTIFY realtime pub/sub. */
302
321
  readonly supportsListenNotify: boolean;
303
322
  /** Whether this dialect/engine supports row-level-security session GUCs (set_config). */
package/dist/dialect.js CHANGED
@@ -19,6 +19,8 @@ export const postgresDialect = {
19
19
  nullJsonLiteral: 'NULL',
20
20
  aggSupportsInlineOrderBy: true,
21
21
  supportsVector: true,
22
+ supportsFullTextSearch: true,
23
+ supportsArrayColumns: true,
22
24
  supportsListenNotify: true,
23
25
  supportsRLS: true,
24
26
  supportsAdvisoryLock: true,
@@ -98,6 +98,13 @@ export interface DoctorProbeColumns {
98
98
  * ambiguous rather than issuing a confident drop verdict.
99
99
  */
100
100
  export declare function collectDoctorProbeIndexNames(schema: SchemaMetadata): Map<string, DoctorProbeColumns[]>;
101
+ /**
102
+ * Every (table, column set) a relation probe touches, deduplicated. Consumed by
103
+ * the subtraction side of `doctor` so an index that still serves a live relation
104
+ * is never handed a DROP: the same run's top half demands that index, and
105
+ * recommending both in one report is how a tool loses a DBA's trust.
106
+ */
107
+ export declare function collectRelationProbeColumns(schema: SchemaMetadata): DoctorProbeColumns[];
101
108
  /**
102
109
  * Scan every relation in the schema and return the probes with no index support,
103
110
  * deduplicated by (table, column set) with all contributing relations attached.
Binary file
@@ -101,11 +101,28 @@ export interface TableStats {
101
101
  /** Count of indexes already on the table (pg_index). */
102
102
  existingIndexCount?: number;
103
103
  }
104
+ /**
105
+ * Placeholder for an index column that is an EXPRESSION, not a plain column
106
+ * (`pg_index.indkey` stores 0 for those, and no `pg_attribute` row has attnum 0).
107
+ * It keeps expression POSITIONS in `IndexStat.columns` instead of silently
108
+ * collapsing `(tenant_id, lower(email))` to `['tenant_id']`, which would make a
109
+ * functional index look like a droppable prefix of an unrelated plain index.
110
+ */
111
+ export declare const EXPRESSION_COLUMN = "(expression)";
104
112
  /** A single index's identity + validity, from pg_index / pg_stat_user_indexes. */
105
113
  export interface IndexStat {
106
114
  table: string;
107
115
  indexName: string;
116
+ /** Key + INCLUDE columns in order; an expression slot is {@link EXPRESSION_COLUMN}. */
108
117
  columns: string[];
118
+ /** pg_am.amname (btree / gin / gist / hash / brin / spgist). Absent when unread. */
119
+ accessMethod?: string;
120
+ /** True when the index has at least one expression column (pg_index.indexprs). */
121
+ hasExpressions?: boolean;
122
+ /** The partial-index predicate text (pg_index.indpred), or null when not partial. */
123
+ predicate?: string | null;
124
+ /** pg_get_indexdef, the authoritative definition (used for honest reporting). */
125
+ indexDef?: string;
109
126
  idxScan?: number;
110
127
  isValid: boolean;
111
128
  isUnique: boolean;
@@ -215,6 +232,21 @@ export interface UnusedIndex {
215
232
  sizeBytes: number | null;
216
233
  /** `DROP INDEX CONCURRENTLY IF EXISTS`: printed only, never written to a migration. */
217
234
  dropSql: string;
235
+ /**
236
+ * What is special about this index (functional / partial / non-btree), or null
237
+ * for a plain btree. `idx_scan = 0` is a fact either way, but the caveat says
238
+ * what is actually being dropped: a rebuild is not always a `CREATE INDEX (col)`.
239
+ */
240
+ caveat: string | null;
241
+ /**
242
+ * The same information as `caveat`, structured, so a `--json` consumer never
243
+ * has to regex prose. `kinds` is empty for a plain btree index.
244
+ */
245
+ shape: {
246
+ kinds: Array<'expression' | 'partial' | 'non-btree'>;
247
+ accessMethod: string | null;
248
+ definition: string | null;
249
+ };
218
250
  }
219
251
  /**
220
252
  * Indexes never (or barely) scanned since the last stats reset. Report-only:
@@ -225,6 +257,10 @@ export interface UnusedIndex {
225
257
  */
226
258
  export declare function findUnusedIndexes(snapshot: StatsSnapshot, options?: {
227
259
  minScans?: number;
260
+ relationProbes?: Array<{
261
+ table: string;
262
+ columns: string[];
263
+ }>;
228
264
  }): UnusedIndex[];
229
265
  /** A redundant index whose columns are a leading prefix of a wider index. */
230
266
  export interface RedundantIndex {
@@ -245,6 +281,9 @@ export interface RedundantIndex {
245
281
  * Uniqueness compatibility: only a NON-unique index is ever reported. A unique
246
282
  * or primary-key prefix is load-bearing (it enforces a constraint), so it is
247
283
  * never called redundant even when a wider index shares its leading columns.
284
+ *
285
+ * Shape compatibility: see {@link isCoverageComparable}. A functional, partial,
286
+ * or non-btree index is never reported as covered by a plain btree.
248
287
  */
249
288
  export declare function findRedundantIndexes(snapshot: StatsSnapshot): RedundantIndex[];
250
289
  /**
@@ -257,13 +296,21 @@ export interface DoctorIndexAudit {
257
296
  columns: string[];
258
297
  idxScan: number;
259
298
  sizeBytes: number | null;
260
- dropSql: string;
299
+ /** `null` when the index still serves a live relation probe (see stillProbed). */
300
+ dropSql: string | null;
261
301
  /**
262
302
  * True when the truncated (63-byte) name collides across DIFFERENT column sets
263
303
  * in the schema's probes, so which suggestion this index realizes is
264
304
  * ambiguous. Reported as ambiguous instead of a confident drop verdict.
265
305
  */
266
306
  ambiguous: boolean;
307
+ /**
308
+ * True when the schema still declares a relation whose probe this index
309
+ * serves. `dropSql` is null in that case: the index is unused only because
310
+ * this workload has not run those relation queries yet, and dropping it
311
+ * recreates the missing-index finding in the same report's top half.
312
+ */
313
+ stillProbed: boolean;
267
314
  }
268
315
  /**
269
316
  * The unused-index machinery scoped to doctor's OWN previously-suggested indexes:
@@ -278,6 +325,10 @@ export declare function auditDoctorIndexes(snapshot: StatsSnapshot, doctorNames:
278
325
  columns: string[];
279
326
  }>>, options?: {
280
327
  minScans?: number;
328
+ relationProbes?: Array<{
329
+ table: string;
330
+ columns: string[];
331
+ }>;
281
332
  }): DoctorIndexAudit[];
282
333
  /**
283
334
  * Whether the snapshot is trustworthy enough to render tier verdicts. Empty,