@motherbase/cli 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -247
  3. package/bin/motherbase.js +10 -10
  4. package/package.json +35 -26
  5. package/src/api.js +233 -241
  6. package/src/commands/_util.js +106 -93
  7. package/src/commands/backups.js +16 -16
  8. package/src/commands/branches.js +113 -113
  9. package/src/commands/completion.js +90 -90
  10. package/src/commands/config.js +47 -47
  11. package/src/commands/cron.js +65 -65
  12. package/src/commands/db-catalog.js +326 -326
  13. package/src/commands/db.js +103 -103
  14. package/src/commands/domains.js +124 -124
  15. package/src/commands/functions.js +304 -297
  16. package/src/commands/gen.js +172 -172
  17. package/src/commands/import.js +55 -55
  18. package/src/commands/index.js +85 -85
  19. package/src/commands/inspect.js +178 -178
  20. package/src/commands/keys.js +72 -72
  21. package/src/commands/link.js +35 -35
  22. package/src/commands/local.js +294 -273
  23. package/src/commands/login.js +39 -39
  24. package/src/commands/logout.js +16 -16
  25. package/src/commands/mcp.js +61 -61
  26. package/src/commands/migrate.js +207 -179
  27. package/src/commands/migration.js +108 -108
  28. package/src/commands/network.js +83 -83
  29. package/src/commands/orgs.js +40 -40
  30. package/src/commands/postgres-config.js +113 -113
  31. package/src/commands/projects.js +130 -130
  32. package/src/commands/queues.js +133 -133
  33. package/src/commands/realtime.js +35 -35
  34. package/src/commands/rls.js +176 -176
  35. package/src/commands/secrets.js +66 -66
  36. package/src/commands/signing.js +130 -130
  37. package/src/commands/snippets.js +44 -44
  38. package/src/commands/status.js +42 -42
  39. package/src/commands/storage.js +261 -261
  40. package/src/commands/test.js +9 -9
  41. package/src/commands/unlink.js +15 -15
  42. package/src/commands/users.js +142 -142
  43. package/src/commands/vault.js +82 -82
  44. package/src/commands/webhooks.js +62 -62
  45. package/src/commands/whoami.js +20 -20
  46. package/src/commands/wrappers.js +150 -150
  47. package/src/config.js +98 -98
  48. package/src/context.js +106 -102
  49. package/src/docs.js +68 -0
  50. package/src/edge-templates.js +194 -194
  51. package/src/format.js +92 -92
  52. package/src/inspect/csv.js +22 -22
  53. package/src/inspect/queries.js +562 -562
  54. package/src/main.js +224 -194
  55. package/src/mcp/platform-tools.js +233 -233
  56. package/src/mcp/protocol.js +152 -149
  57. package/src/mcp/storage-tools.js +93 -93
  58. package/src/mcp/tools.js +478 -478
  59. package/src/migration/commands.js +375 -375
  60. package/src/migration/files.js +245 -245
  61. package/src/prompt.js +91 -91
  62. package/src/storage/remote.js +62 -62
  63. package/src/ui.js +111 -111
@@ -1,562 +1,562 @@
1
- 'use strict';
2
-
3
- // The `inspect db` query set, ported from the Supabase CLI
4
- // (apps/cli/src/commands/inspect/db/*/*.query.ts) so the same subcommand
5
- // answers the same question with the same columns.
6
- //
7
- // Every query runs through the admin API's POST /sql/readonly, which executes it
8
- // in a READ ONLY transaction and cannot bind parameters. The deltas from
9
- // upstream all follow from that, or from running on a plain Postgres rather than
10
- // the supabase/postgres image:
11
- //
12
- // - `$1` (the internal schemas to skip) is inlined as a text[] literal, and
13
- // `$2` (the database name) is `current_database()`.
14
- // - pg_stat_statements is referenced unqualified: `CREATE EXTENSION` puts it on
15
- // the search_path, not in an `extensions` schema. It is preloaded by our
16
- // Postgres image but may not be created in a given database, so the queries
17
- // that need it are flagged `needsStatStatements` for a clearer error.
18
- // - numeric results are cast to text. The readonly endpoint serialises a
19
- // Prisma Decimal as its internal `{ s, e, d }` fields, not as a number.
20
- // - vacuum-stats prints `No stats` in SQL, where upstream post-processes the
21
- // rendered cell.
22
-
23
- // Schemas the queries exclude, verbatim from upstream's inspect-schemas.ts.
24
- const INTERNAL_SCHEMAS = [
25
- 'information_schema',
26
- 'pg_*',
27
- '_analytics', '_realtime', '_supavisor', 'auth', 'etl', 'extensions', 'pgbouncer',
28
- 'realtime', 'storage', 'supabase_functions', 'supabase_migrations',
29
- 'cron', 'dbdev', 'graphql', 'graphql_public', 'net', 'pgmq', 'pgsodium',
30
- 'pgsodium_masks', 'pgtle', 'repack', 'tiger', 'tiger_data', 'timescaledb_*',
31
- '_timescaledb_*', 'topology', 'vault',
32
- ];
33
-
34
- // A schema name as a LIKE pattern: `_` and `\` are literal, `*` is the wildcard.
35
- function likePattern(schema) {
36
- return schema.replace(/[\\_*]/g, char => (char === '*' ? '%' : `\\${char}`));
37
- }
38
-
39
- const IGNORED_SCHEMAS = `'{${INTERNAL_SCHEMAS.map(likePattern).join(',')}}'::text[]`;
40
-
41
- // Column shorthand: `[key, label]` pairs, in display order.
42
- const cols = pairs => pairs.map(([key, label]) => ({ key, label }));
43
-
44
- // pg_stat_statements' timing columns were renamed in PG17; to_jsonb reads
45
- // whichever this server has. Shared by `calls` and `outliers`.
46
- const SYNC_IO_TIME = `(
47
- interval '1 millisecond' * (
48
- COALESCE((to_jsonb(s) ->> 'shared_blk_read_time')::double precision, (to_jsonb(s) ->> 'blk_read_time')::double precision, 0)
49
- + COALESCE((to_jsonb(s) ->> 'shared_blk_write_time')::double precision, (to_jsonb(s) ->> 'blk_write_time')::double precision, 0)
50
- )
51
- )::text AS sync_io_time`;
52
-
53
- const QUERIES = {
54
- bloat: {
55
- summary: 'Estimate space wasted by dead tuples in tables and indexes',
56
- columns: cols([['type', 'Type'], ['name', 'Name'], ['bloat', 'Bloat'], ['waste', 'Waste']]),
57
- sql: `WITH constants AS (
58
- SELECT current_setting('block_size')::numeric AS bs, 23 AS hdr, 4 AS ma
59
- ), bloat_info AS (
60
- SELECT
61
- ma,bs,schemaname,tablename,
62
- (datawidth+(hdr+ma-(case when hdr%ma=0 THEN ma ELSE hdr%ma END)))::numeric AS datahdr,
63
- (maxfracsum*(nullhdr+ma-(case when nullhdr%ma=0 THEN ma ELSE nullhdr%ma END))) AS nullhdr2
64
- FROM (
65
- SELECT
66
- schemaname, tablename, hdr, ma, bs,
67
- SUM((1-null_frac)*avg_width) AS datawidth,
68
- MAX(null_frac) AS maxfracsum,
69
- hdr+(
70
- SELECT 1+count(*)/8
71
- FROM pg_stats s2
72
- WHERE null_frac<>0 AND s2.schemaname = s.schemaname AND s2.tablename = s.tablename
73
- ) AS nullhdr
74
- FROM pg_stats s, constants
75
- GROUP BY 1,2,3,4,5
76
- ) AS foo
77
- ), table_bloat AS (
78
- SELECT
79
- schemaname, tablename, cc.relpages, bs,
80
- CEIL((cc.reltuples*((datahdr+ma-
81
- (CASE WHEN datahdr%ma=0 THEN ma ELSE datahdr%ma END))+nullhdr2+4))/(bs-20::float)) AS otta
82
- FROM bloat_info
83
- JOIN pg_class cc ON cc.relname = bloat_info.tablename
84
- JOIN pg_namespace nn ON cc.relnamespace = nn.oid AND nn.nspname = bloat_info.schemaname
85
- WHERE NOT nn.nspname LIKE ANY(${IGNORED_SCHEMAS})
86
- ), index_bloat AS (
87
- SELECT
88
- schemaname, tablename, bs,
89
- COALESCE(c2.relname,'?') AS iname, COALESCE(c2.reltuples,0) AS ituples, COALESCE(c2.relpages,0) AS ipages,
90
- COALESCE(CEIL((c2.reltuples*(datahdr-12))/(bs-20::float)),0) AS iotta
91
- FROM bloat_info
92
- JOIN pg_class cc ON cc.relname = bloat_info.tablename
93
- JOIN pg_namespace nn ON cc.relnamespace = nn.oid AND nn.nspname = bloat_info.schemaname
94
- JOIN pg_index i ON indrelid = cc.oid
95
- JOIN pg_class c2 ON c2.oid = i.indexrelid
96
- WHERE NOT nn.nspname LIKE ANY(${IGNORED_SCHEMAS})
97
- ), bloat_summary AS (
98
- SELECT
99
- 'table' as type,
100
- FORMAT('%I.%I', schemaname, tablename) AS name,
101
- ROUND(CASE WHEN otta=0 THEN 0.0 ELSE table_bloat.relpages/otta::numeric END,1) AS bloat,
102
- CASE WHEN relpages < otta THEN '0' ELSE (bs*(table_bloat.relpages-otta)::bigint)::bigint END AS raw_waste
103
- FROM table_bloat
104
- UNION
105
- SELECT
106
- 'index' as type,
107
- FORMAT('%I.%I::%I', schemaname, tablename, iname) AS name,
108
- ROUND(CASE WHEN iotta=0 OR ipages=0 THEN 0.0 ELSE ipages/iotta::numeric END,1) AS bloat,
109
- CASE WHEN ipages < iotta THEN '0' ELSE (bs*(ipages-iotta))::bigint END AS raw_waste
110
- FROM index_bloat
111
- )
112
- SELECT type, name, bloat::text AS bloat, pg_size_pretty(raw_waste) as waste
113
- FROM bloat_summary
114
- ORDER BY raw_waste DESC, bloat_summary.bloat DESC`,
115
- },
116
-
117
- blocking: {
118
- summary: 'Queries holding locks and the queries waiting on them',
119
- columns: cols([
120
- ['blocked_pid', 'blocked pid'], ['blocking_statement', 'blocking statement'],
121
- ['blocking_duration', 'blocking duration'], ['blocking_pid', 'blocking pid'],
122
- ['blocked_statement', 'blocked statement'], ['blocked_duration', 'blocked duration'],
123
- ]),
124
- sql: `SELECT
125
- bl.pid AS blocked_pid,
126
- ka.query AS blocking_statement,
127
- age(now(), ka.query_start)::text AS blocking_duration,
128
- kl.pid AS blocking_pid,
129
- a.query AS blocked_statement,
130
- age(now(), a.query_start)::text AS blocked_duration
131
- FROM pg_catalog.pg_locks bl
132
- JOIN pg_catalog.pg_stat_activity a
133
- ON bl.pid = a.pid
134
- JOIN pg_catalog.pg_locks kl
135
- JOIN pg_catalog.pg_stat_activity ka
136
- ON kl.pid = ka.pid
137
- ON bl.transactionid = kl.transactionid AND bl.pid != kl.pid
138
- WHERE NOT bl.granted`,
139
- },
140
-
141
- calls: {
142
- summary: 'Queries ordered by number of calls (pg_stat_statements)',
143
- needsStatStatements: true,
144
- columns: cols([
145
- ['query', 'Query'], ['total_exec_time', 'Total Execution Time'],
146
- ['prop_exec_time', 'Proportion of total exec time'], ['ncalls', 'Number Calls'],
147
- ['sync_io_time', 'Sync IO time'],
148
- ]),
149
- sql: `SELECT
150
- query,
151
- (interval '1 millisecond' * total_exec_time)::text AS total_exec_time,
152
- to_char((total_exec_time/sum(total_exec_time) OVER()) * 100, 'FM90D0') || '%' AS prop_exec_time,
153
- to_char(calls, 'FM999G999G999G999G990') AS ncalls,
154
- ${SYNC_IO_TIME}
155
- FROM pg_stat_statements s
156
- ORDER BY calls DESC
157
- LIMIT 10`,
158
- },
159
-
160
- 'db-stats': {
161
- summary: 'Database size, cache hit rates and WAL size',
162
- columns: cols([
163
- ['name', 'Name'], ['database_size', 'Database Size'], ['total_index_size', 'Total Index Size'],
164
- ['total_table_size', 'Total Table Size'], ['total_toast_size', 'Total Toast Size'],
165
- ['time_since_stats_reset', 'Time Since Stats Reset'], ['index_hit_rate', 'Index Hit Rate'],
166
- ['table_hit_rate', 'Table Hit Rate'], ['wal_size', 'WAL Size'],
167
- ]),
168
- // Upstream reads pg_stat_statements_info unconditionally, which would fail
169
- // the whole summary on a database without the extension. query_to_xml
170
- // defers the reference to run time, and the CASE never reaches it when the
171
- // view does not exist.
172
- sql: `WITH total_objects AS (
173
- SELECT c.relkind, pg_size_pretty(SUM(pg_relation_size(c.oid))) AS size
174
- FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
175
- WHERE c.relkind IN ('i', 'r', 't') AND NOT n.nspname LIKE ANY(${IGNORED_SCHEMAS})
176
- GROUP BY c.relkind
177
- ), cache_hit AS (
178
- SELECT
179
- 'i' AS relkind,
180
- ROUND(SUM(idx_blks_hit)::numeric / nullif(SUM(idx_blks_hit + idx_blks_read), 0), 2) AS ratio
181
- FROM pg_statio_user_indexes
182
- WHERE NOT schemaname LIKE ANY(${IGNORED_SCHEMAS})
183
- UNION
184
- SELECT
185
- 't' AS relkind,
186
- ROUND(
187
- SUM(COALESCE((to_jsonb(s) ->> 'rel_blks_hit')::bigint, (to_jsonb(s) ->> 'heap_blks_hit')::bigint, 0))::numeric
188
- / nullif(SUM(
189
- COALESCE((to_jsonb(s) ->> 'rel_blks_hit')::bigint, (to_jsonb(s) ->> 'heap_blks_hit')::bigint, 0)
190
- + COALESCE((to_jsonb(s) ->> 'rel_blks_read')::bigint, (to_jsonb(s) ->> 'heap_blks_read')::bigint, 0)
191
- ), 0),
192
- 2
193
- ) AS ratio
194
- FROM pg_statio_user_tables s
195
- WHERE NOT schemaname LIKE ANY(${IGNORED_SCHEMAS})
196
- )
197
- SELECT
198
- current_database() AS name,
199
- pg_size_pretty(pg_database_size(current_database())) AS database_size,
200
- COALESCE((SELECT size FROM total_objects WHERE relkind = 'i'), '0 bytes') AS total_index_size,
201
- COALESCE((SELECT size FROM total_objects WHERE relkind = 'r'), '0 bytes') AS total_table_size,
202
- COALESCE((SELECT size FROM total_objects WHERE relkind = 't'), '0 bytes') AS total_toast_size,
203
- CASE WHEN to_regclass('pg_stat_statements_info') IS NULL THEN 'N/A' ELSE COALESCE((xpath(
204
- '/row/age/text()',
205
- query_to_xml('SELECT (now() - stats_reset)::text AS age FROM pg_stat_statements_info', false, true, '')
206
- ))[1]::text, 'N/A') END AS time_since_stats_reset,
207
- (SELECT COALESCE(ratio::text, 'N/A') FROM cache_hit WHERE relkind = 'i') AS index_hit_rate,
208
- (SELECT COALESCE(ratio::text, 'N/A') FROM cache_hit WHERE relkind = 't') AS table_hit_rate,
209
- COALESCE((SELECT pg_size_pretty(SUM(size)) FROM pg_ls_waldir()), '0 bytes') AS wal_size`,
210
- },
211
-
212
- 'index-stats': {
213
- summary: 'Index size, usage, scan counts and unused indexes',
214
- columns: cols([
215
- ['name', 'Name'], ['table', 'Table'], ['columns', 'Columns'], ['size', 'Size'],
216
- ['percent_used', 'Percent used'], ['index_scans', 'Index scans'], ['seq_scans', 'Seq scans'],
217
- ['unused', 'Unused'],
218
- ]),
219
- sql: `WITH idx_sizes AS (
220
- SELECT
221
- i.indexrelid AS oid,
222
- FORMAT('%I.%I', n.nspname, c.relname) AS name,
223
- FORMAT('%I.%I', tn.nspname, tc.relname) AS table_name,
224
- (
225
- SELECT STRING_AGG(pg_get_indexdef(i.indexrelid, ord::int, false), ',' ORDER BY ord)
226
- FROM unnest(i.indkey::int[]) WITH ORDINALITY AS k(attnum, ord)
227
- ) AS columns,
228
- pg_relation_size(i.indexrelid) AS index_size_bytes
229
- FROM pg_stat_user_indexes ui
230
- JOIN pg_index i ON ui.indexrelid = i.indexrelid
231
- JOIN pg_class c ON ui.indexrelid = c.oid
232
- JOIN pg_namespace n ON c.relnamespace = n.oid
233
- JOIN pg_class tc ON tc.oid = i.indrelid
234
- JOIN pg_namespace tn ON tn.oid = tc.relnamespace
235
- WHERE NOT n.nspname LIKE ANY(${IGNORED_SCHEMAS})
236
- ),
237
- idx_usage AS (
238
- SELECT indexrelid AS oid, idx_scan::bigint AS idx_scans
239
- FROM pg_stat_user_indexes ui
240
- WHERE NOT schemaname LIKE ANY(${IGNORED_SCHEMAS})
241
- ),
242
- seq_usage AS (
243
- SELECT relid AS oid, seq_scan::bigint AS seq_scans
244
- FROM pg_stat_user_tables
245
- WHERE NOT schemaname LIKE ANY(${IGNORED_SCHEMAS})
246
- ),
247
- usage_pct AS (
248
- SELECT
249
- u.oid,
250
- CASE
251
- WHEN u.idx_scans IS NULL OR u.idx_scans = 0 THEN 0
252
- WHEN s.seq_scans IS NULL THEN 100
253
- ELSE ROUND(100.0 * u.idx_scans / (s.seq_scans + u.idx_scans), 1)
254
- END AS percent_used
255
- FROM idx_usage u
256
- LEFT JOIN seq_usage s ON s.oid = u.oid
257
- )
258
- SELECT
259
- s.name,
260
- s.table_name AS "table",
261
- s.columns,
262
- pg_size_pretty(s.index_size_bytes) AS size,
263
- COALESCE(up.percent_used, 0)::text || '%' AS percent_used,
264
- COALESCE(u.idx_scans, 0) AS index_scans,
265
- COALESCE(sq.seq_scans, 0) AS seq_scans,
266
- CASE WHEN COALESCE(u.idx_scans, 0) = 0 THEN true ELSE false END AS unused
267
- FROM idx_sizes s
268
- LEFT JOIN idx_usage u ON u.oid = s.oid
269
- LEFT JOIN seq_usage sq ON sq.oid = s.oid
270
- LEFT JOIN usage_pct up ON up.oid = s.oid
271
- ORDER BY s.index_size_bytes DESC`,
272
- },
273
-
274
- locks: {
275
- summary: 'Queries holding an exclusive lock on a relation',
276
- columns: cols([
277
- ['pid', 'pid'], ['relname', 'relname'], ['transactionid', 'transaction id'],
278
- ['granted', 'granted'], ['stmt', 'stmt'], ['age', 'age'],
279
- ]),
280
- sql: `SELECT
281
- pg_stat_activity.pid,
282
- COALESCE(pg_class.relname, 'null') AS relname,
283
- COALESCE(pg_locks.transactionid::text, 'null') AS transactionid,
284
- pg_locks.granted,
285
- pg_stat_activity.query AS stmt,
286
- age(now(), pg_stat_activity.query_start)::text AS age
287
- FROM pg_stat_activity, pg_locks LEFT OUTER JOIN pg_class ON (pg_locks.relation = pg_class.oid)
288
- WHERE pg_stat_activity.query <> '<insufficient privilege>'
289
- AND pg_locks.pid = pg_stat_activity.pid
290
- AND pg_locks.mode = 'ExclusiveLock'
291
- ORDER BY query_start`,
292
- },
293
-
294
- 'long-running-queries': {
295
- summary: 'Queries running for longer than five minutes',
296
- columns: cols([['pid', 'pid'], ['duration', 'Duration'], ['query', 'Query']]),
297
- sql: `SELECT
298
- pid,
299
- age(now(), pg_stat_activity.query_start)::text AS duration,
300
- query AS query
301
- FROM pg_stat_activity
302
- WHERE pg_stat_activity.query <> ''::text
303
- AND state <> 'idle'
304
- AND age(now(), pg_stat_activity.query_start) > interval '5 minutes'
305
- ORDER BY age(now(), pg_stat_activity.query_start) DESC`,
306
- },
307
-
308
- outliers: {
309
- summary: 'Queries ordered by total execution time (pg_stat_statements)',
310
- needsStatStatements: true,
311
- columns: cols([
312
- ['query', 'Query'], ['total_exec_time', 'Execution Time'],
313
- ['prop_exec_time', 'Proportion of exec time'], ['ncalls', 'Number Calls'],
314
- ['sync_io_time', 'Sync IO time'],
315
- ]),
316
- sql: `SELECT
317
- (interval '1 millisecond' * total_exec_time)::text AS total_exec_time,
318
- to_char((total_exec_time/sum(total_exec_time) OVER()) * 100, 'FM90D0') || '%' AS prop_exec_time,
319
- to_char(calls, 'FM999G999G999G990') AS ncalls,
320
- ${SYNC_IO_TIME},
321
- query
322
- FROM pg_stat_statements s WHERE userid = (SELECT usesysid FROM pg_user WHERE usename = current_user LIMIT 1)
323
- ORDER BY total_exec_time DESC
324
- LIMIT 10`,
325
- },
326
-
327
- 'replication-slots': {
328
- summary: 'Replication slots and their lag',
329
- columns: cols([
330
- ['slot_name', 'Name'], ['active', 'Active'], ['state', 'State'],
331
- ['replication_client_address', 'Replication Client Address'],
332
- ['replication_lag_gb', 'Replication Lag GB'],
333
- ]),
334
- sql: `SELECT
335
- s.slot_name,
336
- s.active,
337
- COALESCE(r.state, 'N/A') as state,
338
- CASE WHEN r.client_addr IS NULL
339
- THEN 'N/A'
340
- ELSE r.client_addr::text
341
- END replication_client_address,
342
- GREATEST(0, ROUND((redo_lsn-restart_lsn)/1024/1024/1024, 2))::text as replication_lag_gb
343
- FROM pg_control_checkpoint(), pg_replication_slots s
344
- LEFT JOIN pg_stat_replication r ON (r.pid = s.active_pid)`,
345
- },
346
-
347
- 'role-stats': {
348
- summary: 'Roles with their connection counts, limits and custom config',
349
- columns: cols([
350
- ['role_name', 'Role name'], ['active_connections', 'Active connections'],
351
- ['connection_limit', 'Connection limit'], ['custom_config', 'Custom config'],
352
- ]),
353
- sql: `SELECT
354
- rolname as role_name,
355
- (SELECT count(*) FROM pg_stat_activity WHERE pg_roles.rolname = pg_stat_activity.usename) AS active_connections,
356
- CASE WHEN rolconnlimit = -1
357
- THEN current_setting('max_connections')::int8
358
- ELSE rolconnlimit
359
- END AS connection_limit,
360
- array_to_string(rolconfig, ',', '*') as custom_config
361
- FROM pg_roles
362
- ORDER BY 1 DESC`,
363
- },
364
-
365
- 'table-stats': {
366
- summary: 'Table size, index size and estimated row count',
367
- columns: cols([
368
- ['name', 'Name'], ['table_size', 'Table size'], ['index_size', 'Index size'],
369
- ['total_size', 'Total size'], ['estimated_row_count', 'Estimated row count'],
370
- ['seq_scans', 'Seq scans'],
371
- ]),
372
- sql: `SELECT
373
- ts.name,
374
- pg_size_pretty(ts.table_size_bytes) AS table_size,
375
- pg_size_pretty(ts.index_size_bytes) AS index_size,
376
- pg_size_pretty(ts.total_size_bytes) AS total_size,
377
- COALESCE(rc.estimated_row_count, 0) AS estimated_row_count,
378
- COALESCE(rc.seq_scans, 0) AS seq_scans
379
- FROM (
380
- SELECT
381
- FORMAT('%I.%I', n.nspname, c.relname) AS name,
382
- pg_table_size(c.oid) AS table_size_bytes,
383
- pg_indexes_size(c.oid) AS index_size_bytes,
384
- pg_total_relation_size(c.oid) AS total_size_bytes
385
- FROM pg_class c
386
- LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
387
- WHERE NOT n.nspname LIKE ANY(${IGNORED_SCHEMAS})
388
- AND c.relkind = 'r'
389
- ) ts
390
- LEFT JOIN (
391
- SELECT
392
- FORMAT('%I.%I', schemaname, relname) AS name,
393
- n_live_tup AS estimated_row_count,
394
- seq_scan AS seq_scans
395
- FROM pg_stat_user_tables
396
- WHERE NOT schemaname LIKE ANY(${IGNORED_SCHEMAS})
397
- ) rc ON rc.name = ts.name
398
- ORDER BY ts.total_size_bytes DESC`,
399
- },
400
-
401
- 'traffic-profile': {
402
- summary: 'Read/write balance of each table',
403
- columns: cols([
404
- ['schemaname', 'Schema'], ['table_name', 'Table'], ['blocks_read', 'Blocks Read'],
405
- ['write_tuples', 'Write Tuples'], ['blocks_write', 'Blocks Write'], ['activity_ratio', 'Activity Ratio'],
406
- ]),
407
- // Adapted upstream from Crunchy Data's "Is Postgres Read Heavy or Write Heavy?".
408
- sql: `WITH
409
- ratio_target AS (SELECT 5 AS ratio),
410
- table_list AS (SELECT
411
- s.schemaname,
412
- s.relname AS table_name,
413
- si.heap_blks_read + si.idx_blks_read AS blocks_read,
414
- s.n_tup_ins + s.n_tup_upd + s.n_tup_del AS write_tuples,
415
- relpages * (s.n_tup_ins + s.n_tup_upd + s.n_tup_del ) / (case when reltuples = 0 then 1 else reltuples end) as blocks_write
416
- FROM pg_stat_user_tables AS s
417
- JOIN pg_statio_user_tables AS si ON s.relid = si.relid
418
- JOIN pg_class c ON c.oid = s.relid
419
- WHERE (s.n_tup_ins + s.n_tup_upd + s.n_tup_del) > 0
420
- AND (si.heap_blks_read + si.idx_blks_read) > 0
421
- )
422
- SELECT
423
- schemaname,
424
- table_name,
425
- blocks_read,
426
- write_tuples,
427
- to_char(blocks_write, 'FM999999999990.0') AS blocks_write,
428
- CASE
429
- WHEN blocks_read = 0 and blocks_write = 0 THEN 'No Activity'
430
- WHEN blocks_write * ratio > blocks_read THEN
431
- CASE
432
- WHEN blocks_read = 0 THEN 'Write-Only'
433
- ELSE ROUND(blocks_write :: numeric / blocks_read :: numeric, 1)::text || ':1 (Write-Heavy)'
434
- END
435
- WHEN blocks_read > blocks_write * ratio THEN
436
- CASE
437
- WHEN blocks_write = 0 THEN 'Read-Only'
438
- ELSE '1:' || ROUND(blocks_read::numeric / blocks_write :: numeric, 1)::text || ' (Read-Heavy)'
439
- END
440
- ELSE '1:1 (Balanced)'
441
- END AS activity_ratio
442
- FROM table_list, ratio_target
443
- ORDER BY (blocks_read + table_list.blocks_write) DESC`,
444
- },
445
-
446
- 'vacuum-stats': {
447
- summary: 'Vacuum and analyze history, and whether autovacuum is due',
448
- columns: cols([
449
- ['name', 'Table'], ['last_vacuum', 'Last Vacuum'], ['last_autovacuum', 'Last Auto Vacuum'],
450
- ['last_analyze', 'Last Analyze'], ['last_autoanalyze', 'Last Auto Analyze'],
451
- ['rowcount', 'Row count'], ['dead_rowcount', 'Dead row count'],
452
- ['expect_autovacuum', 'Expect autovacuum?'], ['expect_autoanalyze', 'Expect autoanalyze?'],
453
- ]),
454
- sql: `WITH table_opts AS (
455
- SELECT pg_class.oid, relname, nspname, array_to_string(reloptions, '') AS relopts
456
- FROM pg_class INNER JOIN pg_namespace ns ON relnamespace = ns.oid
457
- ), vacuum_settings AS (
458
- SELECT
459
- oid, relname, nspname,
460
- CASE
461
- WHEN relopts LIKE '%autovacuum_vacuum_threshold%'
462
- THEN substring(relopts, '.*autovacuum_vacuum_threshold=([0-9.]+).*')::integer
463
- ELSE current_setting('autovacuum_vacuum_threshold')::integer
464
- END AS autovacuum_vacuum_threshold,
465
- CASE
466
- WHEN relopts LIKE '%autovacuum_vacuum_scale_factor%'
467
- THEN substring(relopts, '.*autovacuum_vacuum_scale_factor=([0-9.]+).*')::real
468
- ELSE current_setting('autovacuum_vacuum_scale_factor')::real
469
- END AS autovacuum_vacuum_scale_factor,
470
- CASE
471
- WHEN relopts LIKE '%autovacuum_analyze_threshold%'
472
- THEN substring(relopts, '.*autovacuum_analyze_threshold=([0-9.]+).*')::integer
473
- ELSE current_setting('autovacuum_analyze_threshold')::integer
474
- END AS autovacuum_analyze_threshold,
475
- CASE
476
- WHEN relopts LIKE '%autovacuum_analyze_scale_factor%'
477
- THEN substring(relopts, '.*autovacuum_analyze_scale_factor=([0-9.]+).*')::real
478
- ELSE current_setting('autovacuum_analyze_scale_factor')::real
479
- END AS autovacuum_analyze_scale_factor
480
- FROM table_opts
481
- )
482
- SELECT
483
- FORMAT('%I.%I', vacuum_settings.nspname, vacuum_settings.relname) AS name,
484
- coalesce(to_char(psut.last_vacuum, 'YYYY-MM-DD HH24:MI'), '') AS last_vacuum,
485
- coalesce(to_char(psut.last_autovacuum, 'YYYY-MM-DD HH24:MI'), '') AS last_autovacuum,
486
- coalesce(to_char(psut.last_analyze, 'YYYY-MM-DD HH24:MI'), '') AS last_analyze,
487
- coalesce(to_char(psut.last_autoanalyze, 'YYYY-MM-DD HH24:MI'), '') AS last_autoanalyze,
488
- CASE WHEN pg_class.reltuples = -1 THEN 'No stats' ELSE to_char(pg_class.reltuples, '9G999G999G999') END AS rowcount,
489
- to_char(psut.n_dead_tup, '9G999G999G999') AS dead_rowcount,
490
- to_char(autovacuum_vacuum_threshold
491
- + (autovacuum_vacuum_scale_factor::numeric * pg_class.reltuples), '9G999G999G999') AS autovacuum_threshold,
492
- CASE
493
- WHEN autovacuum_vacuum_threshold + (autovacuum_vacuum_scale_factor::numeric * pg_class.reltuples) < psut.n_dead_tup
494
- THEN 'yes'
495
- ELSE 'no'
496
- END AS expect_autovacuum,
497
- to_char(autovacuum_analyze_threshold
498
- + (autovacuum_analyze_scale_factor::numeric * pg_class.reltuples), '9G999G999G999') AS autoanalyze_threshold,
499
- CASE
500
- WHEN autovacuum_analyze_threshold + (autovacuum_analyze_scale_factor::numeric * pg_class.reltuples) < psut.n_dead_tup
501
- THEN 'yes'
502
- ELSE 'no'
503
- END AS expect_autoanalyze
504
- FROM pg_stat_user_tables psut INNER JOIN pg_class ON psut.relid = pg_class.oid
505
- INNER JOIN vacuum_settings ON pg_class.oid = vacuum_settings.oid
506
- WHERE NOT vacuum_settings.nspname LIKE ANY(${IGNORED_SCHEMAS})
507
- ORDER BY
508
- case when pg_class.reltuples = -1 then 1 else 0 end,
509
- 1`,
510
- },
511
- };
512
-
513
- // Names Supabase has folded into the queries above and still accepts, each
514
- // routed to the query that replaced it (upstream's deprecated handlers).
515
- const DEPRECATED = {
516
- 'cache-hit': 'db-stats',
517
- 'index-sizes': 'index-stats',
518
- 'index-usage': 'index-stats',
519
- 'seq-scans': 'index-stats',
520
- 'table-record-counts': 'index-stats',
521
- 'total-index-size': 'index-stats',
522
- 'unused-indexes': 'index-stats',
523
- 'role-configs': 'role-stats',
524
- 'role-connections': 'role-stats',
525
- 'table-index-sizes': 'table-stats',
526
- 'table-sizes': 'table-stats',
527
- 'total-table-sizes': 'table-stats',
528
- };
529
-
530
- // `inspect report` has no query of its own for unused indexes (the subcommand
531
- // is a deprecated alias), so it carries upstream's standalone one.
532
- const UNUSED_INDEXES = {
533
- columns: cols([['name', 'name'], ['index', 'index'], ['index_size', 'index_size'], ['index_scans', 'index_scans']]),
534
- sql: `SELECT
535
- FORMAT('%I.%I', schemaname, relname) AS name,
536
- indexrelname AS index,
537
- pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
538
- idx_scan as index_scans
539
- FROM pg_stat_user_indexes ui
540
- JOIN pg_index i ON ui.indexrelid = i.indexrelid
541
- WHERE
542
- NOT indisunique AND idx_scan < 50 AND pg_relation_size(relid) > 5 * 8192
543
- AND NOT schemaname LIKE ANY(${IGNORED_SCHEMAS})
544
- ORDER BY
545
- pg_relation_size(i.indexrelid) / nullif(idx_scan, 0) DESC NULLS FIRST,
546
- pg_relation_size(i.indexrelid) DESC`,
547
- };
548
-
549
- // The report's files, named like upstream's CSVs (`db_stats.csv`), in its order.
550
- const REPORT = [
551
- ...Object.keys(QUERIES).map(name => ({ file: name.replace(/-/g, '_'), name, query: QUERIES[name] })),
552
- { file: 'unused_indexes', name: 'unused-indexes', query: UNUSED_INDEXES },
553
- ].sort((a, b) => a.file.localeCompare(b.file));
554
-
555
- /** The query a subcommand name runs, following deprecated names to their replacement. */
556
- function resolveQuery(name) {
557
- const canonical = DEPRECATED[name] || name;
558
- const query = QUERIES[canonical];
559
- return query ? { name: canonical, query } : undefined;
560
- }
561
-
562
- module.exports = { QUERIES, DEPRECATED, REPORT, INTERNAL_SCHEMAS, resolveQuery, likePattern };
1
+ 'use strict';
2
+
3
+ // The `inspect db` query set, ported from the Supabase CLI
4
+ // (apps/cli/src/commands/inspect/db/*/*.query.ts) so the same subcommand
5
+ // answers the same question with the same columns.
6
+ //
7
+ // Every query runs through the admin API's POST /sql/readonly, which executes it
8
+ // in a READ ONLY transaction and cannot bind parameters. The deltas from
9
+ // upstream all follow from that, or from running on a plain Postgres rather than
10
+ // the supabase/postgres image:
11
+ //
12
+ // - `$1` (the internal schemas to skip) is inlined as a text[] literal, and
13
+ // `$2` (the database name) is `current_database()`.
14
+ // - pg_stat_statements is referenced unqualified: `CREATE EXTENSION` puts it on
15
+ // the search_path, not in an `extensions` schema. It is preloaded by our
16
+ // Postgres image but may not be created in a given database, so the queries
17
+ // that need it are flagged `needsStatStatements` for a clearer error.
18
+ // - numeric results are cast to text. The readonly endpoint serialises a
19
+ // Prisma Decimal as its internal `{ s, e, d }` fields, not as a number.
20
+ // - vacuum-stats prints `No stats` in SQL, where upstream post-processes the
21
+ // rendered cell.
22
+
23
+ // Schemas the queries exclude, verbatim from upstream's inspect-schemas.ts.
24
+ const INTERNAL_SCHEMAS = [
25
+ 'information_schema',
26
+ 'pg_*',
27
+ '_analytics', '_realtime', '_supavisor', 'auth', 'etl', 'extensions', 'pgbouncer',
28
+ 'realtime', 'storage', 'supabase_functions', 'supabase_migrations',
29
+ 'cron', 'dbdev', 'graphql', 'graphql_public', 'net', 'pgmq', 'pgsodium',
30
+ 'pgsodium_masks', 'pgtle', 'repack', 'tiger', 'tiger_data', 'timescaledb_*',
31
+ '_timescaledb_*', 'topology', 'vault',
32
+ ];
33
+
34
+ // A schema name as a LIKE pattern: `_` and `\` are literal, `*` is the wildcard.
35
+ function likePattern(schema) {
36
+ return schema.replace(/[\\_*]/g, char => (char === '*' ? '%' : `\\${char}`));
37
+ }
38
+
39
+ const IGNORED_SCHEMAS = `'{${INTERNAL_SCHEMAS.map(likePattern).join(',')}}'::text[]`;
40
+
41
+ // Column shorthand: `[key, label]` pairs, in display order.
42
+ const cols = pairs => pairs.map(([key, label]) => ({ key, label }));
43
+
44
+ // pg_stat_statements' timing columns were renamed in PG17; to_jsonb reads
45
+ // whichever this server has. Shared by `calls` and `outliers`.
46
+ const SYNC_IO_TIME = `(
47
+ interval '1 millisecond' * (
48
+ COALESCE((to_jsonb(s) ->> 'shared_blk_read_time')::double precision, (to_jsonb(s) ->> 'blk_read_time')::double precision, 0)
49
+ + COALESCE((to_jsonb(s) ->> 'shared_blk_write_time')::double precision, (to_jsonb(s) ->> 'blk_write_time')::double precision, 0)
50
+ )
51
+ )::text AS sync_io_time`;
52
+
53
+ const QUERIES = {
54
+ bloat: {
55
+ summary: 'Estimate space wasted by dead tuples in tables and indexes',
56
+ columns: cols([['type', 'Type'], ['name', 'Name'], ['bloat', 'Bloat'], ['waste', 'Waste']]),
57
+ sql: `WITH constants AS (
58
+ SELECT current_setting('block_size')::numeric AS bs, 23 AS hdr, 4 AS ma
59
+ ), bloat_info AS (
60
+ SELECT
61
+ ma,bs,schemaname,tablename,
62
+ (datawidth+(hdr+ma-(case when hdr%ma=0 THEN ma ELSE hdr%ma END)))::numeric AS datahdr,
63
+ (maxfracsum*(nullhdr+ma-(case when nullhdr%ma=0 THEN ma ELSE nullhdr%ma END))) AS nullhdr2
64
+ FROM (
65
+ SELECT
66
+ schemaname, tablename, hdr, ma, bs,
67
+ SUM((1-null_frac)*avg_width) AS datawidth,
68
+ MAX(null_frac) AS maxfracsum,
69
+ hdr+(
70
+ SELECT 1+count(*)/8
71
+ FROM pg_stats s2
72
+ WHERE null_frac<>0 AND s2.schemaname = s.schemaname AND s2.tablename = s.tablename
73
+ ) AS nullhdr
74
+ FROM pg_stats s, constants
75
+ GROUP BY 1,2,3,4,5
76
+ ) AS foo
77
+ ), table_bloat AS (
78
+ SELECT
79
+ schemaname, tablename, cc.relpages, bs,
80
+ CEIL((cc.reltuples*((datahdr+ma-
81
+ (CASE WHEN datahdr%ma=0 THEN ma ELSE datahdr%ma END))+nullhdr2+4))/(bs-20::float)) AS otta
82
+ FROM bloat_info
83
+ JOIN pg_class cc ON cc.relname = bloat_info.tablename
84
+ JOIN pg_namespace nn ON cc.relnamespace = nn.oid AND nn.nspname = bloat_info.schemaname
85
+ WHERE NOT nn.nspname LIKE ANY(${IGNORED_SCHEMAS})
86
+ ), index_bloat AS (
87
+ SELECT
88
+ schemaname, tablename, bs,
89
+ COALESCE(c2.relname,'?') AS iname, COALESCE(c2.reltuples,0) AS ituples, COALESCE(c2.relpages,0) AS ipages,
90
+ COALESCE(CEIL((c2.reltuples*(datahdr-12))/(bs-20::float)),0) AS iotta
91
+ FROM bloat_info
92
+ JOIN pg_class cc ON cc.relname = bloat_info.tablename
93
+ JOIN pg_namespace nn ON cc.relnamespace = nn.oid AND nn.nspname = bloat_info.schemaname
94
+ JOIN pg_index i ON indrelid = cc.oid
95
+ JOIN pg_class c2 ON c2.oid = i.indexrelid
96
+ WHERE NOT nn.nspname LIKE ANY(${IGNORED_SCHEMAS})
97
+ ), bloat_summary AS (
98
+ SELECT
99
+ 'table' as type,
100
+ FORMAT('%I.%I', schemaname, tablename) AS name,
101
+ ROUND(CASE WHEN otta=0 THEN 0.0 ELSE table_bloat.relpages/otta::numeric END,1) AS bloat,
102
+ CASE WHEN relpages < otta THEN '0' ELSE (bs*(table_bloat.relpages-otta)::bigint)::bigint END AS raw_waste
103
+ FROM table_bloat
104
+ UNION
105
+ SELECT
106
+ 'index' as type,
107
+ FORMAT('%I.%I::%I', schemaname, tablename, iname) AS name,
108
+ ROUND(CASE WHEN iotta=0 OR ipages=0 THEN 0.0 ELSE ipages/iotta::numeric END,1) AS bloat,
109
+ CASE WHEN ipages < iotta THEN '0' ELSE (bs*(ipages-iotta))::bigint END AS raw_waste
110
+ FROM index_bloat
111
+ )
112
+ SELECT type, name, bloat::text AS bloat, pg_size_pretty(raw_waste) as waste
113
+ FROM bloat_summary
114
+ ORDER BY raw_waste DESC, bloat_summary.bloat DESC`,
115
+ },
116
+
117
+ blocking: {
118
+ summary: 'Queries holding locks and the queries waiting on them',
119
+ columns: cols([
120
+ ['blocked_pid', 'blocked pid'], ['blocking_statement', 'blocking statement'],
121
+ ['blocking_duration', 'blocking duration'], ['blocking_pid', 'blocking pid'],
122
+ ['blocked_statement', 'blocked statement'], ['blocked_duration', 'blocked duration'],
123
+ ]),
124
+ sql: `SELECT
125
+ bl.pid AS blocked_pid,
126
+ ka.query AS blocking_statement,
127
+ age(now(), ka.query_start)::text AS blocking_duration,
128
+ kl.pid AS blocking_pid,
129
+ a.query AS blocked_statement,
130
+ age(now(), a.query_start)::text AS blocked_duration
131
+ FROM pg_catalog.pg_locks bl
132
+ JOIN pg_catalog.pg_stat_activity a
133
+ ON bl.pid = a.pid
134
+ JOIN pg_catalog.pg_locks kl
135
+ JOIN pg_catalog.pg_stat_activity ka
136
+ ON kl.pid = ka.pid
137
+ ON bl.transactionid = kl.transactionid AND bl.pid != kl.pid
138
+ WHERE NOT bl.granted`,
139
+ },
140
+
141
+ calls: {
142
+ summary: 'Queries ordered by number of calls (pg_stat_statements)',
143
+ needsStatStatements: true,
144
+ columns: cols([
145
+ ['query', 'Query'], ['total_exec_time', 'Total Execution Time'],
146
+ ['prop_exec_time', 'Proportion of total exec time'], ['ncalls', 'Number Calls'],
147
+ ['sync_io_time', 'Sync IO time'],
148
+ ]),
149
+ sql: `SELECT
150
+ query,
151
+ (interval '1 millisecond' * total_exec_time)::text AS total_exec_time,
152
+ to_char((total_exec_time/sum(total_exec_time) OVER()) * 100, 'FM90D0') || '%' AS prop_exec_time,
153
+ to_char(calls, 'FM999G999G999G999G990') AS ncalls,
154
+ ${SYNC_IO_TIME}
155
+ FROM pg_stat_statements s
156
+ ORDER BY calls DESC
157
+ LIMIT 10`,
158
+ },
159
+
160
+ 'db-stats': {
161
+ summary: 'Database size, cache hit rates and WAL size',
162
+ columns: cols([
163
+ ['name', 'Name'], ['database_size', 'Database Size'], ['total_index_size', 'Total Index Size'],
164
+ ['total_table_size', 'Total Table Size'], ['total_toast_size', 'Total Toast Size'],
165
+ ['time_since_stats_reset', 'Time Since Stats Reset'], ['index_hit_rate', 'Index Hit Rate'],
166
+ ['table_hit_rate', 'Table Hit Rate'], ['wal_size', 'WAL Size'],
167
+ ]),
168
+ // Upstream reads pg_stat_statements_info unconditionally, which would fail
169
+ // the whole summary on a database without the extension. query_to_xml
170
+ // defers the reference to run time, and the CASE never reaches it when the
171
+ // view does not exist.
172
+ sql: `WITH total_objects AS (
173
+ SELECT c.relkind, pg_size_pretty(SUM(pg_relation_size(c.oid))) AS size
174
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
175
+ WHERE c.relkind IN ('i', 'r', 't') AND NOT n.nspname LIKE ANY(${IGNORED_SCHEMAS})
176
+ GROUP BY c.relkind
177
+ ), cache_hit AS (
178
+ SELECT
179
+ 'i' AS relkind,
180
+ ROUND(SUM(idx_blks_hit)::numeric / nullif(SUM(idx_blks_hit + idx_blks_read), 0), 2) AS ratio
181
+ FROM pg_statio_user_indexes
182
+ WHERE NOT schemaname LIKE ANY(${IGNORED_SCHEMAS})
183
+ UNION
184
+ SELECT
185
+ 't' AS relkind,
186
+ ROUND(
187
+ SUM(COALESCE((to_jsonb(s) ->> 'rel_blks_hit')::bigint, (to_jsonb(s) ->> 'heap_blks_hit')::bigint, 0))::numeric
188
+ / nullif(SUM(
189
+ COALESCE((to_jsonb(s) ->> 'rel_blks_hit')::bigint, (to_jsonb(s) ->> 'heap_blks_hit')::bigint, 0)
190
+ + COALESCE((to_jsonb(s) ->> 'rel_blks_read')::bigint, (to_jsonb(s) ->> 'heap_blks_read')::bigint, 0)
191
+ ), 0),
192
+ 2
193
+ ) AS ratio
194
+ FROM pg_statio_user_tables s
195
+ WHERE NOT schemaname LIKE ANY(${IGNORED_SCHEMAS})
196
+ )
197
+ SELECT
198
+ current_database() AS name,
199
+ pg_size_pretty(pg_database_size(current_database())) AS database_size,
200
+ COALESCE((SELECT size FROM total_objects WHERE relkind = 'i'), '0 bytes') AS total_index_size,
201
+ COALESCE((SELECT size FROM total_objects WHERE relkind = 'r'), '0 bytes') AS total_table_size,
202
+ COALESCE((SELECT size FROM total_objects WHERE relkind = 't'), '0 bytes') AS total_toast_size,
203
+ CASE WHEN to_regclass('pg_stat_statements_info') IS NULL THEN 'N/A' ELSE COALESCE((xpath(
204
+ '/row/age/text()',
205
+ query_to_xml('SELECT (now() - stats_reset)::text AS age FROM pg_stat_statements_info', false, true, '')
206
+ ))[1]::text, 'N/A') END AS time_since_stats_reset,
207
+ (SELECT COALESCE(ratio::text, 'N/A') FROM cache_hit WHERE relkind = 'i') AS index_hit_rate,
208
+ (SELECT COALESCE(ratio::text, 'N/A') FROM cache_hit WHERE relkind = 't') AS table_hit_rate,
209
+ COALESCE((SELECT pg_size_pretty(SUM(size)) FROM pg_ls_waldir()), '0 bytes') AS wal_size`,
210
+ },
211
+
212
+ 'index-stats': {
213
+ summary: 'Index size, usage, scan counts and unused indexes',
214
+ columns: cols([
215
+ ['name', 'Name'], ['table', 'Table'], ['columns', 'Columns'], ['size', 'Size'],
216
+ ['percent_used', 'Percent used'], ['index_scans', 'Index scans'], ['seq_scans', 'Seq scans'],
217
+ ['unused', 'Unused'],
218
+ ]),
219
+ sql: `WITH idx_sizes AS (
220
+ SELECT
221
+ i.indexrelid AS oid,
222
+ FORMAT('%I.%I', n.nspname, c.relname) AS name,
223
+ FORMAT('%I.%I', tn.nspname, tc.relname) AS table_name,
224
+ (
225
+ SELECT STRING_AGG(pg_get_indexdef(i.indexrelid, ord::int, false), ',' ORDER BY ord)
226
+ FROM unnest(i.indkey::int[]) WITH ORDINALITY AS k(attnum, ord)
227
+ ) AS columns,
228
+ pg_relation_size(i.indexrelid) AS index_size_bytes
229
+ FROM pg_stat_user_indexes ui
230
+ JOIN pg_index i ON ui.indexrelid = i.indexrelid
231
+ JOIN pg_class c ON ui.indexrelid = c.oid
232
+ JOIN pg_namespace n ON c.relnamespace = n.oid
233
+ JOIN pg_class tc ON tc.oid = i.indrelid
234
+ JOIN pg_namespace tn ON tn.oid = tc.relnamespace
235
+ WHERE NOT n.nspname LIKE ANY(${IGNORED_SCHEMAS})
236
+ ),
237
+ idx_usage AS (
238
+ SELECT indexrelid AS oid, idx_scan::bigint AS idx_scans
239
+ FROM pg_stat_user_indexes ui
240
+ WHERE NOT schemaname LIKE ANY(${IGNORED_SCHEMAS})
241
+ ),
242
+ seq_usage AS (
243
+ SELECT relid AS oid, seq_scan::bigint AS seq_scans
244
+ FROM pg_stat_user_tables
245
+ WHERE NOT schemaname LIKE ANY(${IGNORED_SCHEMAS})
246
+ ),
247
+ usage_pct AS (
248
+ SELECT
249
+ u.oid,
250
+ CASE
251
+ WHEN u.idx_scans IS NULL OR u.idx_scans = 0 THEN 0
252
+ WHEN s.seq_scans IS NULL THEN 100
253
+ ELSE ROUND(100.0 * u.idx_scans / (s.seq_scans + u.idx_scans), 1)
254
+ END AS percent_used
255
+ FROM idx_usage u
256
+ LEFT JOIN seq_usage s ON s.oid = u.oid
257
+ )
258
+ SELECT
259
+ s.name,
260
+ s.table_name AS "table",
261
+ s.columns,
262
+ pg_size_pretty(s.index_size_bytes) AS size,
263
+ COALESCE(up.percent_used, 0)::text || '%' AS percent_used,
264
+ COALESCE(u.idx_scans, 0) AS index_scans,
265
+ COALESCE(sq.seq_scans, 0) AS seq_scans,
266
+ CASE WHEN COALESCE(u.idx_scans, 0) = 0 THEN true ELSE false END AS unused
267
+ FROM idx_sizes s
268
+ LEFT JOIN idx_usage u ON u.oid = s.oid
269
+ LEFT JOIN seq_usage sq ON sq.oid = s.oid
270
+ LEFT JOIN usage_pct up ON up.oid = s.oid
271
+ ORDER BY s.index_size_bytes DESC`,
272
+ },
273
+
274
+ locks: {
275
+ summary: 'Queries holding an exclusive lock on a relation',
276
+ columns: cols([
277
+ ['pid', 'pid'], ['relname', 'relname'], ['transactionid', 'transaction id'],
278
+ ['granted', 'granted'], ['stmt', 'stmt'], ['age', 'age'],
279
+ ]),
280
+ sql: `SELECT
281
+ pg_stat_activity.pid,
282
+ COALESCE(pg_class.relname, 'null') AS relname,
283
+ COALESCE(pg_locks.transactionid::text, 'null') AS transactionid,
284
+ pg_locks.granted,
285
+ pg_stat_activity.query AS stmt,
286
+ age(now(), pg_stat_activity.query_start)::text AS age
287
+ FROM pg_stat_activity, pg_locks LEFT OUTER JOIN pg_class ON (pg_locks.relation = pg_class.oid)
288
+ WHERE pg_stat_activity.query <> '<insufficient privilege>'
289
+ AND pg_locks.pid = pg_stat_activity.pid
290
+ AND pg_locks.mode = 'ExclusiveLock'
291
+ ORDER BY query_start`,
292
+ },
293
+
294
+ 'long-running-queries': {
295
+ summary: 'Queries running for longer than five minutes',
296
+ columns: cols([['pid', 'pid'], ['duration', 'Duration'], ['query', 'Query']]),
297
+ sql: `SELECT
298
+ pid,
299
+ age(now(), pg_stat_activity.query_start)::text AS duration,
300
+ query AS query
301
+ FROM pg_stat_activity
302
+ WHERE pg_stat_activity.query <> ''::text
303
+ AND state <> 'idle'
304
+ AND age(now(), pg_stat_activity.query_start) > interval '5 minutes'
305
+ ORDER BY age(now(), pg_stat_activity.query_start) DESC`,
306
+ },
307
+
308
+ outliers: {
309
+ summary: 'Queries ordered by total execution time (pg_stat_statements)',
310
+ needsStatStatements: true,
311
+ columns: cols([
312
+ ['query', 'Query'], ['total_exec_time', 'Execution Time'],
313
+ ['prop_exec_time', 'Proportion of exec time'], ['ncalls', 'Number Calls'],
314
+ ['sync_io_time', 'Sync IO time'],
315
+ ]),
316
+ sql: `SELECT
317
+ (interval '1 millisecond' * total_exec_time)::text AS total_exec_time,
318
+ to_char((total_exec_time/sum(total_exec_time) OVER()) * 100, 'FM90D0') || '%' AS prop_exec_time,
319
+ to_char(calls, 'FM999G999G999G990') AS ncalls,
320
+ ${SYNC_IO_TIME},
321
+ query
322
+ FROM pg_stat_statements s WHERE userid = (SELECT usesysid FROM pg_user WHERE usename = current_user LIMIT 1)
323
+ ORDER BY total_exec_time DESC
324
+ LIMIT 10`,
325
+ },
326
+
327
+ 'replication-slots': {
328
+ summary: 'Replication slots and their lag',
329
+ columns: cols([
330
+ ['slot_name', 'Name'], ['active', 'Active'], ['state', 'State'],
331
+ ['replication_client_address', 'Replication Client Address'],
332
+ ['replication_lag_gb', 'Replication Lag GB'],
333
+ ]),
334
+ sql: `SELECT
335
+ s.slot_name,
336
+ s.active,
337
+ COALESCE(r.state, 'N/A') as state,
338
+ CASE WHEN r.client_addr IS NULL
339
+ THEN 'N/A'
340
+ ELSE r.client_addr::text
341
+ END replication_client_address,
342
+ GREATEST(0, ROUND((redo_lsn-restart_lsn)/1024/1024/1024, 2))::text as replication_lag_gb
343
+ FROM pg_control_checkpoint(), pg_replication_slots s
344
+ LEFT JOIN pg_stat_replication r ON (r.pid = s.active_pid)`,
345
+ },
346
+
347
+ 'role-stats': {
348
+ summary: 'Roles with their connection counts, limits and custom config',
349
+ columns: cols([
350
+ ['role_name', 'Role name'], ['active_connections', 'Active connections'],
351
+ ['connection_limit', 'Connection limit'], ['custom_config', 'Custom config'],
352
+ ]),
353
+ sql: `SELECT
354
+ rolname as role_name,
355
+ (SELECT count(*) FROM pg_stat_activity WHERE pg_roles.rolname = pg_stat_activity.usename) AS active_connections,
356
+ CASE WHEN rolconnlimit = -1
357
+ THEN current_setting('max_connections')::int8
358
+ ELSE rolconnlimit
359
+ END AS connection_limit,
360
+ array_to_string(rolconfig, ',', '*') as custom_config
361
+ FROM pg_roles
362
+ ORDER BY 1 DESC`,
363
+ },
364
+
365
+ 'table-stats': {
366
+ summary: 'Table size, index size and estimated row count',
367
+ columns: cols([
368
+ ['name', 'Name'], ['table_size', 'Table size'], ['index_size', 'Index size'],
369
+ ['total_size', 'Total size'], ['estimated_row_count', 'Estimated row count'],
370
+ ['seq_scans', 'Seq scans'],
371
+ ]),
372
+ sql: `SELECT
373
+ ts.name,
374
+ pg_size_pretty(ts.table_size_bytes) AS table_size,
375
+ pg_size_pretty(ts.index_size_bytes) AS index_size,
376
+ pg_size_pretty(ts.total_size_bytes) AS total_size,
377
+ COALESCE(rc.estimated_row_count, 0) AS estimated_row_count,
378
+ COALESCE(rc.seq_scans, 0) AS seq_scans
379
+ FROM (
380
+ SELECT
381
+ FORMAT('%I.%I', n.nspname, c.relname) AS name,
382
+ pg_table_size(c.oid) AS table_size_bytes,
383
+ pg_indexes_size(c.oid) AS index_size_bytes,
384
+ pg_total_relation_size(c.oid) AS total_size_bytes
385
+ FROM pg_class c
386
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
387
+ WHERE NOT n.nspname LIKE ANY(${IGNORED_SCHEMAS})
388
+ AND c.relkind = 'r'
389
+ ) ts
390
+ LEFT JOIN (
391
+ SELECT
392
+ FORMAT('%I.%I', schemaname, relname) AS name,
393
+ n_live_tup AS estimated_row_count,
394
+ seq_scan AS seq_scans
395
+ FROM pg_stat_user_tables
396
+ WHERE NOT schemaname LIKE ANY(${IGNORED_SCHEMAS})
397
+ ) rc ON rc.name = ts.name
398
+ ORDER BY ts.total_size_bytes DESC`,
399
+ },
400
+
401
+ 'traffic-profile': {
402
+ summary: 'Read/write balance of each table',
403
+ columns: cols([
404
+ ['schemaname', 'Schema'], ['table_name', 'Table'], ['blocks_read', 'Blocks Read'],
405
+ ['write_tuples', 'Write Tuples'], ['blocks_write', 'Blocks Write'], ['activity_ratio', 'Activity Ratio'],
406
+ ]),
407
+ // Adapted upstream from Crunchy Data's "Is Postgres Read Heavy or Write Heavy?".
408
+ sql: `WITH
409
+ ratio_target AS (SELECT 5 AS ratio),
410
+ table_list AS (SELECT
411
+ s.schemaname,
412
+ s.relname AS table_name,
413
+ si.heap_blks_read + si.idx_blks_read AS blocks_read,
414
+ s.n_tup_ins + s.n_tup_upd + s.n_tup_del AS write_tuples,
415
+ relpages * (s.n_tup_ins + s.n_tup_upd + s.n_tup_del ) / (case when reltuples = 0 then 1 else reltuples end) as blocks_write
416
+ FROM pg_stat_user_tables AS s
417
+ JOIN pg_statio_user_tables AS si ON s.relid = si.relid
418
+ JOIN pg_class c ON c.oid = s.relid
419
+ WHERE (s.n_tup_ins + s.n_tup_upd + s.n_tup_del) > 0
420
+ AND (si.heap_blks_read + si.idx_blks_read) > 0
421
+ )
422
+ SELECT
423
+ schemaname,
424
+ table_name,
425
+ blocks_read,
426
+ write_tuples,
427
+ to_char(blocks_write, 'FM999999999990.0') AS blocks_write,
428
+ CASE
429
+ WHEN blocks_read = 0 and blocks_write = 0 THEN 'No Activity'
430
+ WHEN blocks_write * ratio > blocks_read THEN
431
+ CASE
432
+ WHEN blocks_read = 0 THEN 'Write-Only'
433
+ ELSE ROUND(blocks_write :: numeric / blocks_read :: numeric, 1)::text || ':1 (Write-Heavy)'
434
+ END
435
+ WHEN blocks_read > blocks_write * ratio THEN
436
+ CASE
437
+ WHEN blocks_write = 0 THEN 'Read-Only'
438
+ ELSE '1:' || ROUND(blocks_read::numeric / blocks_write :: numeric, 1)::text || ' (Read-Heavy)'
439
+ END
440
+ ELSE '1:1 (Balanced)'
441
+ END AS activity_ratio
442
+ FROM table_list, ratio_target
443
+ ORDER BY (blocks_read + table_list.blocks_write) DESC`,
444
+ },
445
+
446
+ 'vacuum-stats': {
447
+ summary: 'Vacuum and analyze history, and whether autovacuum is due',
448
+ columns: cols([
449
+ ['name', 'Table'], ['last_vacuum', 'Last Vacuum'], ['last_autovacuum', 'Last Auto Vacuum'],
450
+ ['last_analyze', 'Last Analyze'], ['last_autoanalyze', 'Last Auto Analyze'],
451
+ ['rowcount', 'Row count'], ['dead_rowcount', 'Dead row count'],
452
+ ['expect_autovacuum', 'Expect autovacuum?'], ['expect_autoanalyze', 'Expect autoanalyze?'],
453
+ ]),
454
+ sql: `WITH table_opts AS (
455
+ SELECT pg_class.oid, relname, nspname, array_to_string(reloptions, '') AS relopts
456
+ FROM pg_class INNER JOIN pg_namespace ns ON relnamespace = ns.oid
457
+ ), vacuum_settings AS (
458
+ SELECT
459
+ oid, relname, nspname,
460
+ CASE
461
+ WHEN relopts LIKE '%autovacuum_vacuum_threshold%'
462
+ THEN substring(relopts, '.*autovacuum_vacuum_threshold=([0-9.]+).*')::integer
463
+ ELSE current_setting('autovacuum_vacuum_threshold')::integer
464
+ END AS autovacuum_vacuum_threshold,
465
+ CASE
466
+ WHEN relopts LIKE '%autovacuum_vacuum_scale_factor%'
467
+ THEN substring(relopts, '.*autovacuum_vacuum_scale_factor=([0-9.]+).*')::real
468
+ ELSE current_setting('autovacuum_vacuum_scale_factor')::real
469
+ END AS autovacuum_vacuum_scale_factor,
470
+ CASE
471
+ WHEN relopts LIKE '%autovacuum_analyze_threshold%'
472
+ THEN substring(relopts, '.*autovacuum_analyze_threshold=([0-9.]+).*')::integer
473
+ ELSE current_setting('autovacuum_analyze_threshold')::integer
474
+ END AS autovacuum_analyze_threshold,
475
+ CASE
476
+ WHEN relopts LIKE '%autovacuum_analyze_scale_factor%'
477
+ THEN substring(relopts, '.*autovacuum_analyze_scale_factor=([0-9.]+).*')::real
478
+ ELSE current_setting('autovacuum_analyze_scale_factor')::real
479
+ END AS autovacuum_analyze_scale_factor
480
+ FROM table_opts
481
+ )
482
+ SELECT
483
+ FORMAT('%I.%I', vacuum_settings.nspname, vacuum_settings.relname) AS name,
484
+ coalesce(to_char(psut.last_vacuum, 'YYYY-MM-DD HH24:MI'), '') AS last_vacuum,
485
+ coalesce(to_char(psut.last_autovacuum, 'YYYY-MM-DD HH24:MI'), '') AS last_autovacuum,
486
+ coalesce(to_char(psut.last_analyze, 'YYYY-MM-DD HH24:MI'), '') AS last_analyze,
487
+ coalesce(to_char(psut.last_autoanalyze, 'YYYY-MM-DD HH24:MI'), '') AS last_autoanalyze,
488
+ CASE WHEN pg_class.reltuples = -1 THEN 'No stats' ELSE to_char(pg_class.reltuples, '9G999G999G999') END AS rowcount,
489
+ to_char(psut.n_dead_tup, '9G999G999G999') AS dead_rowcount,
490
+ to_char(autovacuum_vacuum_threshold
491
+ + (autovacuum_vacuum_scale_factor::numeric * pg_class.reltuples), '9G999G999G999') AS autovacuum_threshold,
492
+ CASE
493
+ WHEN autovacuum_vacuum_threshold + (autovacuum_vacuum_scale_factor::numeric * pg_class.reltuples) < psut.n_dead_tup
494
+ THEN 'yes'
495
+ ELSE 'no'
496
+ END AS expect_autovacuum,
497
+ to_char(autovacuum_analyze_threshold
498
+ + (autovacuum_analyze_scale_factor::numeric * pg_class.reltuples), '9G999G999G999') AS autoanalyze_threshold,
499
+ CASE
500
+ WHEN autovacuum_analyze_threshold + (autovacuum_analyze_scale_factor::numeric * pg_class.reltuples) < psut.n_dead_tup
501
+ THEN 'yes'
502
+ ELSE 'no'
503
+ END AS expect_autoanalyze
504
+ FROM pg_stat_user_tables psut INNER JOIN pg_class ON psut.relid = pg_class.oid
505
+ INNER JOIN vacuum_settings ON pg_class.oid = vacuum_settings.oid
506
+ WHERE NOT vacuum_settings.nspname LIKE ANY(${IGNORED_SCHEMAS})
507
+ ORDER BY
508
+ case when pg_class.reltuples = -1 then 1 else 0 end,
509
+ 1`,
510
+ },
511
+ };
512
+
513
+ // Names Supabase has folded into the queries above and still accepts, each
514
+ // routed to the query that replaced it (upstream's deprecated handlers).
515
+ const DEPRECATED = {
516
+ 'cache-hit': 'db-stats',
517
+ 'index-sizes': 'index-stats',
518
+ 'index-usage': 'index-stats',
519
+ 'seq-scans': 'index-stats',
520
+ 'table-record-counts': 'index-stats',
521
+ 'total-index-size': 'index-stats',
522
+ 'unused-indexes': 'index-stats',
523
+ 'role-configs': 'role-stats',
524
+ 'role-connections': 'role-stats',
525
+ 'table-index-sizes': 'table-stats',
526
+ 'table-sizes': 'table-stats',
527
+ 'total-table-sizes': 'table-stats',
528
+ };
529
+
530
+ // `inspect report` has no query of its own for unused indexes (the subcommand
531
+ // is a deprecated alias), so it carries upstream's standalone one.
532
+ const UNUSED_INDEXES = {
533
+ columns: cols([['name', 'name'], ['index', 'index'], ['index_size', 'index_size'], ['index_scans', 'index_scans']]),
534
+ sql: `SELECT
535
+ FORMAT('%I.%I', schemaname, relname) AS name,
536
+ indexrelname AS index,
537
+ pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
538
+ idx_scan as index_scans
539
+ FROM pg_stat_user_indexes ui
540
+ JOIN pg_index i ON ui.indexrelid = i.indexrelid
541
+ WHERE
542
+ NOT indisunique AND idx_scan < 50 AND pg_relation_size(relid) > 5 * 8192
543
+ AND NOT schemaname LIKE ANY(${IGNORED_SCHEMAS})
544
+ ORDER BY
545
+ pg_relation_size(i.indexrelid) / nullif(idx_scan, 0) DESC NULLS FIRST,
546
+ pg_relation_size(i.indexrelid) DESC`,
547
+ };
548
+
549
+ // The report's files, named like upstream's CSVs (`db_stats.csv`), in its order.
550
+ const REPORT = [
551
+ ...Object.keys(QUERIES).map(name => ({ file: name.replace(/-/g, '_'), name, query: QUERIES[name] })),
552
+ { file: 'unused_indexes', name: 'unused-indexes', query: UNUSED_INDEXES },
553
+ ].sort((a, b) => a.file.localeCompare(b.file));
554
+
555
+ /** The query a subcommand name runs, following deprecated names to their replacement. */
556
+ function resolveQuery(name) {
557
+ const canonical = DEPRECATED[name] || name;
558
+ const query = QUERIES[canonical];
559
+ return query ? { name: canonical, query } : undefined;
560
+ }
561
+
562
+ module.exports = { QUERIES, DEPRECATED, REPORT, INTERNAL_SCHEMAS, resolveQuery, likePattern };