@mosaic-code/pg-index-check 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -108,6 +108,31 @@ async function leadingIndexColumns(client, indexKeys) {
108
108
  );
109
109
  return new Map(result.rows.map((row) => [row.index_key, row.column_name]));
110
110
  }
111
+ async function tableLeadingColumns(client, tables) {
112
+ if (tables.length === 0) {
113
+ return /* @__PURE__ */ new Map();
114
+ }
115
+ const result = await client.query(
116
+ `
117
+ SELECT n.nspname || '.' || t.relname AS table_key, a.attname AS column_name
118
+ FROM pg_catalog.pg_index i
119
+ JOIN pg_catalog.pg_class t ON t.oid = i.indrelid
120
+ JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid
121
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
122
+ JOIN pg_catalog.pg_am am ON am.oid = c.relam
123
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
124
+ WHERE am.amname = 'btree' AND i.indisvalid AND n.nspname || '.' || t.relname = ANY($1)
125
+ `,
126
+ [tables]
127
+ );
128
+ const byTable = /* @__PURE__ */ new Map();
129
+ for (const row of result.rows) {
130
+ const columns = byTable.get(row.table_key) ?? /* @__PURE__ */ new Set();
131
+ columns.add(row.column_name);
132
+ byTable.set(row.table_key, columns);
133
+ }
134
+ return byTable;
135
+ }
111
136
 
112
137
  // src/ignore.ts
113
138
  function applyIgnoreRules(findings, rules) {
@@ -170,10 +195,13 @@ function indexesWithConditions(plan) {
170
195
  }
171
196
  function evaluatePlan(plan, context) {
172
197
  return flattenPlan(plan).flatMap((node) => {
173
- const finding = checkSeqScanFilter(node) ?? checkFullIndexScan(node) ?? checkTrailingIndexColumn(node, context);
198
+ const finding = checkSeqScanFilter(node) ?? checkFullIndexScan(node, context) ?? checkTrailingIndexColumn(node, context);
174
199
  return finding ? [finding] : [];
175
200
  });
176
201
  }
202
+ function planTables(plan) {
203
+ return [...new Set(flattenPlan(plan).map(tableName).filter((name) => name !== void 0))];
204
+ }
177
205
  function checkSeqScanFilter(node) {
178
206
  const filter = node.Filter;
179
207
  if (node["Node Type"] !== "Seq Scan" || !filter || isNegationOnly(filter)) {
@@ -186,17 +214,22 @@ function checkSeqScanFilter(node) {
186
214
  hint: indexHint(node, filter)
187
215
  };
188
216
  }
189
- function checkFullIndexScan(node) {
217
+ function checkFullIndexScan(node, context) {
190
218
  const filter = node.Filter;
191
219
  if (!INDEX_SCAN_TYPES.has(node["Node Type"]) || !filter || node["Index Cond"]) {
192
220
  return void 0;
193
221
  }
194
- if (equalityColumn(filter) === void 0) {
222
+ const column = equalityColumn(filter);
223
+ if (column === void 0) {
224
+ return void 0;
225
+ }
226
+ const table = tableName(node);
227
+ if (table !== void 0 && context.leadingColumnsByTable.get(table)?.has(column)) {
195
228
  return void 0;
196
229
  }
197
230
  return {
198
231
  kind: FindingKind.FULL_INDEX_SCAN,
199
- table: tableName(node),
232
+ table,
200
233
  condition: filter,
201
234
  indexName: node["Index Name"],
202
235
  hint: indexHint(node, filter)
@@ -340,7 +373,8 @@ async function checkStatement(client, statement, majorVersion) {
340
373
  async function evaluate(client, plan, majorVersion) {
341
374
  const checkTrailingIndexColumns = majorVersion < SKIP_SCAN_MAJOR_VERSION;
342
375
  const leadingColumns = checkTrailingIndexColumns ? await leadingIndexColumns(client, indexesWithConditions(plan)) : /* @__PURE__ */ new Map();
343
- return evaluatePlan(plan, { leadingColumns, checkTrailingIndexColumns });
376
+ const leadingColumnsByTable = await tableLeadingColumns(client, planTables(plan));
377
+ return evaluatePlan(plan, { leadingColumns, leadingColumnsByTable, checkTrailingIndexColumns });
344
378
  }
345
379
  async function withClient(connectionString, work) {
346
380
  const client = new pg__default.default.Client({ connectionString });
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/statement-text.ts","../src/capture.ts","../src/explain.ts","../src/ignore.ts","../src/types.ts","../src/plan-rules.ts","../src/report.ts","../src/check.ts"],"names":["pg"],"mappings":"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","file":"index.cjs","sourcesContent":["const CHECKED_KEYWORDS = new Set(['SELECT', 'WITH', 'UPDATE', 'DELETE']);\n\n/**\n * Returns the first keyword of a statement (upper-cased), skipping leading\n * whitespace, comments and opening parentheses\n */\nexport function firstKeyword(statement: string): string | undefined {\n let index = 0;\n\n while (index < statement.length) {\n if (/\\s/.test(statement[index]) || statement[index] === '(') {\n index += 1;\n } else if (statement.startsWith('/*', index)) {\n const commentEnd = statement.indexOf('*/', index + 2);\n if (commentEnd === -1) {\n return undefined;\n }\n index = commentEnd + 2;\n } else if (statement.startsWith('--', index)) {\n const lineEnd = statement.indexOf('\\n', index + 2);\n if (lineEnd === -1) {\n return undefined;\n }\n index = lineEnd + 1;\n } else {\n const wordMatch = /^[A-Za-z_]+/.exec(statement.slice(index));\n return wordMatch?.[0].toUpperCase();\n }\n }\n\n return undefined;\n}\n\n/**\n * Whether a captured statement is one whose plan should be checked: a query or\n * data-modifying statement that reads rows, and not a catalog/monitoring query\n */\nexport function isCheckableStatement(statement: string): boolean {\n const keyword = firstKeyword(statement);\n if (keyword === undefined || !CHECKED_KEYWORDS.has(keyword)) {\n return false;\n }\n\n return !/pg_stat_statements|pg_catalog\\.|information_schema\\./i.test(statement);\n}\n\n/**\n * Extracts the `path:line` location from a pg-source-comments comment,\n * e.g. `SELECT /* src/api/users.ts:42 *\\/ ...` gives `src/api/users.ts:42`\n */\nexport function parseSourceLocation(statement: string): string | undefined {\n const match = /\\/\\*\\s*(\\S+:\\d+(?::\\d+)?)(?:\\s+in\\s+\\S+)?\\s*\\*\\//.exec(statement);\n return match?.[1];\n}\n","import type { Client } from 'pg';\nimport { isCheckableStatement } from './statement-text.js';\n\nconst SETUP_HELP =\n 'Add pg_stat_statements to shared_preload_libraries (e.g. `postgres -c shared_preload_libraries=pg_stat_statements`) ' +\n 'and call resetCapture() before the test suite runs.';\n\n/**\n * Installs pg_stat_statements in the current database (if needed) and clears it,\n * so only statements run after this point are checked\n */\nexport async function resetCapturedStatements(client: Client): Promise<void> {\n await client.query('CREATE EXTENSION IF NOT EXISTS pg_stat_statements');\n await client.query('SELECT pg_stat_statements_reset()');\n}\n\n/**\n * Reads the distinct statements recorded for the current database since the last reset\n */\nexport async function readCapturedStatements(client: Client): Promise<string[]> {\n const installed = await client.query(\n \"SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'\"\n );\n if (installed.rowCount === 0) {\n throw new Error(`pg-index-check: the pg_stat_statements extension is not installed. ${SETUP_HELP}`);\n }\n\n const captured = await client.query<{ query: string }>(`\n SELECT s.query\n FROM pg_stat_statements s\n JOIN pg_catalog.pg_database d ON d.oid = s.dbid\n WHERE d.datname = current_database() AND s.toplevel\n `);\n\n const statements = captured.rows.map((row) => row.query).filter(isCheckableStatement);\n if (statements.length === 0) {\n throw new Error(`pg-index-check: no statements were captured in this database. ${SETUP_HELP}`);\n }\n\n return statements;\n}\n","import type { Client } from 'pg';\nimport type { PlanNode } from './types.js';\n\nexport type ExplainOutcome = { plan: PlanNode } | { error: string };\n\n/**\n * Makes the planner treat sequential scans as a last resort for this session,\n * so any sequential scan left in a plan is one no index can replace\n */\nexport async function preparePlanningSession(client: Client): Promise<void> {\n await client.query('SET enable_seqscan = off');\n}\n\n/**\n * Plans a statement with placeholders ($1, ...) left in place. Sent without bound\n * values (simple query protocol), which EXPLAIN (GENERIC_PLAN) requires.\n */\nexport async function explainStatement(client: Client, statement: string): Promise<ExplainOutcome> {\n try {\n const result = await client.query<{ 'QUERY PLAN': [{ Plan: PlanNode }] }>(\n `EXPLAIN (GENERIC_PLAN, VERBOSE, FORMAT JSON) ${statement}`\n );\n return { plan: result.rows[0]['QUERY PLAN'][0].Plan };\n } catch (error) {\n // Recorded as an UNPLANNABLE finding, which fails by default\n return { error: error instanceof Error ? error.message : String(error) };\n }\n}\n\n/**\n * Server major version, e.g. 16\n */\nexport async function serverMajorVersion(client: Client): Promise<number> {\n const result = await client.query<{ server_version_num: string }>('SHOW server_version_num');\n return Math.floor(Number(result.rows[0].server_version_num) / 10000);\n}\n\n/**\n * Leading column of each btree index, keyed by `schema.index`. Expression indexes\n * have no leading column and are omitted.\n */\nexport async function leadingIndexColumns(client: Client, indexKeys: string[]): Promise<Map<string, string>> {\n if (indexKeys.length === 0) {\n return new Map();\n }\n\n const result = await client.query<{ index_key: string; column_name: string }>(\n `\n SELECT n.nspname || '.' || c.relname AS index_key, a.attname AS column_name\n FROM pg_catalog.pg_index i\n JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid\n JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace\n JOIN pg_catalog.pg_am am ON am.oid = c.relam\n JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]\n WHERE am.amname = 'btree' AND n.nspname || '.' || c.relname = ANY($1)\n `,\n [indexKeys]\n );\n\n return new Map(result.rows.map((row) => [row.index_key, row.column_name]));\n}\n","import type { Finding, IgnoreRule } from './types.js';\n\n/**\n * Removes findings matched by an ignore rule and reports rules that matched nothing\n */\nexport function applyIgnoreRules(\n findings: Finding[],\n rules: IgnoreRule[]\n): { kept: Finding[]; staleIgnoreRules: IgnoreRule[] } {\n for (const rule of rules) {\n validateRule(rule);\n }\n\n const usedRules = new Set<IgnoreRule>();\n const kept = findings.filter((finding) => {\n const matchingRule = rules.find((rule) => ruleMatches(rule, finding));\n if (matchingRule) {\n usedRules.add(matchingRule);\n return false;\n }\n return true;\n });\n\n return { kept, staleIgnoreRules: rules.filter((rule) => !usedRules.has(rule)) };\n}\n\nfunction validateRule(rule: IgnoreRule): void {\n if (rule.table === undefined && rule.statement === undefined) {\n throw new Error(`pg-index-check: ignore rule needs a table or statement: ${JSON.stringify(rule)}`);\n }\n if (!rule.reason?.trim()) {\n throw new Error(`pg-index-check: ignore rule needs a reason: ${String(rule.table ?? rule.statement)}`);\n }\n}\n\nfunction ruleMatches(rule: IgnoreRule, finding: Finding): boolean {\n const tableMatches =\n rule.table === undefined ||\n (finding.table !== undefined &&\n (typeof rule.table === 'string' ? rule.table === finding.table : rule.table.test(finding.table)));\n\n const statementMatches =\n rule.statement === undefined ||\n (typeof rule.statement === 'string'\n ? finding.statement.includes(rule.statement)\n : rule.statement.test(finding.statement));\n\n return tableMatches && statementMatches;\n}\n","/**\n * Why a statement was flagged\n */\nexport const FindingKind = {\n /** A sequential scan applies a filter that no index can serve */\n SEQ_SCAN_FILTER: 'SEQ_SCAN_FILTER',\n /** A whole index is walked to apply a single-column equality filter */\n FULL_INDEX_SCAN: 'FULL_INDEX_SCAN',\n /** An index condition skips the index's leading column (PostgreSQL 16/17 only) */\n TRAILING_INDEX_COLUMN: 'TRAILING_INDEX_COLUMN',\n /** PostgreSQL could not produce a plan for the statement */\n UNPLANNABLE: 'UNPLANNABLE',\n} as const;\n\nexport type FindingKind = (typeof FindingKind)[keyof typeof FindingKind];\n\n/**\n * What to do with statements PostgreSQL cannot EXPLAIN\n */\nexport const UnplannableMode = {\n /** Treat them as findings, failing assertIndexes() */\n FAIL: 'fail',\n /** List them in the report without failing */\n REPORT: 'report',\n} as const;\n\nexport type UnplannableMode = (typeof UnplannableMode)[keyof typeof UnplannableMode];\n\nexport interface Finding {\n kind: FindingKind;\n /** Statement text as recorded by pg_stat_statements */\n statement: string;\n /** `path:line` from a pg-source-comments comment in the statement, if present */\n sourceLocation?: string;\n /** Schema-qualified table, e.g. `public.orders` */\n table?: string;\n /** Plan condition that triggered the finding, e.g. `(orders.status = $1)` */\n condition?: string;\n /** Index involved, for FULL_INDEX_SCAN and TRAILING_INDEX_COLUMN */\n indexName?: string;\n /** Suggested fix, when it can be derived from the condition */\n hint?: string;\n /** PostgreSQL error message, for UNPLANNABLE */\n error?: string;\n}\n\nexport interface IgnoreRule {\n /** Schema-qualified table (`public.orders`); a string must match exactly */\n table?: string | RegExp;\n /** Statement text; a string matches if the statement contains it */\n statement?: string | RegExp;\n /** Why this is acceptable. Required so the allowlist stays auditable. */\n reason: string;\n}\n\nexport interface CaptureOptions {\n connectionString: string;\n}\n\nexport interface IndexCheckOptions extends CaptureOptions {\n /** Findings to accept, each with a reason */\n ignore?: IgnoreRule[];\n /** @default 'fail' */\n unplannable?: UnplannableMode;\n}\n\nexport interface IndexCheckResult {\n /** Number of captured statements that were checked */\n statementCount: number;\n /** Findings that fail the check */\n findings: Finding[];\n /** Unplannable statements listed without failing (unplannable: 'report') */\n reported: Finding[];\n /** Ignore rules that matched no finding */\n staleIgnoreRules: IgnoreRule[];\n}\n\n/**\n * Subset of a PostgreSQL `EXPLAIN (FORMAT JSON, VERBOSE)` plan node used by the rules\n */\nexport interface PlanNode {\n 'Node Type': string;\n 'Relation Name'?: string;\n Schema?: string;\n 'Index Name'?: string;\n 'Index Cond'?: string;\n Filter?: string;\n Plans?: PlanNode[];\n}\n","import { FindingKind } from './types.js';\nimport type { Finding, PlanNode } from './types.js';\n\nconst INDEX_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan']);\nconst INDEX_COND_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan', 'Bitmap Index Scan']);\n\nexport interface PlanRuleContext {\n /**\n * Leading btree column per index, keyed by `schema.index`. Only needed when\n * checkTrailingIndexColumns is true.\n */\n leadingColumns: Map<string, string>;\n /** PostgreSQL 18+ can skip-scan, so trailing-column conditions are fine there */\n checkTrailingIndexColumns: boolean;\n}\n\ntype PlanFinding = Omit<Finding, 'statement' | 'sourceLocation'>;\n\n/**\n * Lists every plan node, depth first. Bitmap index scans don't name their table, so\n * they take it from the bitmap heap scan above them.\n */\nexport function flattenPlan(plan: PlanNode, bitmapRelation?: Pick<PlanNode, 'Schema' | 'Relation Name'>): PlanNode[] {\n const node = bitmapRelation && plan['Relation Name'] === undefined ? { ...plan, ...bitmapRelation } : plan;\n const childRelation =\n plan['Node Type'] === 'Bitmap Heap Scan'\n ? { Schema: plan.Schema, 'Relation Name': plan['Relation Name'] }\n : bitmapRelation;\n\n return [node, ...(plan.Plans ?? []).flatMap((child) => flattenPlan(child, childRelation))];\n}\n\n/**\n * Keys (`schema.index`) of indexes used with an index condition, for looking up\n * their leading columns\n */\nexport function indexesWithConditions(plan: PlanNode): string[] {\n return flattenPlan(plan)\n .filter((node) => INDEX_COND_SCAN_TYPES.has(node['Node Type']) && node['Index Cond'])\n .map((node) => indexKey(node));\n}\n\n/**\n * Applies the missing-index rules to a plan produced with enable_seqscan off\n */\nexport function evaluatePlan(plan: PlanNode, context: PlanRuleContext): PlanFinding[] {\n return flattenPlan(plan).flatMap((node) => {\n const finding =\n checkSeqScanFilter(node) ?? checkFullIndexScan(node) ?? checkTrailingIndexColumn(node, context);\n return finding ? [finding] : [];\n });\n}\n\n/**\n * A sequential scan that survives enable_seqscan off means no index can serve its filter\n */\nfunction checkSeqScanFilter(node: PlanNode): PlanFinding | undefined {\n const filter = node.Filter;\n if (node['Node Type'] !== 'Seq Scan' || !filter || isNegationOnly(filter)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.SEQ_SCAN_FILTER,\n table: tableName(node),\n condition: filter,\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Walking a whole index (usually to avoid a sort) while filtering on one column\n * means that column has no index. Range and IS NULL filters are skipped, since\n * pagination walks the primary key on purpose.\n */\nfunction checkFullIndexScan(node: PlanNode): PlanFinding | undefined {\n const filter = node.Filter;\n if (!INDEX_SCAN_TYPES.has(node['Node Type']) || !filter || node['Index Cond']) {\n return undefined;\n }\n if (equalityColumn(filter) === undefined) {\n return undefined;\n }\n\n return {\n kind: FindingKind.FULL_INDEX_SCAN,\n table: tableName(node),\n condition: filter,\n indexName: node['Index Name'],\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Before PostgreSQL 18, an equality condition that skips a btree index's leading\n * column has to scan the whole index\n */\nfunction checkTrailingIndexColumn(node: PlanNode, context: PlanRuleContext): PlanFinding | undefined {\n const indexCondition = node['Index Cond'];\n if (!context.checkTrailingIndexColumns || !INDEX_COND_SCAN_TYPES.has(node['Node Type']) || !indexCondition) {\n return undefined;\n }\n\n const leadingColumn = context.leadingColumns.get(indexKey(node));\n if (leadingColumn === undefined || !indexCondition.includes(' = ')) {\n return undefined;\n }\n if (new RegExp(`\\\\b\"?${escapeRegExp(leadingColumn)}\"?\\\\b`).test(indexCondition)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.TRAILING_INDEX_COLUMN,\n table: tableName(node),\n condition: indexCondition,\n indexName: node['Index Name'],\n hint: `condition does not use the index's leading column \"${leadingColumn}\"`,\n };\n}\n\n/**\n * Filters made only of `<>` / `NOT IN` comparisons, which a btree index cannot serve\n */\nfunction isNegationOnly(filter: string): boolean {\n return filter.includes('<>') && !/ = | < | > | <= | >= |~~| IS NULL/.test(filter);\n}\n\n/**\n * Column name when the filter is a single `column = value` comparison\n */\nfunction equalityColumn(filter: string): string | undefined {\n if (/ AND | OR /.test(filter) || filter.split(' = ').length !== 2) {\n return undefined;\n }\n const match = /^\\((?:\\w+\\.)?\"?(\\w+)\"? = /.exec(filter);\n return match?.[1];\n}\n\nfunction indexHint(node: PlanNode, filter: string): string | undefined {\n const column = equalityColumn(filter);\n return column === undefined ? undefined : `CREATE INDEX ON ${tableName(node)} (${column})`;\n}\n\nfunction tableName(node: PlanNode): string | undefined {\n const relation = node['Relation Name'];\n if (relation === undefined) {\n return undefined;\n }\n return node.Schema ? `${node.Schema}.${relation}` : relation;\n}\n\nfunction indexKey(node: PlanNode): string {\n return `${node.Schema}.${node['Index Name']}`;\n}\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n","import type { Finding, IndexCheckResult } from './types.js';\n\n/**\n * Formats a check result for humans\n */\nexport function formatReport(result: IndexCheckResult): string {\n const sections = [\n `pg-index-check: ${result.findings.length} finding(s) across ${result.statementCount} statement(s)`,\n ];\n\n if (result.findings.length > 0) {\n sections.push(result.findings.map(formatFinding).join('\\n\\n'));\n }\n if (result.reported.length > 0) {\n sections.push(`Unplannable statements (not failing):\\n\\n${result.reported.map(formatFinding).join('\\n\\n')}`);\n }\n if (result.staleIgnoreRules.length > 0) {\n const rules = result.staleIgnoreRules.map(\n (rule) => ` - ${String(rule.table ?? rule.statement)} (${rule.reason})`\n );\n sections.push(`Ignore rules that matched nothing:\\n${rules.join('\\n')}`);\n }\n\n return sections.join('\\n\\n');\n}\n\nfunction formatFinding(finding: Finding): string {\n const heading = [finding.kind, finding.table, finding.sourceLocation && `at ${finding.sourceLocation}`]\n .filter(Boolean)\n .join(' ');\n\n const details = [\n finding.condition && `condition: ${finding.condition}`,\n finding.indexName && `index: ${finding.indexName}`,\n finding.error && `error: ${finding.error}`,\n finding.hint && `fix: ${finding.hint}`,\n `statement: ${finding.statement}`,\n ].filter(Boolean);\n\n return [heading, ...details.map((line) => ` ${line}`)].join('\\n');\n}\n","import pg from 'pg';\nimport { readCapturedStatements, resetCapturedStatements } from './capture.js';\nimport {\n explainStatement,\n leadingIndexColumns,\n preparePlanningSession,\n serverMajorVersion,\n} from './explain.js';\nimport { applyIgnoreRules } from './ignore.js';\nimport { evaluatePlan, indexesWithConditions } from './plan-rules.js';\nimport { formatReport } from './report.js';\nimport { parseSourceLocation } from './statement-text.js';\nimport { FindingKind, UnplannableMode } from './types.js';\nimport type { CaptureOptions, Finding, IndexCheckOptions, IndexCheckResult, PlanNode } from './types.js';\n\n/** PostgreSQL 18 added skip scan, so conditions on trailing index columns are servable */\nconst SKIP_SCAN_MAJOR_VERSION = 18;\n/** EXPLAIN (GENERIC_PLAN) was added in PostgreSQL 16 */\nconst MINIMUM_MAJOR_VERSION = 16;\n\n/**\n * Thrown by assertIndexes() when statements have no usable index\n */\nexport class IndexCheckError extends Error {\n readonly result: IndexCheckResult;\n\n constructor(result: IndexCheckResult) {\n super(formatReport(result));\n this.name = 'IndexCheckError';\n this.result = result;\n }\n}\n\n/**\n * Clears recorded statements. Call before the test suite runs (e.g. in vitest globalSetup).\n */\nexport async function resetCapture(options: CaptureOptions): Promise<void> {\n await withClient(options.connectionString, resetCapturedStatements);\n}\n\n/**\n * Plans every statement recorded since resetCapture() with sequential scans\n * disabled, and returns those no index can serve\n */\nexport async function checkIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const unplannableMode = options.unplannable ?? UnplannableMode.FAIL;\n\n return withClient(options.connectionString, async (client) => {\n // Read before running anything else, so this check's own queries are not captured\n const statements = await readCapturedStatements(client);\n\n const majorVersion = await serverMajorVersion(client);\n if (majorVersion < MINIMUM_MAJOR_VERSION) {\n throw new Error(`pg-index-check: PostgreSQL ${MINIMUM_MAJOR_VERSION}+ is required, found ${majorVersion}`);\n }\n await preparePlanningSession(client);\n\n const allFindings: Finding[] = [];\n for (const statement of statements) {\n allFindings.push(...(await checkStatement(client, statement, majorVersion)));\n }\n\n const isReportedOnly = (finding: Finding) =>\n finding.kind === FindingKind.UNPLANNABLE && unplannableMode === UnplannableMode.REPORT;\n const { kept, staleIgnoreRules } = applyIgnoreRules(allFindings, options.ignore ?? []);\n\n return {\n statementCount: statements.length,\n findings: kept.filter((finding) => !isReportedOnly(finding)),\n reported: kept.filter(isReportedOnly),\n staleIgnoreRules,\n };\n });\n}\n\n/**\n * Like checkIndexes(), but throws an IndexCheckError listing the findings.\n * Call after the test suite runs (e.g. in vitest globalSetup's teardown).\n */\nexport async function assertIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const result = await checkIndexes(options);\n if (result.findings.length > 0) {\n throw new IndexCheckError(result);\n }\n return result;\n}\n\nasync function checkStatement(client: pg.Client, statement: string, majorVersion: number): Promise<Finding[]> {\n const sourceLocation = parseSourceLocation(statement);\n const outcome = await explainStatement(client, statement);\n\n if ('error' in outcome) {\n return [{ kind: FindingKind.UNPLANNABLE, statement, sourceLocation, error: outcome.error }];\n }\n\n const findings = await evaluate(client, outcome.plan, majorVersion);\n return findings.map((finding) => ({ ...finding, statement, sourceLocation }));\n}\n\nasync function evaluate(client: pg.Client, plan: PlanNode, majorVersion: number) {\n const checkTrailingIndexColumns = majorVersion < SKIP_SCAN_MAJOR_VERSION;\n const leadingColumns = checkTrailingIndexColumns\n ? await leadingIndexColumns(client, indexesWithConditions(plan))\n : new Map<string, string>();\n\n return evaluatePlan(plan, { leadingColumns, checkTrailingIndexColumns });\n}\n\nasync function withClient<T>(connectionString: string, work: (client: pg.Client) => Promise<T>): Promise<T> {\n // A dedicated connection, so enable_seqscan never leaks into the application's sessions\n const client = new pg.Client({ connectionString });\n await client.connect();\n try {\n return await work(client);\n } finally {\n await client.end();\n }\n}\n"]}
1
+ {"version":3,"sources":["../src/statement-text.ts","../src/capture.ts","../src/explain.ts","../src/ignore.ts","../src/types.ts","../src/plan-rules.ts","../src/report.ts","../src/check.ts"],"names":["pg"],"mappings":";;;;;;;;;;;AAAA,IAAM,gBAAA,uBAAuB,GAAA,CAAI,CAAC,UAAU,MAAA,EAAQ,QAAA,EAAU,QAAQ,CAAC,CAAA;AAMhE,SAAS,aAAa,SAAA,EAAuC;AAClE,EAAA,IAAI,KAAA,GAAQ,CAAA;AAEZ,EAAA,OAAO,KAAA,GAAQ,UAAU,MAAA,EAAQ;AAC/B,IAAA,IAAI,IAAA,CAAK,KAAK,SAAA,CAAU,KAAK,CAAC,CAAA,IAAK,SAAA,CAAU,KAAK,CAAA,KAAM,GAAA,EAAK;AAC3D,MAAA,KAAA,IAAS,CAAA;AAAA,IACX,CAAA,MAAA,IAAW,SAAA,CAAU,UAAA,CAAW,IAAA,EAAM,KAAK,CAAA,EAAG;AAC5C,MAAA,MAAM,UAAA,GAAa,SAAA,CAAU,OAAA,CAAQ,IAAA,EAAM,QAAQ,CAAC,CAAA;AACpD,MAAA,IAAI,eAAe,EAAA,EAAI;AACrB,QAAA,OAAO,MAAA;AAAA,MACT;AACA,MAAA,KAAA,GAAQ,UAAA,GAAa,CAAA;AAAA,IACvB,CAAA,MAAA,IAAW,SAAA,CAAU,UAAA,CAAW,IAAA,EAAM,KAAK,CAAA,EAAG;AAC5C,MAAA,MAAM,OAAA,GAAU,SAAA,CAAU,OAAA,CAAQ,IAAA,EAAM,QAAQ,CAAC,CAAA;AACjD,MAAA,IAAI,YAAY,EAAA,EAAI;AAClB,QAAA,OAAO,MAAA;AAAA,MACT;AACA,MAAA,KAAA,GAAQ,OAAA,GAAU,CAAA;AAAA,IACpB,CAAA,MAAO;AACL,MAAA,MAAM,YAAY,aAAA,CAAc,IAAA,CAAK,SAAA,CAAU,KAAA,CAAM,KAAK,CAAC,CAAA;AAC3D,MAAA,OAAO,SAAA,GAAY,CAAC,CAAA,CAAE,WAAA,EAAY;AAAA,IACpC;AAAA,EACF;AAEA,EAAA,OAAO,MAAA;AACT;AAMO,SAAS,qBAAqB,SAAA,EAA4B;AAC/D,EAAA,MAAM,OAAA,GAAU,aAAa,SAAS,CAAA;AACtC,EAAA,IAAI,YAAY,MAAA,IAAa,CAAC,gBAAA,CAAiB,GAAA,CAAI,OAAO,CAAA,EAAG;AAC3D,IAAA,OAAO,KAAA;AAAA,EACT;AAEA,EAAA,OAAO,CAAC,uDAAA,CAAwD,IAAA,CAAK,SAAS,CAAA;AAChF;AAMO,SAAS,oBAAoB,SAAA,EAAuC;AACzE,EAAA,MAAM,KAAA,GAAQ,kDAAA,CAAmD,IAAA,CAAK,SAAS,CAAA;AAC/E,EAAA,OAAO,QAAQ,CAAC,CAAA;AAClB;;;AClDA,IAAM,UAAA,GACJ,yKAAA;AAOF,eAAsB,wBAAwB,MAAA,EAA+B;AAC3E,EAAA,MAAM,MAAA,CAAO,MAAM,mDAAmD,CAAA;AACtE,EAAA,MAAM,MAAA,CAAO,MAAM,mCAAmC,CAAA;AACxD;AAKA,eAAsB,uBAAuB,MAAA,EAAmC;AAC9E,EAAA,MAAM,SAAA,GAAY,MAAM,MAAA,CAAO,KAAA;AAAA,IAC7B;AAAA,GACF;AACA,EAAA,IAAI,SAAA,CAAU,aAAa,CAAA,EAAG;AAC5B,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,mEAAA,EAAsE,UAAU,CAAA,CAAE,CAAA;AAAA,EACpG;AAEA,EAAA,MAAM,QAAA,GAAW,MAAM,MAAA,CAAO,KAAA,CAAyB;AAAA;AAAA;AAAA;AAAA;AAAA,EAAA,CAKtD,CAAA;AAED,EAAA,MAAM,UAAA,GAAa,QAAA,CAAS,IAAA,CAAK,GAAA,CAAI,CAAC,QAAQ,GAAA,CAAI,KAAK,CAAA,CAAE,MAAA,CAAO,oBAAoB,CAAA;AACpF,EAAA,IAAI,UAAA,CAAW,WAAW,CAAA,EAAG;AAC3B,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,8DAAA,EAAiE,UAAU,CAAA,CAAE,CAAA;AAAA,EAC/F;AAEA,EAAA,OAAO,UAAA;AACT;;;AC/BA,eAAsB,uBAAuB,MAAA,EAA+B;AAC1E,EAAA,MAAM,MAAA,CAAO,MAAM,0BAA0B,CAAA;AAC/C;AAMA,eAAsB,gBAAA,CAAiB,QAAgB,SAAA,EAA4C;AACjG,EAAA,IAAI;AACF,IAAA,MAAM,MAAA,GAAS,MAAM,MAAA,CAAO,KAAA;AAAA,MAC1B,gDAAgD,SAAS,CAAA;AAAA,KAC3D;AACA,IAAA,OAAO,EAAE,IAAA,EAAM,MAAA,CAAO,IAAA,CAAK,CAAC,EAAE,YAAY,CAAA,CAAE,CAAC,CAAA,CAAE,IAAA,EAAK;AAAA,EACtD,SAAS,KAAA,EAAO;AAEd,IAAA,OAAO,EAAE,OAAO,KAAA,YAAiB,KAAA,GAAQ,MAAM,OAAA,GAAU,MAAA,CAAO,KAAK,CAAA,EAAE;AAAA,EACzE;AACF;AAKA,eAAsB,mBAAmB,MAAA,EAAiC;AACxE,EAAA,MAAM,MAAA,GAAS,MAAM,MAAA,CAAO,KAAA,CAAsC,yBAAyB,CAAA;AAC3F,EAAA,OAAO,IAAA,CAAK,MAAM,MAAA,CAAO,MAAA,CAAO,KAAK,CAAC,CAAA,CAAE,kBAAkB,CAAA,GAAI,GAAK,CAAA;AACrE;AAMA,eAAsB,mBAAA,CAAoB,QAAgB,SAAA,EAAmD;AAC3G,EAAA,IAAI,SAAA,CAAU,WAAW,CAAA,EAAG;AAC1B,IAAA,2BAAW,GAAA,EAAI;AAAA,EACjB;AAEA,EAAA,MAAM,MAAA,GAAS,MAAM,MAAA,CAAO,KAAA;AAAA,IAC1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAAA,CAAA;AAAA,IASA,CAAC,SAAS;AAAA,GACZ;AAEA,EAAA,OAAO,IAAI,GAAA,CAAI,MAAA,CAAO,IAAA,CAAK,GAAA,CAAI,CAAC,GAAA,KAAQ,CAAC,GAAA,CAAI,SAAA,EAAW,GAAA,CAAI,WAAW,CAAC,CAAC,CAAA;AAC3E;AAOA,eAAsB,mBAAA,CAAoB,QAAgB,MAAA,EAAqD;AAC7G,EAAA,IAAI,MAAA,CAAO,WAAW,CAAA,EAAG;AACvB,IAAA,2BAAW,GAAA,EAAI;AAAA,EACjB;AAEA,EAAA,MAAM,MAAA,GAAS,MAAM,MAAA,CAAO,KAAA;AAAA,IAC1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAAA,CAAA;AAAA,IAUA,CAAC,MAAM;AAAA,GACT;AAEA,EAAA,MAAM,OAAA,uBAAc,GAAA,EAAyB;AAC7C,EAAA,KAAA,MAAW,GAAA,IAAO,OAAO,IAAA,EAAM;AAC7B,IAAA,MAAM,UAAU,OAAA,CAAQ,GAAA,CAAI,IAAI,SAAS,CAAA,wBAAS,GAAA,EAAY;AAC9D,IAAA,OAAA,CAAQ,GAAA,CAAI,IAAI,WAAW,CAAA;AAC3B,IAAA,OAAA,CAAQ,GAAA,CAAI,GAAA,CAAI,SAAA,EAAW,OAAO,CAAA;AAAA,EACpC;AACA,EAAA,OAAO,OAAA;AACT;;;ACxFO,SAAS,gBAAA,CACd,UACA,KAAA,EACqD;AACrD,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,YAAA,CAAa,IAAI,CAAA;AAAA,EACnB;AAEA,EAAA,MAAM,SAAA,uBAAgB,GAAA,EAAgB;AACtC,EAAA,MAAM,IAAA,GAAO,QAAA,CAAS,MAAA,CAAO,CAAC,OAAA,KAAY;AACxC,IAAA,MAAM,YAAA,GAAe,MAAM,IAAA,CAAK,CAAC,SAAS,WAAA,CAAY,IAAA,EAAM,OAAO,CAAC,CAAA;AACpE,IAAA,IAAI,YAAA,EAAc;AAChB,MAAA,SAAA,CAAU,IAAI,YAAY,CAAA;AAC1B,MAAA,OAAO,KAAA;AAAA,IACT;AACA,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AAED,EAAA,OAAO,EAAE,IAAA,EAAM,gBAAA,EAAkB,KAAA,CAAM,MAAA,CAAO,CAAC,IAAA,KAAS,CAAC,SAAA,CAAU,GAAA,CAAI,IAAI,CAAC,CAAA,EAAE;AAChF;AAEA,SAAS,aAAa,IAAA,EAAwB;AAC5C,EAAA,IAAI,IAAA,CAAK,KAAA,KAAU,MAAA,IAAa,IAAA,CAAK,cAAc,MAAA,EAAW;AAC5D,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,wDAAA,EAA2D,KAAK,SAAA,CAAU,IAAI,CAAC,CAAA,CAAE,CAAA;AAAA,EACnG;AACA,EAAA,IAAI,CAAC,IAAA,CAAK,MAAA,EAAQ,IAAA,EAAK,EAAG;AACxB,IAAA,MAAM,IAAI,MAAM,CAAA,4CAAA,EAA+C,MAAA,CAAO,KAAK,KAAA,IAAS,IAAA,CAAK,SAAS,CAAC,CAAA,CAAE,CAAA;AAAA,EACvG;AACF;AAEA,SAAS,WAAA,CAAY,MAAkB,OAAA,EAA2B;AAChE,EAAA,MAAM,eACJ,IAAA,CAAK,KAAA,KAAU,UACd,OAAA,CAAQ,KAAA,KAAU,WAChB,OAAO,IAAA,CAAK,UAAU,QAAA,GAAW,IAAA,CAAK,UAAU,OAAA,CAAQ,KAAA,GAAQ,KAAK,KAAA,CAAM,IAAA,CAAK,QAAQ,KAAK,CAAA,CAAA;AAElG,EAAA,MAAM,mBACJ,IAAA,CAAK,SAAA,KAAc,WAClB,OAAO,IAAA,CAAK,cAAc,QAAA,GACvB,OAAA,CAAQ,SAAA,CAAU,QAAA,CAAS,KAAK,SAAS,CAAA,GACzC,KAAK,SAAA,CAAU,IAAA,CAAK,QAAQ,SAAS,CAAA,CAAA;AAE3C,EAAA,OAAO,YAAA,IAAgB,gBAAA;AACzB;;;AC7CO,IAAM,WAAA,GAAc;AAAA;AAAA,EAEzB,eAAA,EAAiB,iBAAA;AAAA;AAAA,EAEjB,eAAA,EAAiB,iBAAA;AAAA;AAAA,EAEjB,qBAAA,EAAuB,uBAAA;AAAA;AAAA,EAEvB,WAAA,EAAa;AACf;AAOO,IAAM,eAAA,GAAkB;AAAA;AAAA,EAE7B,IAAA,EAAM,MAAA;AAAA;AAAA,EAEN,MAAA,EAAQ;AACV;;;ACrBA,IAAM,mCAAmB,IAAI,GAAA,CAAI,CAAC,YAAA,EAAc,iBAAiB,CAAC,CAAA;AAClE,IAAM,wCAAwB,IAAI,GAAA,CAAI,CAAC,YAAA,EAAc,iBAAA,EAAmB,mBAAmB,CAAC,CAAA;AAwBrF,SAAS,WAAA,CAAY,MAAgB,cAAA,EAAyE;AACnH,EAAA,MAAM,IAAA,GAAO,cAAA,IAAkB,IAAA,CAAK,eAAe,CAAA,KAAM,MAAA,GAAY,EAAE,GAAG,IAAA,EAAM,GAAG,cAAA,EAAe,GAAI,IAAA;AACtG,EAAA,MAAM,aAAA,GACJ,IAAA,CAAK,WAAW,CAAA,KAAM,kBAAA,GAClB,EAAE,MAAA,EAAQ,IAAA,CAAK,MAAA,EAAQ,eAAA,EAAiB,IAAA,CAAK,eAAe,GAAE,GAC9D,cAAA;AAEN,EAAA,OAAO,CAAC,IAAA,EAAM,GAAA,CAAI,IAAA,CAAK,SAAS,EAAC,EAAG,OAAA,CAAQ,CAAC,KAAA,KAAU,WAAA,CAAY,KAAA,EAAO,aAAa,CAAC,CAAC,CAAA;AAC3F;AAMO,SAAS,sBAAsB,IAAA,EAA0B;AAC9D,EAAA,OAAO,WAAA,CAAY,IAAI,CAAA,CACpB,MAAA,CAAO,CAAC,IAAA,KAAS,qBAAA,CAAsB,IAAI,IAAA,CAAK,WAAW,CAAC,CAAA,IAAK,IAAA,CAAK,YAAY,CAAC,CAAA,CACnF,IAAI,CAAC,IAAA,KAAS,QAAA,CAAS,IAAI,CAAC,CAAA;AACjC;AAKO,SAAS,YAAA,CAAa,MAAgB,OAAA,EAAyC;AACpF,EAAA,OAAO,WAAA,CAAY,IAAI,CAAA,CAAE,OAAA,CAAQ,CAAC,IAAA,KAAS;AACzC,IAAA,MAAM,OAAA,GACJ,kBAAA,CAAmB,IAAI,CAAA,IAAK,kBAAA,CAAmB,MAAM,OAAO,CAAA,IAAK,wBAAA,CAAyB,IAAA,EAAM,OAAO,CAAA;AACzG,IAAA,OAAO,OAAA,GAAU,CAAC,OAAO,CAAA,GAAI,EAAC;AAAA,EAChC,CAAC,CAAA;AACH;AAKO,SAAS,WAAW,IAAA,EAA0B;AACnD,EAAA,OAAO,CAAC,GAAG,IAAI,GAAA,CAAI,WAAA,CAAY,IAAI,CAAA,CAAE,GAAA,CAAI,SAAS,CAAA,CAAE,OAAO,CAAC,IAAA,KAAyB,IAAA,KAAS,MAAS,CAAC,CAAC,CAAA;AAC3G;AAKA,SAAS,mBAAmB,IAAA,EAAyC;AACnE,EAAA,MAAM,SAAS,IAAA,CAAK,MAAA;AACpB,EAAA,IAAI,IAAA,CAAK,WAAW,CAAA,KAAM,UAAA,IAAc,CAAC,MAAA,IAAU,cAAA,CAAe,MAAM,CAAA,EAAG;AACzE,IAAA,OAAO,MAAA;AAAA,EACT;AAEA,EAAA,OAAO;AAAA,IACL,MAAM,WAAA,CAAY,eAAA;AAAA,IAClB,KAAA,EAAO,UAAU,IAAI,CAAA;AAAA,IACrB,SAAA,EAAW,MAAA;AAAA,IACX,IAAA,EAAM,SAAA,CAAU,IAAA,EAAM,MAAM;AAAA,GAC9B;AACF;AAOA,SAAS,kBAAA,CAAmB,MAAgB,OAAA,EAAmD;AAC7F,EAAA,MAAM,SAAS,IAAA,CAAK,MAAA;AACpB,EAAA,IAAI,CAAC,gBAAA,CAAiB,GAAA,CAAI,IAAA,CAAK,WAAW,CAAC,CAAA,IAAK,CAAC,MAAA,IAAU,IAAA,CAAK,YAAY,CAAA,EAAG;AAC7E,IAAA,OAAO,MAAA;AAAA,EACT;AACA,EAAA,MAAM,MAAA,GAAS,eAAe,MAAM,CAAA;AACpC,EAAA,IAAI,WAAW,MAAA,EAAW;AACxB,IAAA,OAAO,MAAA;AAAA,EACT;AAMA,EAAA,MAAM,KAAA,GAAQ,UAAU,IAAI,CAAA;AAC5B,EAAA,IAAI,KAAA,KAAU,UAAa,OAAA,CAAQ,qBAAA,CAAsB,IAAI,KAAK,CAAA,EAAG,GAAA,CAAI,MAAM,CAAA,EAAG;AAChF,IAAA,OAAO,MAAA;AAAA,EACT;AAEA,EAAA,OAAO;AAAA,IACL,MAAM,WAAA,CAAY,eAAA;AAAA,IAClB,KAAA;AAAA,IACA,SAAA,EAAW,MAAA;AAAA,IACX,SAAA,EAAW,KAAK,YAAY,CAAA;AAAA,IAC5B,IAAA,EAAM,SAAA,CAAU,IAAA,EAAM,MAAM;AAAA,GAC9B;AACF;AAMA,SAAS,wBAAA,CAAyB,MAAgB,OAAA,EAAmD;AACnG,EAAA,MAAM,cAAA,GAAiB,KAAK,YAAY,CAAA;AACxC,EAAA,IAAI,CAAC,OAAA,CAAQ,yBAAA,IAA6B,CAAC,qBAAA,CAAsB,GAAA,CAAI,IAAA,CAAK,WAAW,CAAC,CAAA,IAAK,CAAC,cAAA,EAAgB;AAC1G,IAAA,OAAO,MAAA;AAAA,EACT;AAEA,EAAA,MAAM,gBAAgB,OAAA,CAAQ,cAAA,CAAe,GAAA,CAAI,QAAA,CAAS,IAAI,CAAC,CAAA;AAC/D,EAAA,IAAI,kBAAkB,MAAA,IAAa,CAAC,cAAA,CAAe,QAAA,CAAS,KAAK,CAAA,EAAG;AAClE,IAAA,OAAO,MAAA;AAAA,EACT;AACA,EAAA,IAAI,IAAI,MAAA,CAAO,CAAA,KAAA,EAAQ,YAAA,CAAa,aAAa,CAAC,CAAA,KAAA,CAAO,CAAA,CAAE,IAAA,CAAK,cAAc,CAAA,EAAG;AAC/E,IAAA,OAAO,MAAA;AAAA,EACT;AAEA,EAAA,OAAO;AAAA,IACL,MAAM,WAAA,CAAY,qBAAA;AAAA,IAClB,KAAA,EAAO,UAAU,IAAI,CAAA;AAAA,IACrB,SAAA,EAAW,cAAA;AAAA,IACX,SAAA,EAAW,KAAK,YAAY,CAAA;AAAA,IAC5B,IAAA,EAAM,sDAAsD,aAAa,CAAA,CAAA;AAAA,GAC3E;AACF;AAKA,SAAS,eAAe,MAAA,EAAyB;AAC/C,EAAA,OAAO,OAAO,QAAA,CAAS,IAAI,KAAK,CAAC,mCAAA,CAAoC,KAAK,MAAM,CAAA;AAClF;AAKA,SAAS,eAAe,MAAA,EAAoC;AAC1D,EAAA,IAAI,YAAA,CAAa,KAAK,MAAM,CAAA,IAAK,OAAO,KAAA,CAAM,KAAK,CAAA,CAAE,MAAA,KAAW,CAAA,EAAG;AACjE,IAAA,OAAO,MAAA;AAAA,EACT;AACA,EAAA,MAAM,KAAA,GAAQ,2BAAA,CAA4B,IAAA,CAAK,MAAM,CAAA;AACrD,EAAA,OAAO,QAAQ,CAAC,CAAA;AAClB;AAEA,SAAS,SAAA,CAAU,MAAgB,MAAA,EAAoC;AACrE,EAAA,MAAM,MAAA,GAAS,eAAe,MAAM,CAAA;AACpC,EAAA,OAAO,MAAA,KAAW,SAAY,MAAA,GAAY,CAAA,gBAAA,EAAmB,UAAU,IAAI,CAAC,KAAK,MAAM,CAAA,CAAA,CAAA;AACzF;AAEA,SAAS,UAAU,IAAA,EAAoC;AACrD,EAAA,MAAM,QAAA,GAAW,KAAK,eAAe,CAAA;AACrC,EAAA,IAAI,aAAa,MAAA,EAAW;AAC1B,IAAA,OAAO,MAAA;AAAA,EACT;AACA,EAAA,OAAO,KAAK,MAAA,GAAS,CAAA,EAAG,KAAK,MAAM,CAAA,CAAA,EAAI,QAAQ,CAAA,CAAA,GAAK,QAAA;AACtD;AAEA,SAAS,SAAS,IAAA,EAAwB;AACxC,EAAA,OAAO,GAAG,IAAA,CAAK,MAAM,CAAA,CAAA,EAAI,IAAA,CAAK,YAAY,CAAC,CAAA,CAAA;AAC7C;AAEA,SAAS,aAAa,IAAA,EAAsB;AAC1C,EAAA,OAAO,IAAA,CAAK,OAAA,CAAQ,qBAAA,EAAuB,MAAM,CAAA;AACnD;;;AC/KO,SAAS,aAAa,MAAA,EAAkC;AAC7D,EAAA,MAAM,QAAA,GAAW;AAAA,IACf,mBAAmB,MAAA,CAAO,QAAA,CAAS,MAAM,CAAA,mBAAA,EAAsB,OAAO,cAAc,CAAA,aAAA;AAAA,GACtF;AAEA,EAAA,IAAI,MAAA,CAAO,QAAA,CAAS,MAAA,GAAS,CAAA,EAAG;AAC9B,IAAA,QAAA,CAAS,IAAA,CAAK,OAAO,QAAA,CAAS,GAAA,CAAI,aAAa,CAAA,CAAE,IAAA,CAAK,MAAM,CAAC,CAAA;AAAA,EAC/D;AACA,EAAA,IAAI,MAAA,CAAO,QAAA,CAAS,MAAA,GAAS,CAAA,EAAG;AAC9B,IAAA,QAAA,CAAS,IAAA,CAAK,CAAA;;AAAA,EAA4C,MAAA,CAAO,SAAS,GAAA,CAAI,aAAa,EAAE,IAAA,CAAK,MAAM,CAAC,CAAA,CAAE,CAAA;AAAA,EAC7G;AACA,EAAA,IAAI,MAAA,CAAO,gBAAA,CAAiB,MAAA,GAAS,CAAA,EAAG;AACtC,IAAA,MAAM,KAAA,GAAQ,OAAO,gBAAA,CAAiB,GAAA;AAAA,MACpC,CAAC,IAAA,KAAS,CAAA,IAAA,EAAO,MAAA,CAAO,IAAA,CAAK,KAAA,IAAS,IAAA,CAAK,SAAS,CAAC,CAAA,EAAA,EAAK,IAAA,CAAK,MAAM,CAAA,CAAA;AAAA,KACvE;AACA,IAAA,QAAA,CAAS,IAAA,CAAK,CAAA;AAAA,EAAuC,KAAA,CAAM,IAAA,CAAK,IAAI,CAAC,CAAA,CAAE,CAAA;AAAA,EACzE;AAEA,EAAA,OAAO,QAAA,CAAS,KAAK,MAAM,CAAA;AAC7B;AAEA,SAAS,cAAc,OAAA,EAA0B;AAC/C,EAAA,MAAM,UAAU,CAAC,OAAA,CAAQ,IAAA,EAAM,OAAA,CAAQ,OAAO,OAAA,CAAQ,cAAA,IAAkB,CAAA,GAAA,EAAM,OAAA,CAAQ,cAAc,CAAA,CAAE,CAAA,CACnG,OAAO,OAAO,CAAA,CACd,KAAK,GAAG,CAAA;AAEX,EAAA,MAAM,OAAA,GAAU;AAAA,IACd,OAAA,CAAQ,SAAA,IAAa,CAAA,WAAA,EAAc,OAAA,CAAQ,SAAS,CAAA,CAAA;AAAA,IACpD,OAAA,CAAQ,SAAA,IAAa,CAAA,OAAA,EAAU,OAAA,CAAQ,SAAS,CAAA,CAAA;AAAA,IAChD,OAAA,CAAQ,KAAA,IAAS,CAAA,OAAA,EAAU,OAAA,CAAQ,KAAK,CAAA,CAAA;AAAA,IACxC,OAAA,CAAQ,IAAA,IAAQ,CAAA,KAAA,EAAQ,OAAA,CAAQ,IAAI,CAAA,CAAA;AAAA,IACpC,CAAA,WAAA,EAAc,QAAQ,SAAS,CAAA;AAAA,GACjC,CAAE,OAAO,OAAO,CAAA;AAEhB,EAAA,OAAO,CAAC,OAAA,EAAS,GAAG,OAAA,CAAQ,GAAA,CAAI,CAAC,IAAA,KAAS,CAAA,EAAA,EAAK,IAAI,CAAA,CAAE,CAAC,CAAA,CAAE,KAAK,IAAI,CAAA;AACnE;;;ACvBA,IAAM,uBAAA,GAA0B,EAAA;AAEhC,IAAM,qBAAA,GAAwB,EAAA;AAKvB,IAAM,eAAA,GAAN,cAA8B,KAAA,CAAM;AAAA,EAChC,MAAA;AAAA,EAET,YAAY,MAAA,EAA0B;AACpC,IAAA,KAAA,CAAM,YAAA,CAAa,MAAM,CAAC,CAAA;AAC1B,IAAA,IAAA,CAAK,IAAA,GAAO,iBAAA;AACZ,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AAAA,EAChB;AACF;AAKA,eAAsB,aAAa,OAAA,EAAwC;AACzE,EAAA,MAAM,UAAA,CAAW,OAAA,CAAQ,gBAAA,EAAkB,uBAAuB,CAAA;AACpE;AAMA,eAAsB,aAAa,OAAA,EAAuD;AACxF,EAAA,MAAM,eAAA,GAAkB,OAAA,CAAQ,WAAA,IAAe,eAAA,CAAgB,IAAA;AAE/D,EAAA,OAAO,UAAA,CAAW,OAAA,CAAQ,gBAAA,EAAkB,OAAO,MAAA,KAAW;AAE5D,IAAA,MAAM,UAAA,GAAa,MAAM,sBAAA,CAAuB,MAAM,CAAA;AAEtD,IAAA,MAAM,YAAA,GAAe,MAAM,kBAAA,CAAmB,MAAM,CAAA;AACpD,IAAA,IAAI,eAAe,qBAAA,EAAuB;AACxC,MAAA,MAAM,IAAI,KAAA,CAAM,CAAA,2BAAA,EAA8B,qBAAqB,CAAA,qBAAA,EAAwB,YAAY,CAAA,CAAE,CAAA;AAAA,IAC3G;AACA,IAAA,MAAM,uBAAuB,MAAM,CAAA;AAEnC,IAAA,MAAM,cAAyB,EAAC;AAChC,IAAA,KAAA,MAAW,aAAa,UAAA,EAAY;AAClC,MAAA,WAAA,CAAY,KAAK,GAAI,MAAM,eAAe,MAAA,EAAQ,SAAA,EAAW,YAAY,CAAE,CAAA;AAAA,IAC7E;AAEA,IAAA,MAAM,cAAA,GAAiB,CAAC,OAAA,KACtB,OAAA,CAAQ,SAAS,WAAA,CAAY,WAAA,IAAe,oBAAoB,eAAA,CAAgB,MAAA;AAClF,IAAA,MAAM,EAAE,MAAM,gBAAA,EAAiB,GAAI,iBAAiB,WAAA,EAAa,OAAA,CAAQ,MAAA,IAAU,EAAE,CAAA;AAErF,IAAA,OAAO;AAAA,MACL,gBAAgB,UAAA,CAAW,MAAA;AAAA,MAC3B,QAAA,EAAU,KAAK,MAAA,CAAO,CAAC,YAAY,CAAC,cAAA,CAAe,OAAO,CAAC,CAAA;AAAA,MAC3D,QAAA,EAAU,IAAA,CAAK,MAAA,CAAO,cAAc,CAAA;AAAA,MACpC;AAAA,KACF;AAAA,EACF,CAAC,CAAA;AACH;AAMA,eAAsB,cAAc,OAAA,EAAuD;AACzF,EAAA,MAAM,MAAA,GAAS,MAAM,YAAA,CAAa,OAAO,CAAA;AACzC,EAAA,IAAI,MAAA,CAAO,QAAA,CAAS,MAAA,GAAS,CAAA,EAAG;AAC9B,IAAA,MAAM,IAAI,gBAAgB,MAAM,CAAA;AAAA,EAClC;AACA,EAAA,OAAO,MAAA;AACT;AAEA,eAAe,cAAA,CAAe,MAAA,EAAmB,SAAA,EAAmB,YAAA,EAA0C;AAC5G,EAAA,MAAM,cAAA,GAAiB,oBAAoB,SAAS,CAAA;AACpD,EAAA,MAAM,OAAA,GAAU,MAAM,gBAAA,CAAiB,MAAA,EAAQ,SAAS,CAAA;AAExD,EAAA,IAAI,WAAW,OAAA,EAAS;AACtB,IAAA,OAAO,CAAC,EAAE,IAAA,EAAM,WAAA,CAAY,WAAA,EAAa,WAAW,cAAA,EAAgB,KAAA,EAAO,OAAA,CAAQ,KAAA,EAAO,CAAA;AAAA,EAC5F;AAEA,EAAA,MAAM,WAAW,MAAM,QAAA,CAAS,MAAA,EAAQ,OAAA,CAAQ,MAAM,YAAY,CAAA;AAClE,EAAA,OAAO,QAAA,CAAS,IAAI,CAAC,OAAA,MAAa,EAAE,GAAG,OAAA,EAAS,SAAA,EAAW,cAAA,EAAe,CAAE,CAAA;AAC9E;AAEA,eAAe,QAAA,CAAS,MAAA,EAAmB,IAAA,EAAgB,YAAA,EAAsB;AAC/E,EAAA,MAAM,4BAA4B,YAAA,GAAe,uBAAA;AACjD,EAAA,MAAM,cAAA,GAAiB,yBAAA,GACnB,MAAM,mBAAA,CAAoB,MAAA,EAAQ,sBAAsB,IAAI,CAAC,CAAA,mBAC7D,IAAI,GAAA,EAAoB;AAC5B,EAAA,MAAM,wBAAwB,MAAM,mBAAA,CAAoB,MAAA,EAAQ,UAAA,CAAW,IAAI,CAAC,CAAA;AAEhF,EAAA,OAAO,aAAa,IAAA,EAAM,EAAE,cAAA,EAAgB,qBAAA,EAAuB,2BAA2B,CAAA;AAChG;AAEA,eAAe,UAAA,CAAc,kBAA0B,IAAA,EAAqD;AAE1G,EAAA,MAAM,SAAS,IAAIA,mBAAA,CAAG,MAAA,CAAO,EAAE,kBAAkB,CAAA;AACjD,EAAA,MAAM,OAAO,OAAA,EAAQ;AACrB,EAAA,IAAI;AACF,IAAA,OAAO,MAAM,KAAK,MAAM,CAAA;AAAA,EAC1B,CAAA,SAAE;AACA,IAAA,MAAM,OAAO,GAAA,EAAI;AAAA,EACnB;AACF","file":"index.cjs","sourcesContent":["const CHECKED_KEYWORDS = new Set(['SELECT', 'WITH', 'UPDATE', 'DELETE']);\n\n/**\n * Returns the first keyword of a statement (upper-cased), skipping leading\n * whitespace, comments and opening parentheses\n */\nexport function firstKeyword(statement: string): string | undefined {\n let index = 0;\n\n while (index < statement.length) {\n if (/\\s/.test(statement[index]) || statement[index] === '(') {\n index += 1;\n } else if (statement.startsWith('/*', index)) {\n const commentEnd = statement.indexOf('*/', index + 2);\n if (commentEnd === -1) {\n return undefined;\n }\n index = commentEnd + 2;\n } else if (statement.startsWith('--', index)) {\n const lineEnd = statement.indexOf('\\n', index + 2);\n if (lineEnd === -1) {\n return undefined;\n }\n index = lineEnd + 1;\n } else {\n const wordMatch = /^[A-Za-z_]+/.exec(statement.slice(index));\n return wordMatch?.[0].toUpperCase();\n }\n }\n\n return undefined;\n}\n\n/**\n * Whether a captured statement is one whose plan should be checked: a query or\n * data-modifying statement that reads rows, and not a catalog/monitoring query\n */\nexport function isCheckableStatement(statement: string): boolean {\n const keyword = firstKeyword(statement);\n if (keyword === undefined || !CHECKED_KEYWORDS.has(keyword)) {\n return false;\n }\n\n return !/pg_stat_statements|pg_catalog\\.|information_schema\\./i.test(statement);\n}\n\n/**\n * Extracts the `path:line` location from a pg-source-comments comment,\n * e.g. `SELECT /* src/api/users.ts:42 *\\/ ...` gives `src/api/users.ts:42`\n */\nexport function parseSourceLocation(statement: string): string | undefined {\n const match = /\\/\\*\\s*(\\S+:\\d+(?::\\d+)?)(?:\\s+in\\s+\\S+)?\\s*\\*\\//.exec(statement);\n return match?.[1];\n}\n","import type { Client } from 'pg';\nimport { isCheckableStatement } from './statement-text.js';\n\nconst SETUP_HELP =\n 'Add pg_stat_statements to shared_preload_libraries (e.g. `postgres -c shared_preload_libraries=pg_stat_statements`) ' +\n 'and call resetCapture() before the test suite runs.';\n\n/**\n * Installs pg_stat_statements in the current database (if needed) and clears it,\n * so only statements run after this point are checked\n */\nexport async function resetCapturedStatements(client: Client): Promise<void> {\n await client.query('CREATE EXTENSION IF NOT EXISTS pg_stat_statements');\n await client.query('SELECT pg_stat_statements_reset()');\n}\n\n/**\n * Reads the distinct statements recorded for the current database since the last reset\n */\nexport async function readCapturedStatements(client: Client): Promise<string[]> {\n const installed = await client.query(\n \"SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'\"\n );\n if (installed.rowCount === 0) {\n throw new Error(`pg-index-check: the pg_stat_statements extension is not installed. ${SETUP_HELP}`);\n }\n\n const captured = await client.query<{ query: string }>(`\n SELECT s.query\n FROM pg_stat_statements s\n JOIN pg_catalog.pg_database d ON d.oid = s.dbid\n WHERE d.datname = current_database() AND s.toplevel\n `);\n\n const statements = captured.rows.map((row) => row.query).filter(isCheckableStatement);\n if (statements.length === 0) {\n throw new Error(`pg-index-check: no statements were captured in this database. ${SETUP_HELP}`);\n }\n\n return statements;\n}\n","import type { Client } from 'pg';\nimport type { PlanNode } from './types.js';\n\nexport type ExplainOutcome = { plan: PlanNode } | { error: string };\n\n/**\n * Makes the planner treat sequential scans as a last resort for this session,\n * so any sequential scan left in a plan is one no index can replace\n */\nexport async function preparePlanningSession(client: Client): Promise<void> {\n await client.query('SET enable_seqscan = off');\n}\n\n/**\n * Plans a statement with placeholders ($1, ...) left in place. Sent without bound\n * values (simple query protocol), which EXPLAIN (GENERIC_PLAN) requires.\n */\nexport async function explainStatement(client: Client, statement: string): Promise<ExplainOutcome> {\n try {\n const result = await client.query<{ 'QUERY PLAN': [{ Plan: PlanNode }] }>(\n `EXPLAIN (GENERIC_PLAN, VERBOSE, FORMAT JSON) ${statement}`\n );\n return { plan: result.rows[0]['QUERY PLAN'][0].Plan };\n } catch (error) {\n // Recorded as an UNPLANNABLE finding, which fails by default\n return { error: error instanceof Error ? error.message : String(error) };\n }\n}\n\n/**\n * Server major version, e.g. 16\n */\nexport async function serverMajorVersion(client: Client): Promise<number> {\n const result = await client.query<{ server_version_num: string }>('SHOW server_version_num');\n return Math.floor(Number(result.rows[0].server_version_num) / 10000);\n}\n\n/**\n * Leading column of each btree index, keyed by `schema.index`. Expression indexes\n * have no leading column and are omitted.\n */\nexport async function leadingIndexColumns(client: Client, indexKeys: string[]): Promise<Map<string, string>> {\n if (indexKeys.length === 0) {\n return new Map();\n }\n\n const result = await client.query<{ index_key: string; column_name: string }>(\n `\n SELECT n.nspname || '.' || c.relname AS index_key, a.attname AS column_name\n FROM pg_catalog.pg_index i\n JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid\n JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace\n JOIN pg_catalog.pg_am am ON am.oid = c.relam\n JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]\n WHERE am.amname = 'btree' AND n.nspname || '.' || c.relname = ANY($1)\n `,\n [indexKeys]\n );\n\n return new Map(result.rows.map((row) => [row.index_key, row.column_name]));\n}\n\n/**\n * Columns that lead some valid btree index, per table, keyed by `schema.table`.\n * A query filtering such a column by equality has a usable index even if the\n * generic planner chose a different one, so it is not a missing-index finding.\n */\nexport async function tableLeadingColumns(client: Client, tables: string[]): Promise<Map<string, Set<string>>> {\n if (tables.length === 0) {\n return new Map();\n }\n\n const result = await client.query<{ table_key: string; column_name: string }>(\n `\n SELECT n.nspname || '.' || t.relname AS table_key, a.attname AS column_name\n FROM pg_catalog.pg_index i\n JOIN pg_catalog.pg_class t ON t.oid = i.indrelid\n JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid\n JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace\n JOIN pg_catalog.pg_am am ON am.oid = c.relam\n JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]\n WHERE am.amname = 'btree' AND i.indisvalid AND n.nspname || '.' || t.relname = ANY($1)\n `,\n [tables]\n );\n\n const byTable = new Map<string, Set<string>>();\n for (const row of result.rows) {\n const columns = byTable.get(row.table_key) ?? new Set<string>();\n columns.add(row.column_name);\n byTable.set(row.table_key, columns);\n }\n return byTable;\n}\n","import type { Finding, IgnoreRule } from './types.js';\n\n/**\n * Removes findings matched by an ignore rule and reports rules that matched nothing\n */\nexport function applyIgnoreRules(\n findings: Finding[],\n rules: IgnoreRule[]\n): { kept: Finding[]; staleIgnoreRules: IgnoreRule[] } {\n for (const rule of rules) {\n validateRule(rule);\n }\n\n const usedRules = new Set<IgnoreRule>();\n const kept = findings.filter((finding) => {\n const matchingRule = rules.find((rule) => ruleMatches(rule, finding));\n if (matchingRule) {\n usedRules.add(matchingRule);\n return false;\n }\n return true;\n });\n\n return { kept, staleIgnoreRules: rules.filter((rule) => !usedRules.has(rule)) };\n}\n\nfunction validateRule(rule: IgnoreRule): void {\n if (rule.table === undefined && rule.statement === undefined) {\n throw new Error(`pg-index-check: ignore rule needs a table or statement: ${JSON.stringify(rule)}`);\n }\n if (!rule.reason?.trim()) {\n throw new Error(`pg-index-check: ignore rule needs a reason: ${String(rule.table ?? rule.statement)}`);\n }\n}\n\nfunction ruleMatches(rule: IgnoreRule, finding: Finding): boolean {\n const tableMatches =\n rule.table === undefined ||\n (finding.table !== undefined &&\n (typeof rule.table === 'string' ? rule.table === finding.table : rule.table.test(finding.table)));\n\n const statementMatches =\n rule.statement === undefined ||\n (typeof rule.statement === 'string'\n ? finding.statement.includes(rule.statement)\n : rule.statement.test(finding.statement));\n\n return tableMatches && statementMatches;\n}\n","/**\n * Why a statement was flagged\n */\nexport const FindingKind = {\n /** A sequential scan applies a filter that no index can serve */\n SEQ_SCAN_FILTER: 'SEQ_SCAN_FILTER',\n /** A whole index is walked to apply a single-column equality filter */\n FULL_INDEX_SCAN: 'FULL_INDEX_SCAN',\n /** An index condition skips the index's leading column (PostgreSQL 16/17 only) */\n TRAILING_INDEX_COLUMN: 'TRAILING_INDEX_COLUMN',\n /** PostgreSQL could not produce a plan for the statement */\n UNPLANNABLE: 'UNPLANNABLE',\n} as const;\n\nexport type FindingKind = (typeof FindingKind)[keyof typeof FindingKind];\n\n/**\n * What to do with statements PostgreSQL cannot EXPLAIN\n */\nexport const UnplannableMode = {\n /** Treat them as findings, failing assertIndexes() */\n FAIL: 'fail',\n /** List them in the report without failing */\n REPORT: 'report',\n} as const;\n\nexport type UnplannableMode = (typeof UnplannableMode)[keyof typeof UnplannableMode];\n\nexport interface Finding {\n kind: FindingKind;\n /** Statement text as recorded by pg_stat_statements */\n statement: string;\n /** `path:line` from a pg-source-comments comment in the statement, if present */\n sourceLocation?: string;\n /** Schema-qualified table, e.g. `public.orders` */\n table?: string;\n /** Plan condition that triggered the finding, e.g. `(orders.status = $1)` */\n condition?: string;\n /** Index involved, for FULL_INDEX_SCAN and TRAILING_INDEX_COLUMN */\n indexName?: string;\n /** Suggested fix, when it can be derived from the condition */\n hint?: string;\n /** PostgreSQL error message, for UNPLANNABLE */\n error?: string;\n}\n\nexport interface IgnoreRule {\n /** Schema-qualified table (`public.orders`); a string must match exactly */\n table?: string | RegExp;\n /** Statement text; a string matches if the statement contains it */\n statement?: string | RegExp;\n /** Why this is acceptable. Required so the allowlist stays auditable. */\n reason: string;\n}\n\nexport interface CaptureOptions {\n connectionString: string;\n}\n\nexport interface IndexCheckOptions extends CaptureOptions {\n /** Findings to accept, each with a reason */\n ignore?: IgnoreRule[];\n /** @default 'fail' */\n unplannable?: UnplannableMode;\n}\n\nexport interface IndexCheckResult {\n /** Number of captured statements that were checked */\n statementCount: number;\n /** Findings that fail the check */\n findings: Finding[];\n /** Unplannable statements listed without failing (unplannable: 'report') */\n reported: Finding[];\n /** Ignore rules that matched no finding */\n staleIgnoreRules: IgnoreRule[];\n}\n\n/**\n * Subset of a PostgreSQL `EXPLAIN (FORMAT JSON, VERBOSE)` plan node used by the rules\n */\nexport interface PlanNode {\n 'Node Type': string;\n 'Relation Name'?: string;\n Schema?: string;\n 'Index Name'?: string;\n 'Index Cond'?: string;\n Filter?: string;\n Plans?: PlanNode[];\n}\n","import { FindingKind } from './types.js';\nimport type { Finding, PlanNode } from './types.js';\n\nconst INDEX_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan']);\nconst INDEX_COND_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan', 'Bitmap Index Scan']);\n\nexport interface PlanRuleContext {\n /**\n * Leading btree column per index, keyed by `schema.index`. Only needed when\n * checkTrailingIndexColumns is true.\n */\n leadingColumns: Map<string, string>;\n /**\n * Columns that lead some btree index, per table (`schema.table`). Used to\n * suppress a FULL_INDEX_SCAN when a usable index for the filtered column\n * exists — the generic planner just picked a different one.\n */\n leadingColumnsByTable: Map<string, Set<string>>;\n /** PostgreSQL 18+ can skip-scan, so trailing-column conditions are fine there */\n checkTrailingIndexColumns: boolean;\n}\n\ntype PlanFinding = Omit<Finding, 'statement' | 'sourceLocation'>;\n\n/**\n * Lists every plan node, depth first. Bitmap index scans don't name their table, so\n * they take it from the bitmap heap scan above them.\n */\nexport function flattenPlan(plan: PlanNode, bitmapRelation?: Pick<PlanNode, 'Schema' | 'Relation Name'>): PlanNode[] {\n const node = bitmapRelation && plan['Relation Name'] === undefined ? { ...plan, ...bitmapRelation } : plan;\n const childRelation =\n plan['Node Type'] === 'Bitmap Heap Scan'\n ? { Schema: plan.Schema, 'Relation Name': plan['Relation Name'] }\n : bitmapRelation;\n\n return [node, ...(plan.Plans ?? []).flatMap((child) => flattenPlan(child, childRelation))];\n}\n\n/**\n * Keys (`schema.index`) of indexes used with an index condition, for looking up\n * their leading columns\n */\nexport function indexesWithConditions(plan: PlanNode): string[] {\n return flattenPlan(plan)\n .filter((node) => INDEX_COND_SCAN_TYPES.has(node['Node Type']) && node['Index Cond'])\n .map((node) => indexKey(node));\n}\n\n/**\n * Applies the missing-index rules to a plan produced with enable_seqscan off\n */\nexport function evaluatePlan(plan: PlanNode, context: PlanRuleContext): PlanFinding[] {\n return flattenPlan(plan).flatMap((node) => {\n const finding =\n checkSeqScanFilter(node) ?? checkFullIndexScan(node, context) ?? checkTrailingIndexColumn(node, context);\n return finding ? [finding] : [];\n });\n}\n\n/**\n * Tables (`schema.table`) a plan reads, for looking up their index leading columns\n */\nexport function planTables(plan: PlanNode): string[] {\n return [...new Set(flattenPlan(plan).map(tableName).filter((name): name is string => name !== undefined))];\n}\n\n/**\n * A sequential scan that survives enable_seqscan off means no index can serve its filter\n */\nfunction checkSeqScanFilter(node: PlanNode): PlanFinding | undefined {\n const filter = node.Filter;\n if (node['Node Type'] !== 'Seq Scan' || !filter || isNegationOnly(filter)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.SEQ_SCAN_FILTER,\n table: tableName(node),\n condition: filter,\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Walking a whole index (usually to avoid a sort) while filtering on one column\n * means that column has no index. Range and IS NULL filters are skipped, since\n * pagination walks the primary key on purpose.\n */\nfunction checkFullIndexScan(node: PlanNode, context: PlanRuleContext): PlanFinding | undefined {\n const filter = node.Filter;\n if (!INDEX_SCAN_TYPES.has(node['Node Type']) || !filter || node['Index Cond']) {\n return undefined;\n }\n const column = equalityColumn(filter);\n if (column === undefined) {\n return undefined;\n }\n\n // A usable index for this equality filter already exists (its leading column\n // is the filtered one); the generic planner just chose an ordering index\n // instead. With real selectivity production uses the usable one, so this is\n // not a missing index. The guard only guarantees at least one usable index.\n const table = tableName(node);\n if (table !== undefined && context.leadingColumnsByTable.get(table)?.has(column)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.FULL_INDEX_SCAN,\n table,\n condition: filter,\n indexName: node['Index Name'],\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Before PostgreSQL 18, an equality condition that skips a btree index's leading\n * column has to scan the whole index\n */\nfunction checkTrailingIndexColumn(node: PlanNode, context: PlanRuleContext): PlanFinding | undefined {\n const indexCondition = node['Index Cond'];\n if (!context.checkTrailingIndexColumns || !INDEX_COND_SCAN_TYPES.has(node['Node Type']) || !indexCondition) {\n return undefined;\n }\n\n const leadingColumn = context.leadingColumns.get(indexKey(node));\n if (leadingColumn === undefined || !indexCondition.includes(' = ')) {\n return undefined;\n }\n if (new RegExp(`\\\\b\"?${escapeRegExp(leadingColumn)}\"?\\\\b`).test(indexCondition)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.TRAILING_INDEX_COLUMN,\n table: tableName(node),\n condition: indexCondition,\n indexName: node['Index Name'],\n hint: `condition does not use the index's leading column \"${leadingColumn}\"`,\n };\n}\n\n/**\n * Filters made only of `<>` / `NOT IN` comparisons, which a btree index cannot serve\n */\nfunction isNegationOnly(filter: string): boolean {\n return filter.includes('<>') && !/ = | < | > | <= | >= |~~| IS NULL/.test(filter);\n}\n\n/**\n * Column name when the filter is a single `column = value` comparison\n */\nfunction equalityColumn(filter: string): string | undefined {\n if (/ AND | OR /.test(filter) || filter.split(' = ').length !== 2) {\n return undefined;\n }\n const match = /^\\((?:\\w+\\.)?\"?(\\w+)\"? = /.exec(filter);\n return match?.[1];\n}\n\nfunction indexHint(node: PlanNode, filter: string): string | undefined {\n const column = equalityColumn(filter);\n return column === undefined ? undefined : `CREATE INDEX ON ${tableName(node)} (${column})`;\n}\n\nfunction tableName(node: PlanNode): string | undefined {\n const relation = node['Relation Name'];\n if (relation === undefined) {\n return undefined;\n }\n return node.Schema ? `${node.Schema}.${relation}` : relation;\n}\n\nfunction indexKey(node: PlanNode): string {\n return `${node.Schema}.${node['Index Name']}`;\n}\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n","import type { Finding, IndexCheckResult } from './types.js';\n\n/**\n * Formats a check result for humans\n */\nexport function formatReport(result: IndexCheckResult): string {\n const sections = [\n `pg-index-check: ${result.findings.length} finding(s) across ${result.statementCount} statement(s)`,\n ];\n\n if (result.findings.length > 0) {\n sections.push(result.findings.map(formatFinding).join('\\n\\n'));\n }\n if (result.reported.length > 0) {\n sections.push(`Unplannable statements (not failing):\\n\\n${result.reported.map(formatFinding).join('\\n\\n')}`);\n }\n if (result.staleIgnoreRules.length > 0) {\n const rules = result.staleIgnoreRules.map(\n (rule) => ` - ${String(rule.table ?? rule.statement)} (${rule.reason})`\n );\n sections.push(`Ignore rules that matched nothing:\\n${rules.join('\\n')}`);\n }\n\n return sections.join('\\n\\n');\n}\n\nfunction formatFinding(finding: Finding): string {\n const heading = [finding.kind, finding.table, finding.sourceLocation && `at ${finding.sourceLocation}`]\n .filter(Boolean)\n .join(' ');\n\n const details = [\n finding.condition && `condition: ${finding.condition}`,\n finding.indexName && `index: ${finding.indexName}`,\n finding.error && `error: ${finding.error}`,\n finding.hint && `fix: ${finding.hint}`,\n `statement: ${finding.statement}`,\n ].filter(Boolean);\n\n return [heading, ...details.map((line) => ` ${line}`)].join('\\n');\n}\n","import pg from 'pg';\nimport { readCapturedStatements, resetCapturedStatements } from './capture.js';\nimport {\n explainStatement,\n leadingIndexColumns,\n preparePlanningSession,\n serverMajorVersion,\n tableLeadingColumns,\n} from './explain.js';\nimport { applyIgnoreRules } from './ignore.js';\nimport { evaluatePlan, indexesWithConditions, planTables } from './plan-rules.js';\nimport { formatReport } from './report.js';\nimport { parseSourceLocation } from './statement-text.js';\nimport { FindingKind, UnplannableMode } from './types.js';\nimport type { CaptureOptions, Finding, IndexCheckOptions, IndexCheckResult, PlanNode } from './types.js';\n\n/** PostgreSQL 18 added skip scan, so conditions on trailing index columns are servable */\nconst SKIP_SCAN_MAJOR_VERSION = 18;\n/** EXPLAIN (GENERIC_PLAN) was added in PostgreSQL 16 */\nconst MINIMUM_MAJOR_VERSION = 16;\n\n/**\n * Thrown by assertIndexes() when statements have no usable index\n */\nexport class IndexCheckError extends Error {\n readonly result: IndexCheckResult;\n\n constructor(result: IndexCheckResult) {\n super(formatReport(result));\n this.name = 'IndexCheckError';\n this.result = result;\n }\n}\n\n/**\n * Clears recorded statements. Call before the test suite runs (e.g. in vitest globalSetup).\n */\nexport async function resetCapture(options: CaptureOptions): Promise<void> {\n await withClient(options.connectionString, resetCapturedStatements);\n}\n\n/**\n * Plans every statement recorded since resetCapture() with sequential scans\n * disabled, and returns those no index can serve\n */\nexport async function checkIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const unplannableMode = options.unplannable ?? UnplannableMode.FAIL;\n\n return withClient(options.connectionString, async (client) => {\n // Read before running anything else, so this check's own queries are not captured\n const statements = await readCapturedStatements(client);\n\n const majorVersion = await serverMajorVersion(client);\n if (majorVersion < MINIMUM_MAJOR_VERSION) {\n throw new Error(`pg-index-check: PostgreSQL ${MINIMUM_MAJOR_VERSION}+ is required, found ${majorVersion}`);\n }\n await preparePlanningSession(client);\n\n const allFindings: Finding[] = [];\n for (const statement of statements) {\n allFindings.push(...(await checkStatement(client, statement, majorVersion)));\n }\n\n const isReportedOnly = (finding: Finding) =>\n finding.kind === FindingKind.UNPLANNABLE && unplannableMode === UnplannableMode.REPORT;\n const { kept, staleIgnoreRules } = applyIgnoreRules(allFindings, options.ignore ?? []);\n\n return {\n statementCount: statements.length,\n findings: kept.filter((finding) => !isReportedOnly(finding)),\n reported: kept.filter(isReportedOnly),\n staleIgnoreRules,\n };\n });\n}\n\n/**\n * Like checkIndexes(), but throws an IndexCheckError listing the findings.\n * Call after the test suite runs (e.g. in vitest globalSetup's teardown).\n */\nexport async function assertIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const result = await checkIndexes(options);\n if (result.findings.length > 0) {\n throw new IndexCheckError(result);\n }\n return result;\n}\n\nasync function checkStatement(client: pg.Client, statement: string, majorVersion: number): Promise<Finding[]> {\n const sourceLocation = parseSourceLocation(statement);\n const outcome = await explainStatement(client, statement);\n\n if ('error' in outcome) {\n return [{ kind: FindingKind.UNPLANNABLE, statement, sourceLocation, error: outcome.error }];\n }\n\n const findings = await evaluate(client, outcome.plan, majorVersion);\n return findings.map((finding) => ({ ...finding, statement, sourceLocation }));\n}\n\nasync function evaluate(client: pg.Client, plan: PlanNode, majorVersion: number) {\n const checkTrailingIndexColumns = majorVersion < SKIP_SCAN_MAJOR_VERSION;\n const leadingColumns = checkTrailingIndexColumns\n ? await leadingIndexColumns(client, indexesWithConditions(plan))\n : new Map<string, string>();\n const leadingColumnsByTable = await tableLeadingColumns(client, planTables(plan));\n\n return evaluatePlan(plan, { leadingColumns, leadingColumnsByTable, checkTrailingIndexColumns });\n}\n\nasync function withClient<T>(connectionString: string, work: (client: pg.Client) => Promise<T>): Promise<T> {\n // A dedicated connection, so enable_seqscan never leaks into the application's sessions\n const client = new pg.Client({ connectionString });\n await client.connect();\n try {\n return await work(client);\n } finally {\n await client.end();\n }\n}\n"]}
package/dist/index.js CHANGED
@@ -102,6 +102,31 @@ async function leadingIndexColumns(client, indexKeys) {
102
102
  );
103
103
  return new Map(result.rows.map((row) => [row.index_key, row.column_name]));
104
104
  }
105
+ async function tableLeadingColumns(client, tables) {
106
+ if (tables.length === 0) {
107
+ return /* @__PURE__ */ new Map();
108
+ }
109
+ const result = await client.query(
110
+ `
111
+ SELECT n.nspname || '.' || t.relname AS table_key, a.attname AS column_name
112
+ FROM pg_catalog.pg_index i
113
+ JOIN pg_catalog.pg_class t ON t.oid = i.indrelid
114
+ JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid
115
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
116
+ JOIN pg_catalog.pg_am am ON am.oid = c.relam
117
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
118
+ WHERE am.amname = 'btree' AND i.indisvalid AND n.nspname || '.' || t.relname = ANY($1)
119
+ `,
120
+ [tables]
121
+ );
122
+ const byTable = /* @__PURE__ */ new Map();
123
+ for (const row of result.rows) {
124
+ const columns = byTable.get(row.table_key) ?? /* @__PURE__ */ new Set();
125
+ columns.add(row.column_name);
126
+ byTable.set(row.table_key, columns);
127
+ }
128
+ return byTable;
129
+ }
105
130
 
106
131
  // src/ignore.ts
107
132
  function applyIgnoreRules(findings, rules) {
@@ -164,10 +189,13 @@ function indexesWithConditions(plan) {
164
189
  }
165
190
  function evaluatePlan(plan, context) {
166
191
  return flattenPlan(plan).flatMap((node) => {
167
- const finding = checkSeqScanFilter(node) ?? checkFullIndexScan(node) ?? checkTrailingIndexColumn(node, context);
192
+ const finding = checkSeqScanFilter(node) ?? checkFullIndexScan(node, context) ?? checkTrailingIndexColumn(node, context);
168
193
  return finding ? [finding] : [];
169
194
  });
170
195
  }
196
+ function planTables(plan) {
197
+ return [...new Set(flattenPlan(plan).map(tableName).filter((name) => name !== void 0))];
198
+ }
171
199
  function checkSeqScanFilter(node) {
172
200
  const filter = node.Filter;
173
201
  if (node["Node Type"] !== "Seq Scan" || !filter || isNegationOnly(filter)) {
@@ -180,17 +208,22 @@ function checkSeqScanFilter(node) {
180
208
  hint: indexHint(node, filter)
181
209
  };
182
210
  }
183
- function checkFullIndexScan(node) {
211
+ function checkFullIndexScan(node, context) {
184
212
  const filter = node.Filter;
185
213
  if (!INDEX_SCAN_TYPES.has(node["Node Type"]) || !filter || node["Index Cond"]) {
186
214
  return void 0;
187
215
  }
188
- if (equalityColumn(filter) === void 0) {
216
+ const column = equalityColumn(filter);
217
+ if (column === void 0) {
218
+ return void 0;
219
+ }
220
+ const table = tableName(node);
221
+ if (table !== void 0 && context.leadingColumnsByTable.get(table)?.has(column)) {
189
222
  return void 0;
190
223
  }
191
224
  return {
192
225
  kind: FindingKind.FULL_INDEX_SCAN,
193
- table: tableName(node),
226
+ table,
194
227
  condition: filter,
195
228
  indexName: node["Index Name"],
196
229
  hint: indexHint(node, filter)
@@ -334,7 +367,8 @@ async function checkStatement(client, statement, majorVersion) {
334
367
  async function evaluate(client, plan, majorVersion) {
335
368
  const checkTrailingIndexColumns = majorVersion < SKIP_SCAN_MAJOR_VERSION;
336
369
  const leadingColumns = checkTrailingIndexColumns ? await leadingIndexColumns(client, indexesWithConditions(plan)) : /* @__PURE__ */ new Map();
337
- return evaluatePlan(plan, { leadingColumns, checkTrailingIndexColumns });
370
+ const leadingColumnsByTable = await tableLeadingColumns(client, planTables(plan));
371
+ return evaluatePlan(plan, { leadingColumns, leadingColumnsByTable, checkTrailingIndexColumns });
338
372
  }
339
373
  async function withClient(connectionString, work) {
340
374
  const client = new pg.Client({ connectionString });
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/statement-text.ts","../src/capture.ts","../src/explain.ts","../src/ignore.ts","../src/types.ts","../src/plan-rules.ts","../src/report.ts","../src/check.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["const CHECKED_KEYWORDS = new Set(['SELECT', 'WITH', 'UPDATE', 'DELETE']);\n\n/**\n * Returns the first keyword of a statement (upper-cased), skipping leading\n * whitespace, comments and opening parentheses\n */\nexport function firstKeyword(statement: string): string | undefined {\n let index = 0;\n\n while (index < statement.length) {\n if (/\\s/.test(statement[index]) || statement[index] === '(') {\n index += 1;\n } else if (statement.startsWith('/*', index)) {\n const commentEnd = statement.indexOf('*/', index + 2);\n if (commentEnd === -1) {\n return undefined;\n }\n index = commentEnd + 2;\n } else if (statement.startsWith('--', index)) {\n const lineEnd = statement.indexOf('\\n', index + 2);\n if (lineEnd === -1) {\n return undefined;\n }\n index = lineEnd + 1;\n } else {\n const wordMatch = /^[A-Za-z_]+/.exec(statement.slice(index));\n return wordMatch?.[0].toUpperCase();\n }\n }\n\n return undefined;\n}\n\n/**\n * Whether a captured statement is one whose plan should be checked: a query or\n * data-modifying statement that reads rows, and not a catalog/monitoring query\n */\nexport function isCheckableStatement(statement: string): boolean {\n const keyword = firstKeyword(statement);\n if (keyword === undefined || !CHECKED_KEYWORDS.has(keyword)) {\n return false;\n }\n\n return !/pg_stat_statements|pg_catalog\\.|information_schema\\./i.test(statement);\n}\n\n/**\n * Extracts the `path:line` location from a pg-source-comments comment,\n * e.g. `SELECT /* src/api/users.ts:42 *\\/ ...` gives `src/api/users.ts:42`\n */\nexport function parseSourceLocation(statement: string): string | undefined {\n const match = /\\/\\*\\s*(\\S+:\\d+(?::\\d+)?)(?:\\s+in\\s+\\S+)?\\s*\\*\\//.exec(statement);\n return match?.[1];\n}\n","import type { Client } from 'pg';\nimport { isCheckableStatement } from './statement-text.js';\n\nconst SETUP_HELP =\n 'Add pg_stat_statements to shared_preload_libraries (e.g. `postgres -c shared_preload_libraries=pg_stat_statements`) ' +\n 'and call resetCapture() before the test suite runs.';\n\n/**\n * Installs pg_stat_statements in the current database (if needed) and clears it,\n * so only statements run after this point are checked\n */\nexport async function resetCapturedStatements(client: Client): Promise<void> {\n await client.query('CREATE EXTENSION IF NOT EXISTS pg_stat_statements');\n await client.query('SELECT pg_stat_statements_reset()');\n}\n\n/**\n * Reads the distinct statements recorded for the current database since the last reset\n */\nexport async function readCapturedStatements(client: Client): Promise<string[]> {\n const installed = await client.query(\n \"SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'\"\n );\n if (installed.rowCount === 0) {\n throw new Error(`pg-index-check: the pg_stat_statements extension is not installed. ${SETUP_HELP}`);\n }\n\n const captured = await client.query<{ query: string }>(`\n SELECT s.query\n FROM pg_stat_statements s\n JOIN pg_catalog.pg_database d ON d.oid = s.dbid\n WHERE d.datname = current_database() AND s.toplevel\n `);\n\n const statements = captured.rows.map((row) => row.query).filter(isCheckableStatement);\n if (statements.length === 0) {\n throw new Error(`pg-index-check: no statements were captured in this database. ${SETUP_HELP}`);\n }\n\n return statements;\n}\n","import type { Client } from 'pg';\nimport type { PlanNode } from './types.js';\n\nexport type ExplainOutcome = { plan: PlanNode } | { error: string };\n\n/**\n * Makes the planner treat sequential scans as a last resort for this session,\n * so any sequential scan left in a plan is one no index can replace\n */\nexport async function preparePlanningSession(client: Client): Promise<void> {\n await client.query('SET enable_seqscan = off');\n}\n\n/**\n * Plans a statement with placeholders ($1, ...) left in place. Sent without bound\n * values (simple query protocol), which EXPLAIN (GENERIC_PLAN) requires.\n */\nexport async function explainStatement(client: Client, statement: string): Promise<ExplainOutcome> {\n try {\n const result = await client.query<{ 'QUERY PLAN': [{ Plan: PlanNode }] }>(\n `EXPLAIN (GENERIC_PLAN, VERBOSE, FORMAT JSON) ${statement}`\n );\n return { plan: result.rows[0]['QUERY PLAN'][0].Plan };\n } catch (error) {\n // Recorded as an UNPLANNABLE finding, which fails by default\n return { error: error instanceof Error ? error.message : String(error) };\n }\n}\n\n/**\n * Server major version, e.g. 16\n */\nexport async function serverMajorVersion(client: Client): Promise<number> {\n const result = await client.query<{ server_version_num: string }>('SHOW server_version_num');\n return Math.floor(Number(result.rows[0].server_version_num) / 10000);\n}\n\n/**\n * Leading column of each btree index, keyed by `schema.index`. Expression indexes\n * have no leading column and are omitted.\n */\nexport async function leadingIndexColumns(client: Client, indexKeys: string[]): Promise<Map<string, string>> {\n if (indexKeys.length === 0) {\n return new Map();\n }\n\n const result = await client.query<{ index_key: string; column_name: string }>(\n `\n SELECT n.nspname || '.' || c.relname AS index_key, a.attname AS column_name\n FROM pg_catalog.pg_index i\n JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid\n JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace\n JOIN pg_catalog.pg_am am ON am.oid = c.relam\n JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]\n WHERE am.amname = 'btree' AND n.nspname || '.' || c.relname = ANY($1)\n `,\n [indexKeys]\n );\n\n return new Map(result.rows.map((row) => [row.index_key, row.column_name]));\n}\n","import type { Finding, IgnoreRule } from './types.js';\n\n/**\n * Removes findings matched by an ignore rule and reports rules that matched nothing\n */\nexport function applyIgnoreRules(\n findings: Finding[],\n rules: IgnoreRule[]\n): { kept: Finding[]; staleIgnoreRules: IgnoreRule[] } {\n for (const rule of rules) {\n validateRule(rule);\n }\n\n const usedRules = new Set<IgnoreRule>();\n const kept = findings.filter((finding) => {\n const matchingRule = rules.find((rule) => ruleMatches(rule, finding));\n if (matchingRule) {\n usedRules.add(matchingRule);\n return false;\n }\n return true;\n });\n\n return { kept, staleIgnoreRules: rules.filter((rule) => !usedRules.has(rule)) };\n}\n\nfunction validateRule(rule: IgnoreRule): void {\n if (rule.table === undefined && rule.statement === undefined) {\n throw new Error(`pg-index-check: ignore rule needs a table or statement: ${JSON.stringify(rule)}`);\n }\n if (!rule.reason?.trim()) {\n throw new Error(`pg-index-check: ignore rule needs a reason: ${String(rule.table ?? rule.statement)}`);\n }\n}\n\nfunction ruleMatches(rule: IgnoreRule, finding: Finding): boolean {\n const tableMatches =\n rule.table === undefined ||\n (finding.table !== undefined &&\n (typeof rule.table === 'string' ? rule.table === finding.table : rule.table.test(finding.table)));\n\n const statementMatches =\n rule.statement === undefined ||\n (typeof rule.statement === 'string'\n ? finding.statement.includes(rule.statement)\n : rule.statement.test(finding.statement));\n\n return tableMatches && statementMatches;\n}\n","/**\n * Why a statement was flagged\n */\nexport const FindingKind = {\n /** A sequential scan applies a filter that no index can serve */\n SEQ_SCAN_FILTER: 'SEQ_SCAN_FILTER',\n /** A whole index is walked to apply a single-column equality filter */\n FULL_INDEX_SCAN: 'FULL_INDEX_SCAN',\n /** An index condition skips the index's leading column (PostgreSQL 16/17 only) */\n TRAILING_INDEX_COLUMN: 'TRAILING_INDEX_COLUMN',\n /** PostgreSQL could not produce a plan for the statement */\n UNPLANNABLE: 'UNPLANNABLE',\n} as const;\n\nexport type FindingKind = (typeof FindingKind)[keyof typeof FindingKind];\n\n/**\n * What to do with statements PostgreSQL cannot EXPLAIN\n */\nexport const UnplannableMode = {\n /** Treat them as findings, failing assertIndexes() */\n FAIL: 'fail',\n /** List them in the report without failing */\n REPORT: 'report',\n} as const;\n\nexport type UnplannableMode = (typeof UnplannableMode)[keyof typeof UnplannableMode];\n\nexport interface Finding {\n kind: FindingKind;\n /** Statement text as recorded by pg_stat_statements */\n statement: string;\n /** `path:line` from a pg-source-comments comment in the statement, if present */\n sourceLocation?: string;\n /** Schema-qualified table, e.g. `public.orders` */\n table?: string;\n /** Plan condition that triggered the finding, e.g. `(orders.status = $1)` */\n condition?: string;\n /** Index involved, for FULL_INDEX_SCAN and TRAILING_INDEX_COLUMN */\n indexName?: string;\n /** Suggested fix, when it can be derived from the condition */\n hint?: string;\n /** PostgreSQL error message, for UNPLANNABLE */\n error?: string;\n}\n\nexport interface IgnoreRule {\n /** Schema-qualified table (`public.orders`); a string must match exactly */\n table?: string | RegExp;\n /** Statement text; a string matches if the statement contains it */\n statement?: string | RegExp;\n /** Why this is acceptable. Required so the allowlist stays auditable. */\n reason: string;\n}\n\nexport interface CaptureOptions {\n connectionString: string;\n}\n\nexport interface IndexCheckOptions extends CaptureOptions {\n /** Findings to accept, each with a reason */\n ignore?: IgnoreRule[];\n /** @default 'fail' */\n unplannable?: UnplannableMode;\n}\n\nexport interface IndexCheckResult {\n /** Number of captured statements that were checked */\n statementCount: number;\n /** Findings that fail the check */\n findings: Finding[];\n /** Unplannable statements listed without failing (unplannable: 'report') */\n reported: Finding[];\n /** Ignore rules that matched no finding */\n staleIgnoreRules: IgnoreRule[];\n}\n\n/**\n * Subset of a PostgreSQL `EXPLAIN (FORMAT JSON, VERBOSE)` plan node used by the rules\n */\nexport interface PlanNode {\n 'Node Type': string;\n 'Relation Name'?: string;\n Schema?: string;\n 'Index Name'?: string;\n 'Index Cond'?: string;\n Filter?: string;\n Plans?: PlanNode[];\n}\n","import { FindingKind } from './types.js';\nimport type { Finding, PlanNode } from './types.js';\n\nconst INDEX_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan']);\nconst INDEX_COND_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan', 'Bitmap Index Scan']);\n\nexport interface PlanRuleContext {\n /**\n * Leading btree column per index, keyed by `schema.index`. Only needed when\n * checkTrailingIndexColumns is true.\n */\n leadingColumns: Map<string, string>;\n /** PostgreSQL 18+ can skip-scan, so trailing-column conditions are fine there */\n checkTrailingIndexColumns: boolean;\n}\n\ntype PlanFinding = Omit<Finding, 'statement' | 'sourceLocation'>;\n\n/**\n * Lists every plan node, depth first. Bitmap index scans don't name their table, so\n * they take it from the bitmap heap scan above them.\n */\nexport function flattenPlan(plan: PlanNode, bitmapRelation?: Pick<PlanNode, 'Schema' | 'Relation Name'>): PlanNode[] {\n const node = bitmapRelation && plan['Relation Name'] === undefined ? { ...plan, ...bitmapRelation } : plan;\n const childRelation =\n plan['Node Type'] === 'Bitmap Heap Scan'\n ? { Schema: plan.Schema, 'Relation Name': plan['Relation Name'] }\n : bitmapRelation;\n\n return [node, ...(plan.Plans ?? []).flatMap((child) => flattenPlan(child, childRelation))];\n}\n\n/**\n * Keys (`schema.index`) of indexes used with an index condition, for looking up\n * their leading columns\n */\nexport function indexesWithConditions(plan: PlanNode): string[] {\n return flattenPlan(plan)\n .filter((node) => INDEX_COND_SCAN_TYPES.has(node['Node Type']) && node['Index Cond'])\n .map((node) => indexKey(node));\n}\n\n/**\n * Applies the missing-index rules to a plan produced with enable_seqscan off\n */\nexport function evaluatePlan(plan: PlanNode, context: PlanRuleContext): PlanFinding[] {\n return flattenPlan(plan).flatMap((node) => {\n const finding =\n checkSeqScanFilter(node) ?? checkFullIndexScan(node) ?? checkTrailingIndexColumn(node, context);\n return finding ? [finding] : [];\n });\n}\n\n/**\n * A sequential scan that survives enable_seqscan off means no index can serve its filter\n */\nfunction checkSeqScanFilter(node: PlanNode): PlanFinding | undefined {\n const filter = node.Filter;\n if (node['Node Type'] !== 'Seq Scan' || !filter || isNegationOnly(filter)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.SEQ_SCAN_FILTER,\n table: tableName(node),\n condition: filter,\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Walking a whole index (usually to avoid a sort) while filtering on one column\n * means that column has no index. Range and IS NULL filters are skipped, since\n * pagination walks the primary key on purpose.\n */\nfunction checkFullIndexScan(node: PlanNode): PlanFinding | undefined {\n const filter = node.Filter;\n if (!INDEX_SCAN_TYPES.has(node['Node Type']) || !filter || node['Index Cond']) {\n return undefined;\n }\n if (equalityColumn(filter) === undefined) {\n return undefined;\n }\n\n return {\n kind: FindingKind.FULL_INDEX_SCAN,\n table: tableName(node),\n condition: filter,\n indexName: node['Index Name'],\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Before PostgreSQL 18, an equality condition that skips a btree index's leading\n * column has to scan the whole index\n */\nfunction checkTrailingIndexColumn(node: PlanNode, context: PlanRuleContext): PlanFinding | undefined {\n const indexCondition = node['Index Cond'];\n if (!context.checkTrailingIndexColumns || !INDEX_COND_SCAN_TYPES.has(node['Node Type']) || !indexCondition) {\n return undefined;\n }\n\n const leadingColumn = context.leadingColumns.get(indexKey(node));\n if (leadingColumn === undefined || !indexCondition.includes(' = ')) {\n return undefined;\n }\n if (new RegExp(`\\\\b\"?${escapeRegExp(leadingColumn)}\"?\\\\b`).test(indexCondition)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.TRAILING_INDEX_COLUMN,\n table: tableName(node),\n condition: indexCondition,\n indexName: node['Index Name'],\n hint: `condition does not use the index's leading column \"${leadingColumn}\"`,\n };\n}\n\n/**\n * Filters made only of `<>` / `NOT IN` comparisons, which a btree index cannot serve\n */\nfunction isNegationOnly(filter: string): boolean {\n return filter.includes('<>') && !/ = | < | > | <= | >= |~~| IS NULL/.test(filter);\n}\n\n/**\n * Column name when the filter is a single `column = value` comparison\n */\nfunction equalityColumn(filter: string): string | undefined {\n if (/ AND | OR /.test(filter) || filter.split(' = ').length !== 2) {\n return undefined;\n }\n const match = /^\\((?:\\w+\\.)?\"?(\\w+)\"? = /.exec(filter);\n return match?.[1];\n}\n\nfunction indexHint(node: PlanNode, filter: string): string | undefined {\n const column = equalityColumn(filter);\n return column === undefined ? undefined : `CREATE INDEX ON ${tableName(node)} (${column})`;\n}\n\nfunction tableName(node: PlanNode): string | undefined {\n const relation = node['Relation Name'];\n if (relation === undefined) {\n return undefined;\n }\n return node.Schema ? `${node.Schema}.${relation}` : relation;\n}\n\nfunction indexKey(node: PlanNode): string {\n return `${node.Schema}.${node['Index Name']}`;\n}\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n","import type { Finding, IndexCheckResult } from './types.js';\n\n/**\n * Formats a check result for humans\n */\nexport function formatReport(result: IndexCheckResult): string {\n const sections = [\n `pg-index-check: ${result.findings.length} finding(s) across ${result.statementCount} statement(s)`,\n ];\n\n if (result.findings.length > 0) {\n sections.push(result.findings.map(formatFinding).join('\\n\\n'));\n }\n if (result.reported.length > 0) {\n sections.push(`Unplannable statements (not failing):\\n\\n${result.reported.map(formatFinding).join('\\n\\n')}`);\n }\n if (result.staleIgnoreRules.length > 0) {\n const rules = result.staleIgnoreRules.map(\n (rule) => ` - ${String(rule.table ?? rule.statement)} (${rule.reason})`\n );\n sections.push(`Ignore rules that matched nothing:\\n${rules.join('\\n')}`);\n }\n\n return sections.join('\\n\\n');\n}\n\nfunction formatFinding(finding: Finding): string {\n const heading = [finding.kind, finding.table, finding.sourceLocation && `at ${finding.sourceLocation}`]\n .filter(Boolean)\n .join(' ');\n\n const details = [\n finding.condition && `condition: ${finding.condition}`,\n finding.indexName && `index: ${finding.indexName}`,\n finding.error && `error: ${finding.error}`,\n finding.hint && `fix: ${finding.hint}`,\n `statement: ${finding.statement}`,\n ].filter(Boolean);\n\n return [heading, ...details.map((line) => ` ${line}`)].join('\\n');\n}\n","import pg from 'pg';\nimport { readCapturedStatements, resetCapturedStatements } from './capture.js';\nimport {\n explainStatement,\n leadingIndexColumns,\n preparePlanningSession,\n serverMajorVersion,\n} from './explain.js';\nimport { applyIgnoreRules } from './ignore.js';\nimport { evaluatePlan, indexesWithConditions } from './plan-rules.js';\nimport { formatReport } from './report.js';\nimport { parseSourceLocation } from './statement-text.js';\nimport { FindingKind, UnplannableMode } from './types.js';\nimport type { CaptureOptions, Finding, IndexCheckOptions, IndexCheckResult, PlanNode } from './types.js';\n\n/** PostgreSQL 18 added skip scan, so conditions on trailing index columns are servable */\nconst SKIP_SCAN_MAJOR_VERSION = 18;\n/** EXPLAIN (GENERIC_PLAN) was added in PostgreSQL 16 */\nconst MINIMUM_MAJOR_VERSION = 16;\n\n/**\n * Thrown by assertIndexes() when statements have no usable index\n */\nexport class IndexCheckError extends Error {\n readonly result: IndexCheckResult;\n\n constructor(result: IndexCheckResult) {\n super(formatReport(result));\n this.name = 'IndexCheckError';\n this.result = result;\n }\n}\n\n/**\n * Clears recorded statements. Call before the test suite runs (e.g. in vitest globalSetup).\n */\nexport async function resetCapture(options: CaptureOptions): Promise<void> {\n await withClient(options.connectionString, resetCapturedStatements);\n}\n\n/**\n * Plans every statement recorded since resetCapture() with sequential scans\n * disabled, and returns those no index can serve\n */\nexport async function checkIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const unplannableMode = options.unplannable ?? UnplannableMode.FAIL;\n\n return withClient(options.connectionString, async (client) => {\n // Read before running anything else, so this check's own queries are not captured\n const statements = await readCapturedStatements(client);\n\n const majorVersion = await serverMajorVersion(client);\n if (majorVersion < MINIMUM_MAJOR_VERSION) {\n throw new Error(`pg-index-check: PostgreSQL ${MINIMUM_MAJOR_VERSION}+ is required, found ${majorVersion}`);\n }\n await preparePlanningSession(client);\n\n const allFindings: Finding[] = [];\n for (const statement of statements) {\n allFindings.push(...(await checkStatement(client, statement, majorVersion)));\n }\n\n const isReportedOnly = (finding: Finding) =>\n finding.kind === FindingKind.UNPLANNABLE && unplannableMode === UnplannableMode.REPORT;\n const { kept, staleIgnoreRules } = applyIgnoreRules(allFindings, options.ignore ?? []);\n\n return {\n statementCount: statements.length,\n findings: kept.filter((finding) => !isReportedOnly(finding)),\n reported: kept.filter(isReportedOnly),\n staleIgnoreRules,\n };\n });\n}\n\n/**\n * Like checkIndexes(), but throws an IndexCheckError listing the findings.\n * Call after the test suite runs (e.g. in vitest globalSetup's teardown).\n */\nexport async function assertIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const result = await checkIndexes(options);\n if (result.findings.length > 0) {\n throw new IndexCheckError(result);\n }\n return result;\n}\n\nasync function checkStatement(client: pg.Client, statement: string, majorVersion: number): Promise<Finding[]> {\n const sourceLocation = parseSourceLocation(statement);\n const outcome = await explainStatement(client, statement);\n\n if ('error' in outcome) {\n return [{ kind: FindingKind.UNPLANNABLE, statement, sourceLocation, error: outcome.error }];\n }\n\n const findings = await evaluate(client, outcome.plan, majorVersion);\n return findings.map((finding) => ({ ...finding, statement, sourceLocation }));\n}\n\nasync function evaluate(client: pg.Client, plan: PlanNode, majorVersion: number) {\n const checkTrailingIndexColumns = majorVersion < SKIP_SCAN_MAJOR_VERSION;\n const leadingColumns = checkTrailingIndexColumns\n ? await leadingIndexColumns(client, indexesWithConditions(plan))\n : new Map<string, string>();\n\n return evaluatePlan(plan, { leadingColumns, checkTrailingIndexColumns });\n}\n\nasync function withClient<T>(connectionString: string, work: (client: pg.Client) => Promise<T>): Promise<T> {\n // A dedicated connection, so enable_seqscan never leaks into the application's sessions\n const client = new pg.Client({ connectionString });\n await client.connect();\n try {\n return await work(client);\n } finally {\n await client.end();\n }\n}\n"]}
1
+ {"version":3,"sources":["../src/statement-text.ts","../src/capture.ts","../src/explain.ts","../src/ignore.ts","../src/types.ts","../src/plan-rules.ts","../src/report.ts","../src/check.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["const CHECKED_KEYWORDS = new Set(['SELECT', 'WITH', 'UPDATE', 'DELETE']);\n\n/**\n * Returns the first keyword of a statement (upper-cased), skipping leading\n * whitespace, comments and opening parentheses\n */\nexport function firstKeyword(statement: string): string | undefined {\n let index = 0;\n\n while (index < statement.length) {\n if (/\\s/.test(statement[index]) || statement[index] === '(') {\n index += 1;\n } else if (statement.startsWith('/*', index)) {\n const commentEnd = statement.indexOf('*/', index + 2);\n if (commentEnd === -1) {\n return undefined;\n }\n index = commentEnd + 2;\n } else if (statement.startsWith('--', index)) {\n const lineEnd = statement.indexOf('\\n', index + 2);\n if (lineEnd === -1) {\n return undefined;\n }\n index = lineEnd + 1;\n } else {\n const wordMatch = /^[A-Za-z_]+/.exec(statement.slice(index));\n return wordMatch?.[0].toUpperCase();\n }\n }\n\n return undefined;\n}\n\n/**\n * Whether a captured statement is one whose plan should be checked: a query or\n * data-modifying statement that reads rows, and not a catalog/monitoring query\n */\nexport function isCheckableStatement(statement: string): boolean {\n const keyword = firstKeyword(statement);\n if (keyword === undefined || !CHECKED_KEYWORDS.has(keyword)) {\n return false;\n }\n\n return !/pg_stat_statements|pg_catalog\\.|information_schema\\./i.test(statement);\n}\n\n/**\n * Extracts the `path:line` location from a pg-source-comments comment,\n * e.g. `SELECT /* src/api/users.ts:42 *\\/ ...` gives `src/api/users.ts:42`\n */\nexport function parseSourceLocation(statement: string): string | undefined {\n const match = /\\/\\*\\s*(\\S+:\\d+(?::\\d+)?)(?:\\s+in\\s+\\S+)?\\s*\\*\\//.exec(statement);\n return match?.[1];\n}\n","import type { Client } from 'pg';\nimport { isCheckableStatement } from './statement-text.js';\n\nconst SETUP_HELP =\n 'Add pg_stat_statements to shared_preload_libraries (e.g. `postgres -c shared_preload_libraries=pg_stat_statements`) ' +\n 'and call resetCapture() before the test suite runs.';\n\n/**\n * Installs pg_stat_statements in the current database (if needed) and clears it,\n * so only statements run after this point are checked\n */\nexport async function resetCapturedStatements(client: Client): Promise<void> {\n await client.query('CREATE EXTENSION IF NOT EXISTS pg_stat_statements');\n await client.query('SELECT pg_stat_statements_reset()');\n}\n\n/**\n * Reads the distinct statements recorded for the current database since the last reset\n */\nexport async function readCapturedStatements(client: Client): Promise<string[]> {\n const installed = await client.query(\n \"SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'\"\n );\n if (installed.rowCount === 0) {\n throw new Error(`pg-index-check: the pg_stat_statements extension is not installed. ${SETUP_HELP}`);\n }\n\n const captured = await client.query<{ query: string }>(`\n SELECT s.query\n FROM pg_stat_statements s\n JOIN pg_catalog.pg_database d ON d.oid = s.dbid\n WHERE d.datname = current_database() AND s.toplevel\n `);\n\n const statements = captured.rows.map((row) => row.query).filter(isCheckableStatement);\n if (statements.length === 0) {\n throw new Error(`pg-index-check: no statements were captured in this database. ${SETUP_HELP}`);\n }\n\n return statements;\n}\n","import type { Client } from 'pg';\nimport type { PlanNode } from './types.js';\n\nexport type ExplainOutcome = { plan: PlanNode } | { error: string };\n\n/**\n * Makes the planner treat sequential scans as a last resort for this session,\n * so any sequential scan left in a plan is one no index can replace\n */\nexport async function preparePlanningSession(client: Client): Promise<void> {\n await client.query('SET enable_seqscan = off');\n}\n\n/**\n * Plans a statement with placeholders ($1, ...) left in place. Sent without bound\n * values (simple query protocol), which EXPLAIN (GENERIC_PLAN) requires.\n */\nexport async function explainStatement(client: Client, statement: string): Promise<ExplainOutcome> {\n try {\n const result = await client.query<{ 'QUERY PLAN': [{ Plan: PlanNode }] }>(\n `EXPLAIN (GENERIC_PLAN, VERBOSE, FORMAT JSON) ${statement}`\n );\n return { plan: result.rows[0]['QUERY PLAN'][0].Plan };\n } catch (error) {\n // Recorded as an UNPLANNABLE finding, which fails by default\n return { error: error instanceof Error ? error.message : String(error) };\n }\n}\n\n/**\n * Server major version, e.g. 16\n */\nexport async function serverMajorVersion(client: Client): Promise<number> {\n const result = await client.query<{ server_version_num: string }>('SHOW server_version_num');\n return Math.floor(Number(result.rows[0].server_version_num) / 10000);\n}\n\n/**\n * Leading column of each btree index, keyed by `schema.index`. Expression indexes\n * have no leading column and are omitted.\n */\nexport async function leadingIndexColumns(client: Client, indexKeys: string[]): Promise<Map<string, string>> {\n if (indexKeys.length === 0) {\n return new Map();\n }\n\n const result = await client.query<{ index_key: string; column_name: string }>(\n `\n SELECT n.nspname || '.' || c.relname AS index_key, a.attname AS column_name\n FROM pg_catalog.pg_index i\n JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid\n JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace\n JOIN pg_catalog.pg_am am ON am.oid = c.relam\n JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]\n WHERE am.amname = 'btree' AND n.nspname || '.' || c.relname = ANY($1)\n `,\n [indexKeys]\n );\n\n return new Map(result.rows.map((row) => [row.index_key, row.column_name]));\n}\n\n/**\n * Columns that lead some valid btree index, per table, keyed by `schema.table`.\n * A query filtering such a column by equality has a usable index even if the\n * generic planner chose a different one, so it is not a missing-index finding.\n */\nexport async function tableLeadingColumns(client: Client, tables: string[]): Promise<Map<string, Set<string>>> {\n if (tables.length === 0) {\n return new Map();\n }\n\n const result = await client.query<{ table_key: string; column_name: string }>(\n `\n SELECT n.nspname || '.' || t.relname AS table_key, a.attname AS column_name\n FROM pg_catalog.pg_index i\n JOIN pg_catalog.pg_class t ON t.oid = i.indrelid\n JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid\n JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace\n JOIN pg_catalog.pg_am am ON am.oid = c.relam\n JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]\n WHERE am.amname = 'btree' AND i.indisvalid AND n.nspname || '.' || t.relname = ANY($1)\n `,\n [tables]\n );\n\n const byTable = new Map<string, Set<string>>();\n for (const row of result.rows) {\n const columns = byTable.get(row.table_key) ?? new Set<string>();\n columns.add(row.column_name);\n byTable.set(row.table_key, columns);\n }\n return byTable;\n}\n","import type { Finding, IgnoreRule } from './types.js';\n\n/**\n * Removes findings matched by an ignore rule and reports rules that matched nothing\n */\nexport function applyIgnoreRules(\n findings: Finding[],\n rules: IgnoreRule[]\n): { kept: Finding[]; staleIgnoreRules: IgnoreRule[] } {\n for (const rule of rules) {\n validateRule(rule);\n }\n\n const usedRules = new Set<IgnoreRule>();\n const kept = findings.filter((finding) => {\n const matchingRule = rules.find((rule) => ruleMatches(rule, finding));\n if (matchingRule) {\n usedRules.add(matchingRule);\n return false;\n }\n return true;\n });\n\n return { kept, staleIgnoreRules: rules.filter((rule) => !usedRules.has(rule)) };\n}\n\nfunction validateRule(rule: IgnoreRule): void {\n if (rule.table === undefined && rule.statement === undefined) {\n throw new Error(`pg-index-check: ignore rule needs a table or statement: ${JSON.stringify(rule)}`);\n }\n if (!rule.reason?.trim()) {\n throw new Error(`pg-index-check: ignore rule needs a reason: ${String(rule.table ?? rule.statement)}`);\n }\n}\n\nfunction ruleMatches(rule: IgnoreRule, finding: Finding): boolean {\n const tableMatches =\n rule.table === undefined ||\n (finding.table !== undefined &&\n (typeof rule.table === 'string' ? rule.table === finding.table : rule.table.test(finding.table)));\n\n const statementMatches =\n rule.statement === undefined ||\n (typeof rule.statement === 'string'\n ? finding.statement.includes(rule.statement)\n : rule.statement.test(finding.statement));\n\n return tableMatches && statementMatches;\n}\n","/**\n * Why a statement was flagged\n */\nexport const FindingKind = {\n /** A sequential scan applies a filter that no index can serve */\n SEQ_SCAN_FILTER: 'SEQ_SCAN_FILTER',\n /** A whole index is walked to apply a single-column equality filter */\n FULL_INDEX_SCAN: 'FULL_INDEX_SCAN',\n /** An index condition skips the index's leading column (PostgreSQL 16/17 only) */\n TRAILING_INDEX_COLUMN: 'TRAILING_INDEX_COLUMN',\n /** PostgreSQL could not produce a plan for the statement */\n UNPLANNABLE: 'UNPLANNABLE',\n} as const;\n\nexport type FindingKind = (typeof FindingKind)[keyof typeof FindingKind];\n\n/**\n * What to do with statements PostgreSQL cannot EXPLAIN\n */\nexport const UnplannableMode = {\n /** Treat them as findings, failing assertIndexes() */\n FAIL: 'fail',\n /** List them in the report without failing */\n REPORT: 'report',\n} as const;\n\nexport type UnplannableMode = (typeof UnplannableMode)[keyof typeof UnplannableMode];\n\nexport interface Finding {\n kind: FindingKind;\n /** Statement text as recorded by pg_stat_statements */\n statement: string;\n /** `path:line` from a pg-source-comments comment in the statement, if present */\n sourceLocation?: string;\n /** Schema-qualified table, e.g. `public.orders` */\n table?: string;\n /** Plan condition that triggered the finding, e.g. `(orders.status = $1)` */\n condition?: string;\n /** Index involved, for FULL_INDEX_SCAN and TRAILING_INDEX_COLUMN */\n indexName?: string;\n /** Suggested fix, when it can be derived from the condition */\n hint?: string;\n /** PostgreSQL error message, for UNPLANNABLE */\n error?: string;\n}\n\nexport interface IgnoreRule {\n /** Schema-qualified table (`public.orders`); a string must match exactly */\n table?: string | RegExp;\n /** Statement text; a string matches if the statement contains it */\n statement?: string | RegExp;\n /** Why this is acceptable. Required so the allowlist stays auditable. */\n reason: string;\n}\n\nexport interface CaptureOptions {\n connectionString: string;\n}\n\nexport interface IndexCheckOptions extends CaptureOptions {\n /** Findings to accept, each with a reason */\n ignore?: IgnoreRule[];\n /** @default 'fail' */\n unplannable?: UnplannableMode;\n}\n\nexport interface IndexCheckResult {\n /** Number of captured statements that were checked */\n statementCount: number;\n /** Findings that fail the check */\n findings: Finding[];\n /** Unplannable statements listed without failing (unplannable: 'report') */\n reported: Finding[];\n /** Ignore rules that matched no finding */\n staleIgnoreRules: IgnoreRule[];\n}\n\n/**\n * Subset of a PostgreSQL `EXPLAIN (FORMAT JSON, VERBOSE)` plan node used by the rules\n */\nexport interface PlanNode {\n 'Node Type': string;\n 'Relation Name'?: string;\n Schema?: string;\n 'Index Name'?: string;\n 'Index Cond'?: string;\n Filter?: string;\n Plans?: PlanNode[];\n}\n","import { FindingKind } from './types.js';\nimport type { Finding, PlanNode } from './types.js';\n\nconst INDEX_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan']);\nconst INDEX_COND_SCAN_TYPES = new Set(['Index Scan', 'Index Only Scan', 'Bitmap Index Scan']);\n\nexport interface PlanRuleContext {\n /**\n * Leading btree column per index, keyed by `schema.index`. Only needed when\n * checkTrailingIndexColumns is true.\n */\n leadingColumns: Map<string, string>;\n /**\n * Columns that lead some btree index, per table (`schema.table`). Used to\n * suppress a FULL_INDEX_SCAN when a usable index for the filtered column\n * exists — the generic planner just picked a different one.\n */\n leadingColumnsByTable: Map<string, Set<string>>;\n /** PostgreSQL 18+ can skip-scan, so trailing-column conditions are fine there */\n checkTrailingIndexColumns: boolean;\n}\n\ntype PlanFinding = Omit<Finding, 'statement' | 'sourceLocation'>;\n\n/**\n * Lists every plan node, depth first. Bitmap index scans don't name their table, so\n * they take it from the bitmap heap scan above them.\n */\nexport function flattenPlan(plan: PlanNode, bitmapRelation?: Pick<PlanNode, 'Schema' | 'Relation Name'>): PlanNode[] {\n const node = bitmapRelation && plan['Relation Name'] === undefined ? { ...plan, ...bitmapRelation } : plan;\n const childRelation =\n plan['Node Type'] === 'Bitmap Heap Scan'\n ? { Schema: plan.Schema, 'Relation Name': plan['Relation Name'] }\n : bitmapRelation;\n\n return [node, ...(plan.Plans ?? []).flatMap((child) => flattenPlan(child, childRelation))];\n}\n\n/**\n * Keys (`schema.index`) of indexes used with an index condition, for looking up\n * their leading columns\n */\nexport function indexesWithConditions(plan: PlanNode): string[] {\n return flattenPlan(plan)\n .filter((node) => INDEX_COND_SCAN_TYPES.has(node['Node Type']) && node['Index Cond'])\n .map((node) => indexKey(node));\n}\n\n/**\n * Applies the missing-index rules to a plan produced with enable_seqscan off\n */\nexport function evaluatePlan(plan: PlanNode, context: PlanRuleContext): PlanFinding[] {\n return flattenPlan(plan).flatMap((node) => {\n const finding =\n checkSeqScanFilter(node) ?? checkFullIndexScan(node, context) ?? checkTrailingIndexColumn(node, context);\n return finding ? [finding] : [];\n });\n}\n\n/**\n * Tables (`schema.table`) a plan reads, for looking up their index leading columns\n */\nexport function planTables(plan: PlanNode): string[] {\n return [...new Set(flattenPlan(plan).map(tableName).filter((name): name is string => name !== undefined))];\n}\n\n/**\n * A sequential scan that survives enable_seqscan off means no index can serve its filter\n */\nfunction checkSeqScanFilter(node: PlanNode): PlanFinding | undefined {\n const filter = node.Filter;\n if (node['Node Type'] !== 'Seq Scan' || !filter || isNegationOnly(filter)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.SEQ_SCAN_FILTER,\n table: tableName(node),\n condition: filter,\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Walking a whole index (usually to avoid a sort) while filtering on one column\n * means that column has no index. Range and IS NULL filters are skipped, since\n * pagination walks the primary key on purpose.\n */\nfunction checkFullIndexScan(node: PlanNode, context: PlanRuleContext): PlanFinding | undefined {\n const filter = node.Filter;\n if (!INDEX_SCAN_TYPES.has(node['Node Type']) || !filter || node['Index Cond']) {\n return undefined;\n }\n const column = equalityColumn(filter);\n if (column === undefined) {\n return undefined;\n }\n\n // A usable index for this equality filter already exists (its leading column\n // is the filtered one); the generic planner just chose an ordering index\n // instead. With real selectivity production uses the usable one, so this is\n // not a missing index. The guard only guarantees at least one usable index.\n const table = tableName(node);\n if (table !== undefined && context.leadingColumnsByTable.get(table)?.has(column)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.FULL_INDEX_SCAN,\n table,\n condition: filter,\n indexName: node['Index Name'],\n hint: indexHint(node, filter),\n };\n}\n\n/**\n * Before PostgreSQL 18, an equality condition that skips a btree index's leading\n * column has to scan the whole index\n */\nfunction checkTrailingIndexColumn(node: PlanNode, context: PlanRuleContext): PlanFinding | undefined {\n const indexCondition = node['Index Cond'];\n if (!context.checkTrailingIndexColumns || !INDEX_COND_SCAN_TYPES.has(node['Node Type']) || !indexCondition) {\n return undefined;\n }\n\n const leadingColumn = context.leadingColumns.get(indexKey(node));\n if (leadingColumn === undefined || !indexCondition.includes(' = ')) {\n return undefined;\n }\n if (new RegExp(`\\\\b\"?${escapeRegExp(leadingColumn)}\"?\\\\b`).test(indexCondition)) {\n return undefined;\n }\n\n return {\n kind: FindingKind.TRAILING_INDEX_COLUMN,\n table: tableName(node),\n condition: indexCondition,\n indexName: node['Index Name'],\n hint: `condition does not use the index's leading column \"${leadingColumn}\"`,\n };\n}\n\n/**\n * Filters made only of `<>` / `NOT IN` comparisons, which a btree index cannot serve\n */\nfunction isNegationOnly(filter: string): boolean {\n return filter.includes('<>') && !/ = | < | > | <= | >= |~~| IS NULL/.test(filter);\n}\n\n/**\n * Column name when the filter is a single `column = value` comparison\n */\nfunction equalityColumn(filter: string): string | undefined {\n if (/ AND | OR /.test(filter) || filter.split(' = ').length !== 2) {\n return undefined;\n }\n const match = /^\\((?:\\w+\\.)?\"?(\\w+)\"? = /.exec(filter);\n return match?.[1];\n}\n\nfunction indexHint(node: PlanNode, filter: string): string | undefined {\n const column = equalityColumn(filter);\n return column === undefined ? undefined : `CREATE INDEX ON ${tableName(node)} (${column})`;\n}\n\nfunction tableName(node: PlanNode): string | undefined {\n const relation = node['Relation Name'];\n if (relation === undefined) {\n return undefined;\n }\n return node.Schema ? `${node.Schema}.${relation}` : relation;\n}\n\nfunction indexKey(node: PlanNode): string {\n return `${node.Schema}.${node['Index Name']}`;\n}\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n","import type { Finding, IndexCheckResult } from './types.js';\n\n/**\n * Formats a check result for humans\n */\nexport function formatReport(result: IndexCheckResult): string {\n const sections = [\n `pg-index-check: ${result.findings.length} finding(s) across ${result.statementCount} statement(s)`,\n ];\n\n if (result.findings.length > 0) {\n sections.push(result.findings.map(formatFinding).join('\\n\\n'));\n }\n if (result.reported.length > 0) {\n sections.push(`Unplannable statements (not failing):\\n\\n${result.reported.map(formatFinding).join('\\n\\n')}`);\n }\n if (result.staleIgnoreRules.length > 0) {\n const rules = result.staleIgnoreRules.map(\n (rule) => ` - ${String(rule.table ?? rule.statement)} (${rule.reason})`\n );\n sections.push(`Ignore rules that matched nothing:\\n${rules.join('\\n')}`);\n }\n\n return sections.join('\\n\\n');\n}\n\nfunction formatFinding(finding: Finding): string {\n const heading = [finding.kind, finding.table, finding.sourceLocation && `at ${finding.sourceLocation}`]\n .filter(Boolean)\n .join(' ');\n\n const details = [\n finding.condition && `condition: ${finding.condition}`,\n finding.indexName && `index: ${finding.indexName}`,\n finding.error && `error: ${finding.error}`,\n finding.hint && `fix: ${finding.hint}`,\n `statement: ${finding.statement}`,\n ].filter(Boolean);\n\n return [heading, ...details.map((line) => ` ${line}`)].join('\\n');\n}\n","import pg from 'pg';\nimport { readCapturedStatements, resetCapturedStatements } from './capture.js';\nimport {\n explainStatement,\n leadingIndexColumns,\n preparePlanningSession,\n serverMajorVersion,\n tableLeadingColumns,\n} from './explain.js';\nimport { applyIgnoreRules } from './ignore.js';\nimport { evaluatePlan, indexesWithConditions, planTables } from './plan-rules.js';\nimport { formatReport } from './report.js';\nimport { parseSourceLocation } from './statement-text.js';\nimport { FindingKind, UnplannableMode } from './types.js';\nimport type { CaptureOptions, Finding, IndexCheckOptions, IndexCheckResult, PlanNode } from './types.js';\n\n/** PostgreSQL 18 added skip scan, so conditions on trailing index columns are servable */\nconst SKIP_SCAN_MAJOR_VERSION = 18;\n/** EXPLAIN (GENERIC_PLAN) was added in PostgreSQL 16 */\nconst MINIMUM_MAJOR_VERSION = 16;\n\n/**\n * Thrown by assertIndexes() when statements have no usable index\n */\nexport class IndexCheckError extends Error {\n readonly result: IndexCheckResult;\n\n constructor(result: IndexCheckResult) {\n super(formatReport(result));\n this.name = 'IndexCheckError';\n this.result = result;\n }\n}\n\n/**\n * Clears recorded statements. Call before the test suite runs (e.g. in vitest globalSetup).\n */\nexport async function resetCapture(options: CaptureOptions): Promise<void> {\n await withClient(options.connectionString, resetCapturedStatements);\n}\n\n/**\n * Plans every statement recorded since resetCapture() with sequential scans\n * disabled, and returns those no index can serve\n */\nexport async function checkIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const unplannableMode = options.unplannable ?? UnplannableMode.FAIL;\n\n return withClient(options.connectionString, async (client) => {\n // Read before running anything else, so this check's own queries are not captured\n const statements = await readCapturedStatements(client);\n\n const majorVersion = await serverMajorVersion(client);\n if (majorVersion < MINIMUM_MAJOR_VERSION) {\n throw new Error(`pg-index-check: PostgreSQL ${MINIMUM_MAJOR_VERSION}+ is required, found ${majorVersion}`);\n }\n await preparePlanningSession(client);\n\n const allFindings: Finding[] = [];\n for (const statement of statements) {\n allFindings.push(...(await checkStatement(client, statement, majorVersion)));\n }\n\n const isReportedOnly = (finding: Finding) =>\n finding.kind === FindingKind.UNPLANNABLE && unplannableMode === UnplannableMode.REPORT;\n const { kept, staleIgnoreRules } = applyIgnoreRules(allFindings, options.ignore ?? []);\n\n return {\n statementCount: statements.length,\n findings: kept.filter((finding) => !isReportedOnly(finding)),\n reported: kept.filter(isReportedOnly),\n staleIgnoreRules,\n };\n });\n}\n\n/**\n * Like checkIndexes(), but throws an IndexCheckError listing the findings.\n * Call after the test suite runs (e.g. in vitest globalSetup's teardown).\n */\nexport async function assertIndexes(options: IndexCheckOptions): Promise<IndexCheckResult> {\n const result = await checkIndexes(options);\n if (result.findings.length > 0) {\n throw new IndexCheckError(result);\n }\n return result;\n}\n\nasync function checkStatement(client: pg.Client, statement: string, majorVersion: number): Promise<Finding[]> {\n const sourceLocation = parseSourceLocation(statement);\n const outcome = await explainStatement(client, statement);\n\n if ('error' in outcome) {\n return [{ kind: FindingKind.UNPLANNABLE, statement, sourceLocation, error: outcome.error }];\n }\n\n const findings = await evaluate(client, outcome.plan, majorVersion);\n return findings.map((finding) => ({ ...finding, statement, sourceLocation }));\n}\n\nasync function evaluate(client: pg.Client, plan: PlanNode, majorVersion: number) {\n const checkTrailingIndexColumns = majorVersion < SKIP_SCAN_MAJOR_VERSION;\n const leadingColumns = checkTrailingIndexColumns\n ? await leadingIndexColumns(client, indexesWithConditions(plan))\n : new Map<string, string>();\n const leadingColumnsByTable = await tableLeadingColumns(client, planTables(plan));\n\n return evaluatePlan(plan, { leadingColumns, leadingColumnsByTable, checkTrailingIndexColumns });\n}\n\nasync function withClient<T>(connectionString: string, work: (client: pg.Client) => Promise<T>): Promise<T> {\n // A dedicated connection, so enable_seqscan never leaks into the application's sessions\n const client = new pg.Client({ connectionString });\n await client.connect();\n try {\n return await work(client);\n } finally {\n await client.end();\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mosaic-code/pg-index-check",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },