@yawlabs/postgres-mcp 0.3.3 → 0.4.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.js CHANGED
@@ -35255,6 +35255,12 @@ function getStatementTimeoutMs() {
35255
35255
  const parsed = Number(raw);
35256
35256
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 3e4;
35257
35257
  }
35258
+ function getConnectionTimeoutMs() {
35259
+ const raw = process.env.POSTGRES_CONNECTION_TIMEOUT_MS;
35260
+ if (!raw) return 1e4;
35261
+ const parsed = Number(raw);
35262
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1e4;
35263
+ }
35258
35264
  function getMaxRows() {
35259
35265
  const raw = process.env.POSTGRES_MAX_ROWS;
35260
35266
  if (!raw) return 1e3;
@@ -35284,6 +35290,7 @@ function getPool() {
35284
35290
  pool = new esm_default.Pool({
35285
35291
  connectionString: getDatabaseUrl(),
35286
35292
  statement_timeout: getStatementTimeoutMs(),
35293
+ connectionTimeoutMillis: getConnectionTimeoutMs(),
35287
35294
  max: getPoolMax(),
35288
35295
  // MCP sessions can have minutes-long gaps between tool calls. A short
35289
35296
  // idleTimeout forces a reconnect on every tool call. 60s keeps the pool
@@ -35296,6 +35303,29 @@ function getPool() {
35296
35303
  });
35297
35304
  return pool;
35298
35305
  }
35306
+ var typeNameCache = null;
35307
+ async function resolveTypeNames(client, oids) {
35308
+ if (oids.length === 0) return {};
35309
+ if (!typeNameCache) {
35310
+ typeNameCache = /* @__PURE__ */ new Map();
35311
+ const res = await client.query("SELECT oid, typname FROM pg_catalog.pg_type");
35312
+ for (const row of res.rows) typeNameCache.set(row.oid, row.typname);
35313
+ }
35314
+ const missing = oids.filter((o) => !typeNameCache?.has(o));
35315
+ if (missing.length > 0) {
35316
+ const res = await client.query(
35317
+ "SELECT oid, typname FROM pg_catalog.pg_type WHERE oid = ANY($1)",
35318
+ [missing]
35319
+ );
35320
+ for (const row of res.rows) typeNameCache.set(row.oid, row.typname);
35321
+ }
35322
+ const out = {};
35323
+ for (const oid of oids) {
35324
+ const n = typeNameCache.get(oid);
35325
+ if (n !== void 0) out[oid] = n;
35326
+ }
35327
+ return out;
35328
+ }
35299
35329
  function formatPgError(err) {
35300
35330
  if (!(err instanceof Error)) return String(err);
35301
35331
  const errObj = err;
@@ -35308,25 +35338,60 @@ function formatPgError(err) {
35308
35338
  }
35309
35339
  return parts.join(" ");
35310
35340
  }
35311
- function toQueryResult(result, maxRows) {
35341
+ async function runUserQueryBounded(client, sql, params, maxRows) {
35342
+ await client.query("SAVEPOINT __pgmcp_sp");
35343
+ let declareSucceeded = false;
35344
+ try {
35345
+ await client.query({
35346
+ text: `DECLARE __pgmcp_cur NO SCROLL CURSOR FOR ${sql}`,
35347
+ values: params,
35348
+ queryMode: "extended"
35349
+ });
35350
+ declareSucceeded = true;
35351
+ const fetched = await client.query(`FETCH ${maxRows + 1} FROM __pgmcp_cur`);
35352
+ try {
35353
+ await client.query("CLOSE __pgmcp_cur");
35354
+ } catch {
35355
+ }
35356
+ await client.query("RELEASE SAVEPOINT __pgmcp_sp");
35357
+ return fetched;
35358
+ } catch (err) {
35359
+ if (declareSucceeded) {
35360
+ throw err;
35361
+ }
35362
+ await client.query("ROLLBACK TO SAVEPOINT __pgmcp_sp");
35363
+ await client.query("RELEASE SAVEPOINT __pgmcp_sp");
35364
+ return client.query({
35365
+ text: sql,
35366
+ values: params,
35367
+ queryMode: "extended"
35368
+ });
35369
+ }
35370
+ }
35371
+ function toQueryResult(result, maxRows, typeNames = {}) {
35312
35372
  const truncated = result.rows.length > maxRows;
35313
35373
  const rows = truncated ? result.rows.slice(0, maxRows) : result.rows;
35314
35374
  return {
35315
35375
  rows,
35316
35376
  rowCount: result.rowCount,
35317
- fields: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
35377
+ fields: result.fields.map((f) => {
35378
+ const name = typeNames[f.dataTypeID];
35379
+ return name !== void 0 ? { name: f.name, dataTypeID: f.dataTypeID, dataTypeName: name } : { name: f.name, dataTypeID: f.dataTypeID };
35380
+ }),
35318
35381
  command: result.command,
35319
35382
  ...truncated ? { truncated: true } : {}
35320
35383
  };
35321
35384
  }
35322
- async function runReadOnly(sql, params = []) {
35385
+ async function runReadOnly(sql, params = [], hooks = {}) {
35323
35386
  const client = await getPool().connect();
35324
35387
  const maxRows = getMaxRows();
35325
35388
  try {
35326
35389
  await client.query("BEGIN READ ONLY");
35327
- const result = await client.query({ text: sql, values: params, queryMode: "extended" });
35390
+ if (hooks.setup) await hooks.setup(client);
35391
+ const result = await runUserQueryBounded(client, sql, params, maxRows);
35328
35392
  await client.query("ROLLBACK");
35329
- return { ok: true, data: toQueryResult(result, maxRows) };
35393
+ const typeNames = await resolveTypeNames(client, [...new Set(result.fields.map((f) => f.dataTypeID))]);
35394
+ return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
35330
35395
  } catch (err) {
35331
35396
  try {
35332
35397
  await client.query("ROLLBACK");
@@ -35334,6 +35399,12 @@ async function runReadOnly(sql, params = []) {
35334
35399
  }
35335
35400
  return { ok: false, error: formatPgError(err) };
35336
35401
  } finally {
35402
+ if (hooks.teardown) {
35403
+ try {
35404
+ await hooks.teardown(client);
35405
+ } catch {
35406
+ }
35407
+ }
35337
35408
  client.release();
35338
35409
  }
35339
35410
  }
@@ -35348,9 +35419,10 @@ async function runReadWrite(sql, params = []) {
35348
35419
  const maxRows = getMaxRows();
35349
35420
  try {
35350
35421
  await client.query("BEGIN");
35351
- const result = await client.query({ text: sql, values: params, queryMode: "extended" });
35422
+ const result = await runUserQueryBounded(client, sql, params, maxRows);
35352
35423
  await client.query("COMMIT");
35353
- return { ok: true, data: toQueryResult(result, maxRows) };
35424
+ const typeNames = await resolveTypeNames(client, [...new Set(result.fields.map((f) => f.dataTypeID))]);
35425
+ return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
35354
35426
  } catch (err) {
35355
35427
  try {
35356
35428
  await client.query("ROLLBACK");
@@ -35361,7 +35433,7 @@ async function runReadWrite(sql, params = []) {
35361
35433
  client.release();
35362
35434
  }
35363
35435
  }
35364
- async function runReadWriteRollback(sql, params = []) {
35436
+ async function runReadWriteRollback(sql, params = [], hooks = {}) {
35365
35437
  if (!isWritesAllowed()) {
35366
35438
  return {
35367
35439
  ok: false,
@@ -35372,9 +35444,11 @@ async function runReadWriteRollback(sql, params = []) {
35372
35444
  const maxRows = getMaxRows();
35373
35445
  try {
35374
35446
  await client.query("BEGIN");
35375
- const result = await client.query({ text: sql, values: params, queryMode: "extended" });
35447
+ if (hooks.setup) await hooks.setup(client);
35448
+ const result = await runUserQueryBounded(client, sql, params, maxRows);
35376
35449
  await client.query("ROLLBACK");
35377
- return { ok: true, data: toQueryResult(result, maxRows) };
35450
+ const typeNames = await resolveTypeNames(client, [...new Set(result.fields.map((f) => f.dataTypeID))]);
35451
+ return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
35378
35452
  } catch (err) {
35379
35453
  try {
35380
35454
  await client.query("ROLLBACK");
@@ -35382,6 +35456,12 @@ async function runReadWriteRollback(sql, params = []) {
35382
35456
  }
35383
35457
  return { ok: false, error: formatPgError(err) };
35384
35458
  } finally {
35459
+ if (hooks.teardown) {
35460
+ try {
35461
+ await hooks.teardown(client);
35462
+ } catch {
35463
+ }
35464
+ }
35385
35465
  client.release();
35386
35466
  }
35387
35467
  }
@@ -35393,10 +35473,33 @@ async function runInternal(sql, params = []) {
35393
35473
  return { ok: false, error: formatPgError(err) };
35394
35474
  }
35395
35475
  }
35476
+ async function withSharedClient(fn) {
35477
+ const client = await getPool().connect();
35478
+ try {
35479
+ const runOnClient = async (sql, params = []) => {
35480
+ try {
35481
+ const result = await client.query(sql, params);
35482
+ return { ok: true, data: result.rows };
35483
+ } catch (err) {
35484
+ return { ok: false, error: formatPgError(err) };
35485
+ }
35486
+ };
35487
+ return await fn(runOnClient);
35488
+ } finally {
35489
+ client.release();
35490
+ }
35491
+ }
35396
35492
  async function shutdown() {
35397
- if (pool) {
35398
- await pool.end();
35399
- pool = null;
35493
+ typeNameCache = null;
35494
+ if (!pool) return;
35495
+ const ending = pool;
35496
+ pool = null;
35497
+ try {
35498
+ await Promise.race([
35499
+ ending.end(),
35500
+ new Promise((_, reject) => setTimeout(() => reject(new Error("pool shutdown timed out after 5s")), 5e3))
35501
+ ]);
35502
+ } catch {
35400
35503
  }
35401
35504
  }
35402
35505
 
@@ -35490,7 +35593,7 @@ var adminTools = [
35490
35593
  },
35491
35594
  {
35492
35595
  name: "pg_table_privileges",
35493
- description: "Show which roles have which privileges (SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER) on a table or on all tables in a schema. Use this to answer 'who can write to this table?' or to audit schema-wide access before a migration.",
35596
+ description: "Show which roles have which privileges (SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER) on a table or on every table in a schema. If `table` is omitted, the result spans every table in `schema`, ordered by table then grantee. Use this to answer 'who can write to this table?' or to audit schema-wide access before a migration.",
35494
35597
  annotations: {
35495
35598
  title: "Show table privileges",
35496
35599
  readOnlyHint: true,
@@ -35570,49 +35673,137 @@ var adminTools = [
35570
35673
  },
35571
35674
  inputSchema: external_exports3.object({}),
35572
35675
  handler: async () => {
35573
- const [slotsRes, replicasRes, walRes] = await Promise.all([
35574
- runInternal(
35575
- `SELECT
35576
- slot_name, slot_type, active,
35577
- restart_lsn::text AS restart_lsn,
35578
- confirmed_flush_lsn::text AS confirmed_flush_lsn,
35579
- wal_status, database, plugin
35580
- FROM pg_catalog.pg_replication_slots
35581
- ORDER BY slot_name`
35582
- ),
35583
- runInternal(
35584
- `SELECT
35585
- application_name,
35586
- client_addr::text AS client_addr,
35587
- state,
35588
- sync_state,
35589
- EXTRACT(EPOCH FROM write_lag)::numeric(10, 2)::float8 AS write_lag_seconds,
35590
- EXTRACT(EPOCH FROM flush_lag)::numeric(10, 2)::float8 AS flush_lag_seconds,
35591
- EXTRACT(EPOCH FROM replay_lag)::numeric(10, 2)::float8 AS replay_lag_seconds
35592
- FROM pg_catalog.pg_stat_replication
35593
- ORDER BY application_name`
35594
- ),
35595
- runInternal(
35596
- `SELECT
35597
- pg_is_in_recovery() AS is_in_recovery,
35598
- CASE
35599
- WHEN pg_is_in_recovery() THEN pg_last_wal_receive_lsn()::text
35600
- ELSE pg_current_wal_lsn()::text
35601
- END AS wal_position`
35602
- )
35603
- ]);
35604
- if (!slotsRes.ok) return slotsRes;
35605
- if (!replicasRes.ok) return replicasRes;
35606
- if (!walRes.ok) return walRes;
35607
- return {
35608
- ok: true,
35609
- data: {
35610
- is_replica: walRes.data?.[0]?.is_in_recovery ?? false,
35611
- wal_position: walRes.data?.[0]?.wal_position ?? null,
35612
- slots: slotsRes.data ?? [],
35613
- replicas: replicasRes.data ?? []
35614
- }
35615
- };
35676
+ return withSharedClient(async (run) => {
35677
+ const [slotsRes, replicasRes, walRes] = await Promise.all([
35678
+ run(
35679
+ `SELECT
35680
+ slot_name, slot_type, active,
35681
+ restart_lsn::text AS restart_lsn,
35682
+ confirmed_flush_lsn::text AS confirmed_flush_lsn,
35683
+ wal_status, database, plugin
35684
+ FROM pg_catalog.pg_replication_slots
35685
+ ORDER BY slot_name`
35686
+ ),
35687
+ run(
35688
+ `SELECT
35689
+ application_name,
35690
+ client_addr::text AS client_addr,
35691
+ state,
35692
+ sync_state,
35693
+ EXTRACT(EPOCH FROM write_lag)::numeric(10, 2)::float8 AS write_lag_seconds,
35694
+ EXTRACT(EPOCH FROM flush_lag)::numeric(10, 2)::float8 AS flush_lag_seconds,
35695
+ EXTRACT(EPOCH FROM replay_lag)::numeric(10, 2)::float8 AS replay_lag_seconds
35696
+ FROM pg_catalog.pg_stat_replication
35697
+ ORDER BY application_name`
35698
+ ),
35699
+ run(
35700
+ `SELECT
35701
+ pg_is_in_recovery() AS is_in_recovery,
35702
+ CASE
35703
+ WHEN pg_is_in_recovery() THEN pg_last_wal_receive_lsn()::text
35704
+ ELSE pg_current_wal_lsn()::text
35705
+ END AS wal_position`
35706
+ )
35707
+ ]);
35708
+ if (!slotsRes.ok) return slotsRes;
35709
+ if (!replicasRes.ok) return replicasRes;
35710
+ if (!walRes.ok) return walRes;
35711
+ return {
35712
+ ok: true,
35713
+ data: {
35714
+ is_replica: walRes.data?.[0]?.is_in_recovery ?? false,
35715
+ wal_position: walRes.data?.[0]?.wal_position ?? null,
35716
+ slots: slotsRes.data ?? [],
35717
+ replicas: replicasRes.data ?? []
35718
+ }
35719
+ };
35720
+ });
35721
+ }
35722
+ },
35723
+ {
35724
+ name: "pg_advisor",
35725
+ description: "Rolled-up DBA lint pass. One call returns three categories of findings:\n- sequence_exhaustion: SERIAL / BIGSERIAL / IDENTITY sequences whose `last_value` is above `seqExhaustionThreshold` of `max_value`. The classic incident class.\n- tables_without_primary_key: user tables with no PK. Bloat candidates and a sign of design drift; some replication setups also need PKs.\n- public_tables_without_rls: tables in `public` (or any schema in `rlsSchemas`) with row-level security disabled. Useful as a security baseline check.\nUse this as the 'what should I be looking at?' starting point, then drill into `pg_unused_indexes`, `pg_table_bloat`, `pg_seq_scan_tables` for the perf side.",
35726
+ annotations: {
35727
+ title: "Database advisor (DBA lints)",
35728
+ readOnlyHint: true,
35729
+ destructiveHint: false,
35730
+ idempotentHint: true,
35731
+ openWorldHint: true
35732
+ },
35733
+ inputSchema: external_exports3.object({
35734
+ seqExhaustionThreshold: external_exports3.number().min(0).max(1).default(0.5).describe("Minimum used-fraction (last_value / max_value) to flag a sequence (default 0.5 = 50%)."),
35735
+ rlsSchemas: external_exports3.array(external_exports3.string().min(1).max(63)).default(["public"]).describe("Schemas where RLS-missing should be flagged. Defaults to ['public']."),
35736
+ limit: external_exports3.number().int().min(1).max(500).default(50).describe("Max rows per category (default 50).")
35737
+ }),
35738
+ handler: async (input) => {
35739
+ const { seqExhaustionThreshold, rlsSchemas, limit } = input;
35740
+ return withSharedClient(async (run) => {
35741
+ const [seqRes, noPkRes, rlsRes] = await Promise.all([
35742
+ run(
35743
+ // pg_sequences was added in PG10. last_value can be NULL on a never-
35744
+ // touched sequence; we filter those out (nothing to report yet).
35745
+ `SELECT
35746
+ schemaname AS schema,
35747
+ sequencename AS sequence,
35748
+ last_value::text AS last_value,
35749
+ max_value::text AS max_value,
35750
+ (last_value::float8 / NULLIF(max_value::float8, 0))::numeric(6, 4)::float8 AS pct_used
35751
+ FROM pg_catalog.pg_sequences
35752
+ WHERE last_value IS NOT NULL
35753
+ AND max_value > 0
35754
+ AND (last_value::float8 / max_value::float8) >= $1
35755
+ ORDER BY pct_used DESC NULLS LAST
35756
+ LIMIT $2`,
35757
+ [seqExhaustionThreshold, limit]
35758
+ ),
35759
+ run(
35760
+ // Declarative-partition children inherit the parent's primary key
35761
+ // as an indisprimary index, so the NOT EXISTS clause already
35762
+ // excludes them. Nothing extra needed for partitioned schemas.
35763
+ `SELECT
35764
+ n.nspname AS schema,
35765
+ c.relname AS "table"
35766
+ FROM pg_catalog.pg_class c
35767
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
35768
+ WHERE c.relkind = 'r'
35769
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
35770
+ AND n.nspname NOT LIKE 'pg_%'
35771
+ AND NOT EXISTS (
35772
+ SELECT 1 FROM pg_catalog.pg_index i
35773
+ WHERE i.indrelid = c.oid AND i.indisprimary
35774
+ )
35775
+ ORDER BY n.nspname, c.relname
35776
+ LIMIT $1`,
35777
+ [limit]
35778
+ ),
35779
+ run(
35780
+ `SELECT
35781
+ n.nspname AS schema,
35782
+ c.relname AS "table"
35783
+ FROM pg_catalog.pg_class c
35784
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
35785
+ WHERE c.relkind IN ('r', 'p')
35786
+ AND n.nspname = ANY($1)
35787
+ AND NOT c.relrowsecurity
35788
+ ORDER BY n.nspname, c.relname
35789
+ LIMIT $2`,
35790
+ [rlsSchemas, limit]
35791
+ )
35792
+ ]);
35793
+ const warnings = [];
35794
+ if (!seqRes.ok) warnings.push(`sequence_exhaustion fetch failed: ${seqRes.error}`);
35795
+ if (!noPkRes.ok) warnings.push(`tables_without_primary_key fetch failed: ${noPkRes.error}`);
35796
+ if (!rlsRes.ok) warnings.push(`public_tables_without_rls fetch failed: ${rlsRes.error}`);
35797
+ return {
35798
+ ok: true,
35799
+ data: {
35800
+ sequence_exhaustion: seqRes.ok ? seqRes.data : [],
35801
+ tables_without_primary_key: noPkRes.ok ? noPkRes.data : [],
35802
+ public_tables_without_rls: rlsRes.ok ? rlsRes.data : [],
35803
+ ...warnings.length > 0 ? { _warnings: warnings } : {}
35804
+ }
35805
+ };
35806
+ });
35616
35807
  }
35617
35808
  },
35618
35809
  {
@@ -35627,7 +35818,7 @@ var adminTools = [
35627
35818
  },
35628
35819
  inputSchema: external_exports3.object({
35629
35820
  schema: external_exports3.string().min(1).max(63).optional().describe("Limit to one schema. If omitted, all user schemas are included."),
35630
- minDeadRatio: external_exports3.number().min(0).max(1).default(0.1).describe("Minimum dead/live ratio to include (default 0.1 = 10%)."),
35821
+ minDeadRatio: external_exports3.number().min(0).max(1).default(0.1).describe("Minimum dead-tuple fraction to include \u2014 dead / (live + dead). Default 0.1 = 10%."),
35631
35822
  limit: external_exports3.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
35632
35823
  }),
35633
35824
  handler: async (input) => {
@@ -35636,22 +35827,23 @@ var adminTools = [
35636
35827
  const params = [minDeadRatio, limit];
35637
35828
  if (schema) params.push(schema);
35638
35829
  return runInternal(
35830
+ // dead_ratio = dead / (live + dead): bounded [0, 1]. A 100%-dead table
35831
+ // (live=0, dead>0) correctly reports 1.0 instead of 0. Tables with both
35832
+ // counters at 0 are filtered out -- nothing to report.
35639
35833
  `SELECT
35640
35834
  schemaname AS schema,
35641
35835
  relname AS "table",
35642
35836
  n_live_tup::text AS live_tuples,
35643
35837
  n_dead_tup::text AS dead_tuples,
35644
- CASE
35645
- WHEN n_live_tup = 0 THEN 0
35646
- ELSE (n_dead_tup::float8 / GREATEST(n_live_tup, 1))::numeric(6, 3)::float8
35647
- END AS dead_ratio,
35838
+ (n_dead_tup::float8 / (n_live_tup + n_dead_tup))::numeric(6, 3)::float8 AS dead_ratio,
35648
35839
  pg_size_pretty(pg_total_relation_size(relid)) AS size_pretty,
35649
35840
  pg_total_relation_size(relid)::text AS size_bytes,
35650
35841
  last_vacuum::text AS last_vacuum,
35651
35842
  last_autovacuum::text AS last_autovacuum,
35652
35843
  last_analyze::text AS last_analyze
35653
35844
  FROM pg_catalog.pg_stat_user_tables
35654
- WHERE (n_dead_tup::float8 / GREATEST(n_live_tup, 1)) >= $1
35845
+ WHERE (n_live_tup + n_dead_tup) > 0
35846
+ AND (n_dead_tup::float8 / (n_live_tup + n_dead_tup)) >= $1
35655
35847
  ${schemaFilter}
35656
35848
  ORDER BY n_dead_tup DESC
35657
35849
  LIMIT $2`,
@@ -35667,10 +35859,56 @@ var paramValue = external_exports3.lazy(
35667
35859
  );
35668
35860
 
35669
35861
  // src/tools/explain.ts
35862
+ var indexAccessMethod = external_exports3.enum(["btree", "hash", "gin", "gist", "brin", "spgist"]);
35863
+ var hypotheticalIndex = external_exports3.object({
35864
+ table: external_exports3.string().min(1).max(127).describe("Target table. Use `schema.table` (e.g. `public.users`) or just `table` for the search_path."),
35865
+ columns: external_exports3.array(external_exports3.string().min(1).max(63)).min(1).describe("Column names in index order. Quoted identifiers are not supported here -- pass plain names."),
35866
+ using: indexAccessMethod.default("btree").describe("Index access method. btree is the right answer for almost every query.")
35867
+ });
35868
+ function quoteIdent(name) {
35869
+ return `"${name.replace(/"/g, '""')}"`;
35870
+ }
35871
+ function quoteQualifiedTable(name) {
35872
+ return name.split(".").map((p) => quoteIdent(p)).join(".");
35873
+ }
35874
+ function validateHypoIndex(idx) {
35875
+ for (const piece of idx.table.split(".")) {
35876
+ if (piece.includes('"')) {
35877
+ return `Hypothetical index table ${JSON.stringify(idx.table)} contains a double-quote; pass plain identifier names without pre-quoting.`;
35878
+ }
35879
+ }
35880
+ for (const col of idx.columns) {
35881
+ if (col.includes('"')) {
35882
+ return `Hypothetical index column ${JSON.stringify(col)} contains a double-quote; pass plain identifier names without pre-quoting.`;
35883
+ }
35884
+ }
35885
+ return null;
35886
+ }
35887
+ function buildHypopgHooks(indexes) {
35888
+ return {
35889
+ setup: async (client) => {
35890
+ for (const idx of indexes) {
35891
+ const cols = idx.columns.map(quoteIdent).join(", ");
35892
+ const tbl = quoteQualifiedTable(idx.table);
35893
+ const createSql = `CREATE INDEX ON ${tbl} USING ${idx.using} (${cols})`;
35894
+ const r = await client.query(
35895
+ "SELECT (hypopg_create_index($1)).indexname AS indexname",
35896
+ [createSql]
35897
+ );
35898
+ if (!r.rows[0]?.indexname) {
35899
+ throw new Error(`hypopg_create_index returned no index for: ${createSql}`);
35900
+ }
35901
+ }
35902
+ },
35903
+ teardown: async (client) => {
35904
+ await client.query("SELECT hypopg_reset()");
35905
+ }
35906
+ };
35907
+ }
35670
35908
  var explainTools = [
35671
35909
  {
35672
35910
  name: "pg_explain",
35673
- description: "Get the query plan for a SQL statement. By default, this uses plain EXPLAIN (no execution). Set `analyze: true` to run the query with EXPLAIN ANALYZE \u2014 for non-SELECT statements, ALLOW_WRITES=1 is required (since ANALYZE actually executes the statement). Writes executed during EXPLAIN ANALYZE are always rolled back, so you can inspect a plan for an INSERT/UPDATE/DELETE without persisting the mutation. Format is `text` (default) or `json`. Pass the raw SQL (not an EXPLAIN-prefixed statement).",
35911
+ description: "Get the query plan for a SQL statement. By default, this uses plain EXPLAIN (no execution). Set `analyze: true` to run the query with EXPLAIN ANALYZE \u2014 for non-SELECT statements, ALLOW_WRITES=1 is required (since ANALYZE actually executes the statement). Writes executed during EXPLAIN ANALYZE are always rolled back, so you can inspect a plan for an INSERT/UPDATE/DELETE without persisting the mutation. Format is `text` (default) or `json`. Pass the raw SQL (not an EXPLAIN-prefixed statement). Set `hypothetical_indexes` to a list of `{table, columns, using?}` to ask the planner 'what would the plan be if these indexes existed?' -- requires the HypoPG extension (`CREATE EXTENSION hypopg`). The hypothetical indexes are torn down at the end of the call, never touching real disk.",
35674
35912
  annotations: {
35675
35913
  title: "Explain query plan",
35676
35914
  readOnlyHint: false,
@@ -35682,10 +35920,13 @@ var explainTools = [
35682
35920
  sql: external_exports3.string().min(1).max(1e6).describe("The SQL statement to explain. Do NOT prefix with EXPLAIN."),
35683
35921
  analyze: external_exports3.boolean().default(false).describe("Run EXPLAIN ANALYZE (actually executes the query)."),
35684
35922
  format: external_exports3.enum(["text", "json"]).default("text").describe("Output format."),
35685
- params: external_exports3.array(paramValue).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL.")
35923
+ params: external_exports3.array(paramValue).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL."),
35924
+ hypothetical_indexes: external_exports3.array(hypotheticalIndex).optional().describe(
35925
+ "List of indexes the planner should pretend exist for this EXPLAIN. Requires the HypoPG extension. Indexes are session-scoped and reset at the end of the call."
35926
+ )
35686
35927
  }),
35687
35928
  handler: async (input) => {
35688
- const { sql, analyze, format, params } = input;
35929
+ const { sql, analyze, format, params, hypothetical_indexes } = input;
35689
35930
  if (/^\s*EXPLAIN\b/i.test(sql)) {
35690
35931
  return {
35691
35932
  ok: false,
@@ -35696,7 +35937,27 @@ var explainTools = [
35696
35937
  if (analyze) flags.push("ANALYZE");
35697
35938
  if (format === "json") flags.push("FORMAT JSON");
35698
35939
  const explainSql = flags.length > 0 ? `EXPLAIN (${flags.join(", ")}) ${sql}` : `EXPLAIN ${sql}`;
35699
- const result = analyze && isWritesAllowed() ? await runReadWriteRollback(explainSql, params ?? []) : await runReadOnly(explainSql, params ?? []);
35940
+ const hypoIndexes = hypothetical_indexes ?? [];
35941
+ for (const idx of hypoIndexes) {
35942
+ const err = validateHypoIndex(idx);
35943
+ if (err) return { ok: false, error: err };
35944
+ }
35945
+ const hooks = hypoIndexes.length > 0 ? buildHypopgHooks(hypoIndexes) : {};
35946
+ if (hypoIndexes.length > 0) {
35947
+ const check2 = await runInternal(
35948
+ `SELECT EXISTS (
35949
+ SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'hypopg'
35950
+ ) AS installed`
35951
+ );
35952
+ if (!check2.ok) return check2;
35953
+ if (!check2.data?.[0]?.installed) {
35954
+ return {
35955
+ ok: false,
35956
+ error: "hypothetical_indexes requires the HypoPG extension. Install with `CREATE EXTENSION hypopg;` (a superuser-equivalent role usually). HypoPG is read-only at the disk level \u2014 it lives entirely in shared memory."
35957
+ };
35958
+ }
35959
+ }
35960
+ const result = analyze && isWritesAllowed() ? await runReadWriteRollback(explainSql, params ?? [], hooks) : await runReadOnly(explainSql, params ?? [], hooks);
35700
35961
  if (!result.ok) return result;
35701
35962
  const data = result.data;
35702
35963
  const rows = data?.rows ?? [];
@@ -35727,61 +35988,69 @@ var healthTools = [
35727
35988
  }),
35728
35989
  handler: async (input) => {
35729
35990
  const { activeQueryLimit } = input;
35730
- const [versionRes, sizeRes, connsRes, activeRes, tableCountRes] = await Promise.all([
35731
- runInternal(`SELECT version() AS version`),
35732
- runInternal(
35733
- `SELECT
35734
- current_database() AS database,
35735
- pg_size_pretty(pg_database_size(current_database())) AS size_pretty,
35736
- pg_database_size(current_database())::text AS size_bytes`
35737
- ),
35738
- runInternal(
35739
- `SELECT
35740
- count(*)::text AS total,
35741
- count(*) FILTER (WHERE state = 'active')::text AS active,
35742
- count(*) FILTER (WHERE state = 'idle')::text AS idle,
35743
- count(*) FILTER (WHERE state = 'idle in transaction')::text AS idle_in_transaction
35744
- FROM pg_stat_activity
35745
- WHERE datname = current_database()`
35746
- ),
35747
- runInternal(
35748
- `SELECT
35749
- pid,
35750
- state,
35751
- EXTRACT(EPOCH FROM (now() - query_start))::numeric(10, 2)::float8 AS duration_seconds,
35752
- query,
35753
- application_name
35754
- FROM pg_stat_activity
35755
- WHERE datname = current_database()
35756
- AND state IS NOT NULL
35757
- AND state <> 'idle'
35758
- AND pid <> pg_backend_pid()
35759
- ORDER BY query_start ASC NULLS LAST
35760
- LIMIT $1`,
35761
- [activeQueryLimit]
35762
- ),
35763
- runInternal(
35764
- `SELECT count(*)::text AS count
35765
- FROM pg_catalog.pg_class c
35766
- JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
35767
- WHERE c.relkind IN ('r', 'p')
35768
- AND n.nspname NOT IN ('pg_catalog', 'information_schema')
35769
- AND n.nspname NOT LIKE 'pg_toast%'
35770
- AND n.nspname NOT LIKE 'pg_temp_%'`
35771
- )
35772
- ]);
35773
- if (!versionRes.ok) return versionRes;
35774
- return {
35775
- ok: true,
35776
- data: {
35777
- connected: true,
35778
- version: versionRes.data?.[0]?.version,
35779
- database: sizeRes.ok ? sizeRes.data?.[0] : { error: sizeRes.error },
35780
- connections: connsRes.ok ? connsRes.data?.[0] : { error: connsRes.error },
35781
- active_queries: activeRes.ok ? activeRes.data : [],
35782
- table_count: tableCountRes.ok ? tableCountRes.data?.[0]?.count : null
35783
- }
35784
- };
35991
+ return withSharedClient(async (run) => {
35992
+ const [versionRes, sizeRes, connsRes, activeRes, tableCountRes] = await Promise.all([
35993
+ run(`SELECT version() AS version`),
35994
+ run(
35995
+ `SELECT
35996
+ current_database() AS database,
35997
+ pg_size_pretty(pg_database_size(current_database())) AS size_pretty,
35998
+ pg_database_size(current_database())::text AS size_bytes`
35999
+ ),
36000
+ run(
36001
+ `SELECT
36002
+ count(*)::text AS total,
36003
+ count(*) FILTER (WHERE state = 'active')::text AS active,
36004
+ count(*) FILTER (WHERE state = 'idle')::text AS idle,
36005
+ count(*) FILTER (WHERE state = 'idle in transaction')::text AS idle_in_transaction
36006
+ FROM pg_stat_activity
36007
+ WHERE datname = current_database()`
36008
+ ),
36009
+ run(
36010
+ `SELECT
36011
+ pid,
36012
+ state,
36013
+ EXTRACT(EPOCH FROM (now() - query_start))::numeric(10, 2)::float8 AS duration_seconds,
36014
+ query,
36015
+ application_name
36016
+ FROM pg_stat_activity
36017
+ WHERE datname = current_database()
36018
+ AND state IS NOT NULL
36019
+ AND state <> 'idle'
36020
+ AND pid <> pg_backend_pid()
36021
+ ORDER BY query_start ASC NULLS LAST
36022
+ LIMIT $1`,
36023
+ [activeQueryLimit]
36024
+ ),
36025
+ run(
36026
+ `SELECT count(*)::text AS count
36027
+ FROM pg_catalog.pg_class c
36028
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
36029
+ WHERE c.relkind IN ('r', 'p')
36030
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
36031
+ AND n.nspname NOT LIKE 'pg_toast%'
36032
+ AND n.nspname NOT LIKE 'pg_temp_%'`
36033
+ )
36034
+ ]);
36035
+ if (!versionRes.ok) return versionRes;
36036
+ const warnings = [];
36037
+ if (!sizeRes.ok) warnings.push(`database fetch failed: ${sizeRes.error}`);
36038
+ if (!connsRes.ok) warnings.push(`connections fetch failed: ${connsRes.error}`);
36039
+ if (!activeRes.ok) warnings.push(`active_queries fetch failed: ${activeRes.error}`);
36040
+ if (!tableCountRes.ok) warnings.push(`table_count fetch failed: ${tableCountRes.error}`);
36041
+ return {
36042
+ ok: true,
36043
+ data: {
36044
+ connected: true,
36045
+ version: versionRes.data?.[0]?.version,
36046
+ database: sizeRes.ok ? sizeRes.data?.[0] : null,
36047
+ connections: connsRes.ok ? connsRes.data?.[0] : null,
36048
+ active_queries: activeRes.ok ? activeRes.data : [],
36049
+ table_count: tableCountRes.ok ? tableCountRes.data?.[0]?.count : null,
36050
+ ...warnings.length > 0 ? { _warnings: warnings } : {}
36051
+ }
36052
+ };
36053
+ });
35785
36054
  }
35786
36055
  }
35787
36056
  ];
@@ -35883,7 +36152,7 @@ var schemaTools = [
35883
36152
  },
35884
36153
  {
35885
36154
  name: "pg_describe_table",
35886
- description: "Describe a table: columns (name, type, nullable, default), primary key, foreign keys, and indexes.",
36155
+ description: "Describe a relation: kind (table / view / materialized_view / partitioned_table / foreign_table), columns (name, type, nullable, default), primary key, foreign keys (outgoing), `referenced_by` (other tables whose FKs point at this one), `constraints` (CHECK / UNIQUE non-PK / EXCLUDE), indexes, and partition info (`partition_of` parent, `partitions` children). Works on views and materialized views too -- PK/FK/constraint/index lists will simply be empty for a plain view. Use `kind` to disambiguate before assuming you can write to the relation.",
35887
36156
  annotations: {
35888
36157
  title: "Describe table",
35889
36158
  readOnlyHint: true,
@@ -35897,6 +36166,20 @@ var schemaTools = [
35897
36166
  }),
35898
36167
  handler: async (input) => {
35899
36168
  const { schema, table } = input;
36169
+ const kindQuery = `
36170
+ SELECT
36171
+ CASE c.relkind
36172
+ WHEN 'r' THEN 'table'
36173
+ WHEN 'p' THEN 'partitioned_table'
36174
+ WHEN 'v' THEN 'view'
36175
+ WHEN 'm' THEN 'materialized_view'
36176
+ WHEN 'f' THEN 'foreign_table'
36177
+ ELSE c.relkind::text
36178
+ END AS kind
36179
+ FROM pg_catalog.pg_class c
36180
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
36181
+ WHERE n.nspname = $1 AND c.relname = $2
36182
+ `;
35900
36183
  const columnsQuery = `
35901
36184
  SELECT
35902
36185
  a.attname AS name,
@@ -35961,32 +36244,121 @@ var schemaTools = [
35961
36244
  AND c.relname = $2
35962
36245
  ORDER BY i.relname
35963
36246
  `;
35964
- const [cols, pk, fks, idxs] = await Promise.all([
35965
- runInternal(columnsQuery, [schema, table]),
35966
- runInternal(primaryKeyQuery, [schema, table]),
35967
- runInternal(foreignKeysQuery, [schema, table]),
35968
- runInternal(indexesQuery, [schema, table])
35969
- ]);
35970
- if (!cols.ok) return cols;
35971
- if (!cols.data || cols.data.length === 0) {
35972
- return { ok: false, error: `Table "${schema}"."${table}" not found.` };
35973
- }
35974
- const warnings = [];
35975
- if (!pk.ok) warnings.push(`primary_key fetch failed: ${pk.error}`);
35976
- if (!fks.ok) warnings.push(`foreign_keys fetch failed: ${fks.error}`);
35977
- if (!idxs.ok) warnings.push(`indexes fetch failed: ${idxs.error}`);
35978
- return {
35979
- ok: true,
35980
- data: {
35981
- schema,
35982
- table,
35983
- columns: cols.data,
35984
- primary_key: pk.ok ? (pk.data ?? []).map((r) => r.column_name) : [],
35985
- foreign_keys: fks.ok ? fks.data : [],
35986
- indexes: idxs.ok ? idxs.data : [],
35987
- ...warnings.length > 0 ? { _warnings: warnings } : {}
36247
+ const constraintsQuery = `
36248
+ SELECT
36249
+ con.conname AS name,
36250
+ CASE con.contype
36251
+ WHEN 'c' THEN 'check'
36252
+ WHEN 'u' THEN 'unique'
36253
+ WHEN 'x' THEN 'exclude'
36254
+ ELSE con.contype::text
36255
+ END AS type,
36256
+ pg_catalog.pg_get_constraintdef(con.oid, true) AS definition
36257
+ FROM pg_catalog.pg_constraint con
36258
+ JOIN pg_catalog.pg_class c ON c.oid = con.conrelid
36259
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
36260
+ WHERE n.nspname = $1
36261
+ AND c.relname = $2
36262
+ AND con.contype IN ('c', 'u', 'x')
36263
+ ORDER BY con.contype, con.conname
36264
+ `;
36265
+ const referencedByQuery = `
36266
+ SELECT
36267
+ con.conname AS constraint_name,
36268
+ srcn.nspname AS schema,
36269
+ src.relname AS "table",
36270
+ array_agg(srcatt.attname::text ORDER BY u.attposition) AS columns,
36271
+ array_agg(refatt.attname::text ORDER BY u.attposition) AS referenced_columns
36272
+ FROM pg_catalog.pg_constraint con
36273
+ JOIN pg_catalog.pg_class src ON src.oid = con.conrelid
36274
+ JOIN pg_catalog.pg_namespace srcn ON srcn.oid = src.relnamespace
36275
+ JOIN pg_catalog.pg_class ref ON ref.oid = con.confrelid
36276
+ JOIN pg_catalog.pg_namespace refn ON refn.oid = ref.relnamespace
36277
+ JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS u(attnum, attposition) ON TRUE
36278
+ JOIN pg_catalog.pg_attribute srcatt ON srcatt.attrelid = con.conrelid AND srcatt.attnum = u.attnum
36279
+ JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS fu(attnum, attposition) ON fu.attposition = u.attposition
36280
+ JOIN pg_catalog.pg_attribute refatt ON refatt.attrelid = con.confrelid AND refatt.attnum = fu.attnum
36281
+ WHERE refn.nspname = $1
36282
+ AND ref.relname = $2
36283
+ AND con.contype = 'f'
36284
+ GROUP BY con.conname, srcn.nspname, src.relname
36285
+ ORDER BY srcn.nspname, src.relname, con.conname
36286
+ `;
36287
+ const partitionParentQuery = `
36288
+ SELECT
36289
+ parn.nspname AS schema,
36290
+ par.relname AS "table"
36291
+ FROM pg_catalog.pg_inherits i
36292
+ JOIN pg_catalog.pg_class child ON child.oid = i.inhrelid
36293
+ JOIN pg_catalog.pg_namespace childn ON childn.oid = child.relnamespace
36294
+ JOIN pg_catalog.pg_class par ON par.oid = i.inhparent
36295
+ JOIN pg_catalog.pg_namespace parn ON parn.oid = par.relnamespace
36296
+ WHERE childn.nspname = $1
36297
+ AND child.relname = $2
36298
+ AND child.relispartition
36299
+ `;
36300
+ const partitionChildrenQuery = `
36301
+ SELECT
36302
+ childn.nspname AS schema,
36303
+ child.relname AS "table",
36304
+ pg_catalog.pg_get_expr(child.relpartbound, child.oid) AS bound
36305
+ FROM pg_catalog.pg_inherits i
36306
+ JOIN pg_catalog.pg_class par ON par.oid = i.inhparent
36307
+ JOIN pg_catalog.pg_namespace parn ON parn.oid = par.relnamespace
36308
+ JOIN pg_catalog.pg_class child ON child.oid = i.inhrelid
36309
+ JOIN pg_catalog.pg_namespace childn ON childn.oid = child.relnamespace
36310
+ WHERE parn.nspname = $1
36311
+ AND par.relname = $2
36312
+ AND child.relispartition
36313
+ ORDER BY childn.nspname, child.relname
36314
+ `;
36315
+ return withSharedClient(async (run) => {
36316
+ const [kindRes, cols, pk, fks, idxs, constraints, referencedBy, partitionParent, partitionChildren] = await Promise.all([
36317
+ run(kindQuery, [schema, table]),
36318
+ run(columnsQuery, [schema, table]),
36319
+ run(primaryKeyQuery, [schema, table]),
36320
+ run(foreignKeysQuery, [schema, table]),
36321
+ run(indexesQuery, [schema, table]),
36322
+ run(constraintsQuery, [schema, table]),
36323
+ run(referencedByQuery, [schema, table]),
36324
+ run(partitionParentQuery, [schema, table]),
36325
+ run(partitionChildrenQuery, [schema, table])
36326
+ ]);
36327
+ if (!cols.ok) return cols;
36328
+ if (!cols.data || cols.data.length === 0) {
36329
+ return {
36330
+ ok: false,
36331
+ error: `Table ${JSON.stringify(schema)}.${JSON.stringify(table)} not found.`
36332
+ };
35988
36333
  }
35989
- };
36334
+ const kind = kindRes.ok ? kindRes.data?.[0]?.kind ?? "table" : "table";
36335
+ const warnings = [];
36336
+ if (!pk.ok) warnings.push(`primary_key fetch failed: ${pk.error}`);
36337
+ if (!fks.ok) warnings.push(`foreign_keys fetch failed: ${fks.error}`);
36338
+ if (!idxs.ok) warnings.push(`indexes fetch failed: ${idxs.error}`);
36339
+ if (!constraints.ok) warnings.push(`constraints fetch failed: ${constraints.error}`);
36340
+ if (!referencedBy.ok) warnings.push(`referenced_by fetch failed: ${referencedBy.error}`);
36341
+ if (!partitionParent.ok) warnings.push(`partition_of fetch failed: ${partitionParent.error}`);
36342
+ if (!partitionChildren.ok) warnings.push(`partitions fetch failed: ${partitionChildren.error}`);
36343
+ const parentRow = partitionParent.ok ? partitionParent.data?.[0] : void 0;
36344
+ return {
36345
+ ok: true,
36346
+ data: {
36347
+ schema,
36348
+ table,
36349
+ kind,
36350
+ columns: cols.data,
36351
+ primary_key: pk.ok ? (pk.data ?? []).map((r) => r.column_name) : [],
36352
+ foreign_keys: fks.ok ? fks.data : [],
36353
+ referenced_by: referencedBy.ok ? referencedBy.data : [],
36354
+ constraints: constraints.ok ? constraints.data : [],
36355
+ indexes: idxs.ok ? idxs.data : [],
36356
+ ...parentRow ? { partition_of: parentRow } : {},
36357
+ ...partitionChildren.ok && (partitionChildren.data ?? []).length > 0 ? { partitions: partitionChildren.data } : {},
36358
+ ...warnings.length > 0 ? { _warnings: warnings } : {}
36359
+ }
36360
+ };
36361
+ });
35990
36362
  }
35991
36363
  },
35992
36364
  {
@@ -36165,8 +36537,12 @@ var statsTools = [
36165
36537
  const meanCol = useExecSuffix ? "mean_exec_time" : "mean_time";
36166
36538
  const minCol = useExecSuffix ? "min_exec_time" : "min_time";
36167
36539
  const maxCol = useExecSuffix ? "max_exec_time" : "max_time";
36168
- const orderCol = orderBy === "total_time" ? totalCol : orderBy === "mean_time" ? meanCol : "calls";
36540
+ const orderCol = orderBy === "total_time" ? totalCol : orderBy === "mean_time" ? meanCol : "pg_stat_statements.calls";
36169
36541
  return runInternal(
36542
+ // bigint counters (calls, rows) come back as `.text` for lossless
36543
+ // serialization, matching pg_seq_scan_tables / pg_unused_indexes /
36544
+ // pg_table_bloat. Timing fields stay as float8 because they are
36545
+ // inherently fractional milliseconds.
36170
36546
  `SELECT
36171
36547
  query,
36172
36548
  calls::text AS calls,
@@ -36271,7 +36647,10 @@ var statsTools = [
36271
36647
  }
36272
36648
  ];
36273
36649
  function compareVersions(a, b) {
36274
- const parse3 = (v) => v.split(".").map((n) => Number.parseInt(n, 10) || 0);
36650
+ const parse3 = (v) => v.split(".").map((seg) => {
36651
+ const m = seg.match(/^\d+/);
36652
+ return m ? Number.parseInt(m[0], 10) : 0;
36653
+ });
36275
36654
  const aa = parse3(a);
36276
36655
  const bb = parse3(b);
36277
36656
  const len = Math.max(aa.length, bb.length);
@@ -36283,7 +36662,7 @@ function compareVersions(a, b) {
36283
36662
  }
36284
36663
 
36285
36664
  // src/index.ts
36286
- var version2 = true ? "0.3.3" : (await null).createRequire(import.meta.url)("../package.json").version;
36665
+ var version2 = true ? "0.4.1" : (await null).createRequire(import.meta.url)("../package.json").version;
36287
36666
  var subcommand = process.argv[2];
36288
36667
  if (subcommand === "version" || subcommand === "--version") {
36289
36668
  console.log(version2);