neonctl 2.35.1 → 2.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,7 @@ import * as diff from "./diff.js";
14
14
  import * as env from "./env.js";
15
15
  import * as functions from "./functions.js";
16
16
  import * as init from "./init.js";
17
+ import * as inspect from "./inspect.js";
17
18
  import * as ipAllow from "./ip_allow.js";
18
19
  import * as link from "./link.js";
19
20
  import * as neonAuth from "./neon_auth.js";
@@ -41,6 +42,7 @@ export default [
41
42
  roles,
42
43
  operations,
43
44
  snapshots,
45
+ inspect,
44
46
  cs,
45
47
  psql,
46
48
  setContext,
@@ -0,0 +1,65 @@
1
+ import { fillSingleProject } from "../utils/enrichers.js";
2
+ import { resolveConnectionUri, runInspectQuery } from "../utils/inspect_db.js";
3
+ import { INSPECT_QUERIES, } from "../utils/inspect_queries.js";
4
+ import { writer } from "../writer.js";
5
+ export const command = "inspect";
6
+ export const describe = "Inspect a database's health and configuration";
7
+ export const aliases = ["inspection"];
8
+ /**
9
+ * `fillSingleProject` hits the API to auto-resolve a project. When the user
10
+ * passes `--db-url` we bypass the Neon API entirely, so skip it.
11
+ */
12
+ const fillSingleProjectUnlessDbUrl = async (props) => {
13
+ if (props.dbUrl) {
14
+ return props;
15
+ }
16
+ return fillSingleProject(props);
17
+ };
18
+ const runSubcommand = async (name, props) => {
19
+ const query = INSPECT_QUERIES[name];
20
+ const connectionUri = props.dbUrl ?? (await resolveConnectionUri(props)).connectionUri;
21
+ const rows = await runInspectQuery(connectionUri, query.sql, {
22
+ requiresExtension: query.requiresExtension,
23
+ });
24
+ writer(props).end(rows, {
25
+ fields: query.fields,
26
+ emptyMessage: query.emptyMessage,
27
+ });
28
+ };
29
+ const dbBuilder = (argv) => {
30
+ let builder = argv
31
+ .usage("$0 inspect db <sub-command> [options]")
32
+ .options({
33
+ "project-id": {
34
+ describe: "Project ID",
35
+ type: "string",
36
+ },
37
+ branch: {
38
+ describe: "Branch ID or name",
39
+ type: "string",
40
+ },
41
+ "database-name": {
42
+ describe: "Database name",
43
+ type: "string",
44
+ },
45
+ "role-name": {
46
+ describe: "Role name",
47
+ type: "string",
48
+ },
49
+ "db-url": {
50
+ describe: "Inspect any Postgres via a connection string, bypassing the Neon API (project/branch resolution is skipped)",
51
+ type: "string",
52
+ },
53
+ })
54
+ .middleware(fillSingleProjectUnlessDbUrl);
55
+ for (const name of Object.keys(INSPECT_QUERIES)) {
56
+ builder = builder.command(name, INSPECT_QUERIES[name].describe, (yargs) => yargs, (args) => runSubcommand(name, args));
57
+ }
58
+ return builder;
59
+ };
60
+ export const builder = (argv) => argv
61
+ .usage("$0 inspect <sub-command> [options]")
62
+ .command("db", "Run a diagnostic query against a branch's Postgres", dbBuilder);
63
+ export const handler = (args) => {
64
+ return args;
65
+ };
@@ -0,0 +1,153 @@
1
+ import { parseConnectionUri } from "../psql/index.js";
2
+ import { PgConnection } from "../psql/wire/connection.js";
3
+ import { EndpointType } from "./api_enums.js";
4
+ import { branchIdFromProps } from "./enrichers.js";
5
+ import { parsePITBranch } from "./point_in_time.js";
6
+ export const SSL_MODES = [
7
+ "require",
8
+ "verify-ca",
9
+ "verify-full",
10
+ "omit",
11
+ ];
12
+ /**
13
+ * Resolve a branch's live Postgres connection details via the Neon API
14
+ * (endpoint → role → password → database → URL, honoring point-in-time,
15
+ * pooling, prisma tuning, and SSL mode). Mirrors how `connection-string`
16
+ * resolves a branch so `inspect db` connects the same way.
17
+ */
18
+ export const resolveConnectionUri = async (props) => {
19
+ const projectId = props.projectId;
20
+ const parsedPIT = props.branch
21
+ ? parsePITBranch(props.branch)
22
+ : { tag: "head", branch: "" };
23
+ if (props.branch) {
24
+ props.branch = parsedPIT.branch;
25
+ }
26
+ const branchId = await branchIdFromProps(props);
27
+ const { data: { endpoints }, } = await props.apiClient.listProjectBranchEndpoints(projectId, branchId);
28
+ const matchEndpointType = props.endpointType ?? EndpointType.ReadWrite;
29
+ let endpoint = endpoints.find((e) => e.type === matchEndpointType);
30
+ if (!endpoint && props.endpointType == null) {
31
+ endpoint = endpoints[0];
32
+ }
33
+ if (!endpoint) {
34
+ throw new Error(`No ${props.endpointType ?? ""} endpoint found for the branch: ${branchId}`);
35
+ }
36
+ const role = props.roleName ||
37
+ (await props.apiClient
38
+ .listProjectBranchRoles(projectId, branchId)
39
+ .then(({ data }) => {
40
+ if (data.roles.length === 0) {
41
+ throw new Error(`No roles found for the branch: ${branchId}`);
42
+ }
43
+ if (data.roles.length === 1) {
44
+ return data.roles[0].name;
45
+ }
46
+ throw new Error(`Multiple roles found for the branch, please provide one with the --role-name option: ${data.roles
47
+ .map((r) => r.name)
48
+ .join(", ")}`);
49
+ }));
50
+ const { data: { databases: branchDatabases }, } = await props.apiClient.listProjectBranchDatabases(projectId, branchId);
51
+ const database = props.databaseName ||
52
+ (() => {
53
+ if (branchDatabases.length === 0) {
54
+ throw new Error(`No databases found for the branch: ${branchId}`);
55
+ }
56
+ if (branchDatabases.length === 1) {
57
+ return branchDatabases[0].name;
58
+ }
59
+ throw new Error(`Multiple databases found for the branch, please provide one with the --database-name option: ${branchDatabases
60
+ .map((d) => d.name)
61
+ .join(", ")}`);
62
+ })();
63
+ if (!branchDatabases.find((d) => d.name === database)) {
64
+ throw new Error(`Database not found: ${database}`);
65
+ }
66
+ const { data: { password }, } = await props.apiClient.getProjectBranchRolePassword(props.projectId, endpoint.branch_id, role);
67
+ let host = props.pooled
68
+ ? endpoint.host.replace(endpoint.id, `${endpoint.id}-pooler`)
69
+ : endpoint.host;
70
+ if (parsedPIT.tag !== "head") {
71
+ host = endpoint.host.replace(endpoint.id, endpoint.branch_id);
72
+ }
73
+ const connectionString = new URL(`postgresql://${host}`);
74
+ connectionString.pathname = database;
75
+ connectionString.username = role;
76
+ connectionString.password = password;
77
+ if (props.prisma) {
78
+ connectionString.searchParams.set("connect_timeout", "30");
79
+ if (props.pooled) {
80
+ connectionString.searchParams.set("pool_timeout", "30");
81
+ connectionString.searchParams.set("pgbouncer", "true");
82
+ }
83
+ }
84
+ const ssl = props.ssl ?? "require";
85
+ if (ssl !== "omit") {
86
+ connectionString.searchParams.set("sslmode", ssl);
87
+ connectionString.searchParams.set("channel_binding", "require");
88
+ }
89
+ if (parsedPIT.tag === "lsn") {
90
+ connectionString.searchParams.set("options", `neon_lsn:${parsedPIT.lsn}`);
91
+ }
92
+ else if (parsedPIT.tag === "timestamp") {
93
+ connectionString.searchParams.set("options", `neon_timestamp:${parsedPIT.timestamp}`);
94
+ }
95
+ return {
96
+ connectionUri: connectionString.toString(),
97
+ host,
98
+ role,
99
+ password,
100
+ database,
101
+ options: connectionString.searchParams.toString(),
102
+ };
103
+ };
104
+ const rowsFrom = (result) => {
105
+ const names = result.fields.map((f) => f.name);
106
+ return result.rows.map((row) => {
107
+ const obj = {};
108
+ names.forEach((name, i) => {
109
+ obj[name] = row[i];
110
+ });
111
+ return obj;
112
+ });
113
+ };
114
+ /**
115
+ * Connect to `connectionUri` with the embedded wire client, run a single
116
+ * read-only `sql`, and return the rows as objects keyed by column name (so the
117
+ * shared `writer` can render them by field). The connection is always closed,
118
+ * even on error.
119
+ */
120
+ export const runInspectQuery = async (connectionUri, sql, options = {}) => {
121
+ const opts = parseConnectionUri(connectionUri);
122
+ let connection;
123
+ try {
124
+ connection = await PgConnection.connect(opts);
125
+ }
126
+ catch (err) {
127
+ // Give the failure a Postgres-specific message. The raw socket error
128
+ // (e.g. ECONNREFUSED) would otherwise be mislabeled by the top-level
129
+ // `isNetworkError` handler as a "Could not reach the Neon API" hint —
130
+ // misleading for `inspect db`, which talks to Postgres, not the API.
131
+ // We intentionally do not chain `cause` so that classifier can't match
132
+ // the socket `code`; the underlying reason is preserved in the message.
133
+ const reason = err instanceof Error ? err.message : String(err);
134
+ throw new Error(`Could not connect to Postgres at ${opts.host}:${opts.port}: ${reason}`);
135
+ }
136
+ try {
137
+ const ext = options.requiresExtension;
138
+ if (ext) {
139
+ // `ext` is an internal constant from INSPECT_QUERIES, never user input,
140
+ // so interpolating it into the literal is safe.
141
+ const check = await connection.query(`SELECT 1 FROM pg_extension WHERE extname = '${ext}';`);
142
+ if (check.rows.length === 0) {
143
+ throw new Error(`This query needs the "${ext}" extension, which is not installed. ` +
144
+ `Enable it with: CREATE EXTENSION ${ext};`);
145
+ }
146
+ }
147
+ const result = await connection.query(sql);
148
+ return rowsFrom(result);
149
+ }
150
+ finally {
151
+ await connection.close();
152
+ }
153
+ };
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Diagnostic SQL for `neon inspect db`. Each entry is a read-only query against
3
+ * Postgres' own statistics/catalog views plus the output columns (in display
4
+ * order) the table/json/yaml formatter should show.
5
+ *
6
+ * Most queries need no extra extension. The few that do (currently `outliers`
7
+ * and `calls`, which read `pg_stat_statements`) declare it via
8
+ * `requiresExtension`; the runner checks for it and fails with an actionable
9
+ * hint when it's missing.
10
+ */
11
+ export const INSPECT_QUERIES = {
12
+ "table-sizes": {
13
+ describe: "Size of each table (including TOAST), largest first (pg_table_size)",
14
+ fields: ["schema", "name", "size"],
15
+ emptyMessage: "No user tables found.",
16
+ sql: /* sql */ `
17
+ SELECT
18
+ n.nspname AS schema,
19
+ c.relname AS name,
20
+ pg_size_pretty(pg_table_size(c.oid)) AS size
21
+ FROM pg_class c
22
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
23
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
24
+ AND n.nspname !~ '^pg_toast'
25
+ AND c.relkind = 'r'
26
+ ORDER BY pg_table_size(c.oid) DESC;
27
+ `,
28
+ },
29
+ "index-sizes": {
30
+ describe: "Size of each index, largest first (pg_relation_size)",
31
+ fields: ["schema", "name", "size"],
32
+ emptyMessage: "No indexes found.",
33
+ sql: /* sql */ `
34
+ SELECT
35
+ n.nspname AS schema,
36
+ c.relname AS name,
37
+ pg_size_pretty(pg_relation_size(c.oid)) AS size
38
+ FROM pg_class c
39
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
40
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
41
+ AND n.nspname !~ '^pg_toast'
42
+ AND c.relkind = 'i'
43
+ ORDER BY pg_relation_size(c.oid) DESC;
44
+ `,
45
+ },
46
+ "unused-indexes": {
47
+ describe: "Non-unique indexes with few scans — candidates for removal (pg_stat_user_indexes)",
48
+ fields: ["table", "index", "index_size", "index_scans"],
49
+ emptyMessage: "No unused indexes detected.",
50
+ sql: /* sql */ `
51
+ SELECT
52
+ s.schemaname || '.' || s.relname AS "table",
53
+ s.indexrelname AS index,
54
+ pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size,
55
+ s.idx_scan AS index_scans
56
+ FROM pg_stat_user_indexes s
57
+ JOIN pg_index i ON i.indexrelid = s.indexrelid
58
+ WHERE NOT i.indisunique
59
+ AND NOT i.indisprimary
60
+ AND s.idx_scan < 50
61
+ ORDER BY pg_relation_size(s.indexrelid) DESC, s.idx_scan ASC;
62
+ `,
63
+ },
64
+ "seq-scans": {
65
+ describe: "Number of sequential scans recorded against each table (pg_stat_user_tables)",
66
+ fields: ["schema", "name", "count"],
67
+ emptyMessage: "No user tables found.",
68
+ sql: /* sql */ `
69
+ SELECT
70
+ schemaname AS schema,
71
+ relname AS name,
72
+ seq_scan AS count
73
+ FROM pg_stat_user_tables
74
+ ORDER BY seq_scan DESC;
75
+ `,
76
+ },
77
+ "long-running-queries": {
78
+ describe: "Queries running longer than 5 minutes (pg_stat_activity)",
79
+ fields: ["pid", "duration", "state", "query"],
80
+ emptyMessage: "No long-running queries.",
81
+ sql: /* sql */ `
82
+ SELECT
83
+ pid,
84
+ (now() - query_start)::text AS duration,
85
+ state,
86
+ query
87
+ FROM pg_stat_activity
88
+ WHERE state <> 'idle'
89
+ AND query NOT ILIKE '%pg_stat_activity%'
90
+ AND now() - query_start > interval '5 minutes'
91
+ ORDER BY now() - query_start DESC;
92
+ `,
93
+ },
94
+ locks: {
95
+ describe: "Locks held with the acquiring query and its age (pg_locks + pg_stat_activity)",
96
+ fields: [
97
+ "pid",
98
+ "relname",
99
+ "mode",
100
+ "locktype",
101
+ "granted",
102
+ "age",
103
+ "query",
104
+ ],
105
+ emptyMessage: "No locks held.",
106
+ sql: /* sql */ `
107
+ SELECT
108
+ a.pid,
109
+ c.relname,
110
+ l.mode,
111
+ l.locktype,
112
+ l.granted,
113
+ (now() - a.query_start)::text AS age,
114
+ a.query
115
+ FROM pg_locks l
116
+ JOIN pg_stat_activity a ON a.pid = l.pid
117
+ LEFT JOIN pg_class c ON c.oid = l.relation
118
+ WHERE a.query <> '<insufficient privilege>'
119
+ AND l.pid <> pg_backend_pid()
120
+ ORDER BY a.query_start;
121
+ `,
122
+ },
123
+ outliers: {
124
+ describe: "Queries taking the most cumulative execution time (needs pg_stat_statements)",
125
+ fields: ["total_exec_time", "prop_exec_time", "ncalls", "query"],
126
+ emptyMessage: "No statements recorded yet.",
127
+ requiresExtension: "pg_stat_statements",
128
+ sql: /* sql */ `
129
+ SELECT
130
+ (interval '1 millisecond' * total_exec_time)::text AS total_exec_time,
131
+ to_char(
132
+ (total_exec_time / nullif(sum(total_exec_time) OVER (), 0)) * 100,
133
+ 'FM990D0'
134
+ ) || '%' AS prop_exec_time,
135
+ calls AS ncalls,
136
+ query
137
+ FROM pg_stat_statements s
138
+ JOIN pg_database d ON d.oid = s.dbid
139
+ WHERE d.datname = current_database()
140
+ ORDER BY total_exec_time DESC
141
+ LIMIT 25;
142
+ `,
143
+ },
144
+ calls: {
145
+ describe: "Most frequently called queries (needs pg_stat_statements)",
146
+ fields: ["ncalls", "total_exec_time", "prop_exec_time", "query"],
147
+ emptyMessage: "No statements recorded yet.",
148
+ requiresExtension: "pg_stat_statements",
149
+ sql: /* sql */ `
150
+ SELECT
151
+ calls AS ncalls,
152
+ (interval '1 millisecond' * total_exec_time)::text AS total_exec_time,
153
+ to_char(
154
+ (calls / nullif(sum(calls) OVER (), 0)::float) * 100,
155
+ 'FM990D0'
156
+ ) || '%' AS prop_exec_time,
157
+ query
158
+ FROM pg_stat_statements s
159
+ JOIN pg_database d ON d.oid = s.dbid
160
+ WHERE d.datname = current_database()
161
+ ORDER BY calls DESC
162
+ LIMIT 25;
163
+ `,
164
+ },
165
+ "lfc-hit-rate": {
166
+ describe: "Local File Cache hit rate (needs neon extension)",
167
+ fields: ["name", "ratio"],
168
+ emptyMessage: "No LFC stats available.",
169
+ requiresExtension: "neon",
170
+ // Read the hit/miss counters from neon_get_lfc_stats() rather than the
171
+ // neon_lfc_stats / neon_stat_file_cache views: those views are owned by
172
+ // cloud_admin and are not always readable by end-user roles, whereas the
173
+ // function is EXECUTE-able by the default role.
174
+ sql: /* sql */ `
175
+ WITH lfc AS (
176
+ SELECT lfc_key AS k, lfc_value AS v
177
+ FROM neon_get_lfc_stats() t(lfc_key text, lfc_value bigint)
178
+ )
179
+ SELECT
180
+ 'lfc hit rate' AS name,
181
+ max(v) FILTER (WHERE k = 'file_cache_hits')::float
182
+ / nullif(
183
+ max(v) FILTER (WHERE k = 'file_cache_hits')
184
+ + max(v) FILTER (WHERE k = 'file_cache_misses'),
185
+ 0
186
+ ) AS ratio
187
+ FROM lfc;
188
+ `,
189
+ },
190
+ "working-set": {
191
+ describe: "Estimated working set vs LFC size (needs neon extension)",
192
+ fields: ["window", "working_set", "lfc_size", "exceeds_lfc"],
193
+ emptyMessage: "No working-set estimate available.",
194
+ requiresExtension: "neon",
195
+ sql: /* sql */ `
196
+ SELECT
197
+ w.window,
198
+ pg_size_pretty(w.working_set_bytes) AS working_set,
199
+ pg_size_pretty(pg_size_bytes(current_setting('neon.file_cache_size_limit'))) AS lfc_size,
200
+ CASE
201
+ WHEN w.working_set_bytes
202
+ > pg_size_bytes(current_setting('neon.file_cache_size_limit'))
203
+ THEN 'yes' ELSE 'no'
204
+ END AS exceeds_lfc
205
+ FROM (
206
+ SELECT
207
+ x AS window,
208
+ approximate_working_set_size_seconds(
209
+ extract('epoch' from x::interval)::int
210
+ )::bigint * 8192 AS working_set_bytes
211
+ FROM (VALUES ('1m'), ('5m'), ('15m'), ('1h')) AS t(x)
212
+ ) w
213
+ ORDER BY working_set_bytes;
214
+ `,
215
+ },
216
+ "vacuum-stats": {
217
+ describe: "Autovacuum status per table: last (auto)vacuum, dead tuples, threshold",
218
+ fields: [
219
+ "schema",
220
+ "table",
221
+ "last_vacuum",
222
+ "last_autovacuum",
223
+ "rowcount",
224
+ "dead_rowcount",
225
+ "expect_autovacuum",
226
+ ],
227
+ emptyMessage: "No user tables found.",
228
+ sql: /* sql */ `
229
+ SELECT
230
+ n.nspname AS schema,
231
+ c.relname AS "table",
232
+ to_char(psut.last_vacuum, 'YYYY-MM-DD HH24:MI') AS last_vacuum,
233
+ to_char(psut.last_autovacuum, 'YYYY-MM-DD HH24:MI') AS last_autovacuum,
234
+ c.reltuples::bigint AS rowcount,
235
+ psut.n_dead_tup AS dead_rowcount,
236
+ CASE
237
+ WHEN current_setting('autovacuum_vacuum_threshold')::bigint
238
+ + current_setting('autovacuum_vacuum_scale_factor')::numeric
239
+ * c.reltuples
240
+ < psut.n_dead_tup
241
+ THEN 'yes'
242
+ ELSE 'no'
243
+ END AS expect_autovacuum
244
+ FROM pg_stat_user_tables psut
245
+ JOIN pg_class c ON psut.relid = c.oid
246
+ JOIN pg_namespace n ON c.relnamespace = n.oid
247
+ ORDER BY psut.n_dead_tup DESC;
248
+ `,
249
+ },
250
+ bloat: {
251
+ describe: "Estimated table/index bloat (statistical estimate, no extension needed)",
252
+ fields: ["type", "schema", "object_name", "bloat", "waste"],
253
+ emptyMessage: "No bloat estimate available.",
254
+ sql: /* sql */ `
255
+ WITH constants AS (
256
+ SELECT current_setting('block_size')::numeric AS bs, 23 AS hdr, 8 AS ma
257
+ ),
258
+ bloat_info AS (
259
+ SELECT
260
+ ma, bs, schemaname, tablename,
261
+ (datawidth + (hdr + ma - (CASE WHEN hdr % ma = 0 THEN ma ELSE hdr % ma END)))::numeric AS datahdr,
262
+ (maxfracsum * (nullhdr + ma - (CASE WHEN nullhdr % ma = 0 THEN ma ELSE nullhdr % ma END))) AS nullhdr2
263
+ FROM (
264
+ SELECT
265
+ schemaname, tablename, hdr, ma, bs,
266
+ SUM((1 - null_frac) * avg_width) AS datawidth,
267
+ MAX(null_frac) AS maxfracsum,
268
+ hdr + (
269
+ SELECT 1 + count(*) / 8
270
+ FROM pg_stats s2
271
+ WHERE null_frac <> 0
272
+ AND s2.schemaname = s.schemaname
273
+ AND s2.tablename = s.tablename
274
+ ) AS nullhdr
275
+ FROM pg_stats s, constants
276
+ GROUP BY 1, 2, 3, 4, 5
277
+ ) AS foo
278
+ ),
279
+ table_bloat AS (
280
+ SELECT
281
+ schemaname, tablename, cc.relpages, bs,
282
+ CEIL((cc.reltuples * ((datahdr + ma
283
+ - (CASE WHEN datahdr % ma = 0 THEN ma ELSE datahdr % ma END))
284
+ + nullhdr2 + 4)) / (bs - 20::float)) AS otta
285
+ FROM bloat_info
286
+ JOIN pg_class cc ON cc.relname = bloat_info.tablename
287
+ JOIN pg_namespace nn ON cc.relnamespace = nn.oid
288
+ AND nn.nspname = bloat_info.schemaname
289
+ AND nn.nspname NOT IN ('information_schema', 'pg_catalog')
290
+ WHERE cc.relkind = 'r'
291
+ )
292
+ SELECT
293
+ 'table' AS type,
294
+ schemaname AS schema,
295
+ tablename AS object_name,
296
+ round(CASE WHEN otta = 0 THEN 0.0 ELSE relpages::numeric / otta::numeric END, 1) AS bloat,
297
+ pg_size_pretty(
298
+ (CASE WHEN relpages < otta THEN 0 ELSE (bs * (relpages - otta))::bigint END)
299
+ ) AS waste
300
+ FROM table_bloat
301
+ ORDER BY (CASE WHEN relpages < otta THEN 0 ELSE bs * (relpages - otta) END) DESC
302
+ LIMIT 25;
303
+ `,
304
+ },
305
+ "replication-slots": {
306
+ describe: "Replication slots: kind, status, client, restart/confirmed-flush LSNs, and lag (pg_replication_slots + pg_stat_replication)",
307
+ fields: [
308
+ "slot_name",
309
+ "slot_type",
310
+ "slot_kind",
311
+ "status",
312
+ "client_addr",
313
+ "restart_lsn",
314
+ "confirmed_flush_lsn",
315
+ "replication_lag",
316
+ ],
317
+ emptyMessage: "No replication slots found.",
318
+ sql: /* sql */ `
319
+ SELECT
320
+ s.slot_name,
321
+ s.slot_type,
322
+ CASE
323
+ WHEN s.slot_type = 'physical' THEN 'physical'
324
+ WHEN s.slot_name LIKE 'pg_%_sync_%' THEN 'initial-sync (tablesync)'
325
+ WHEN s.slot_type = 'logical' THEN 'CDC (apply)'
326
+ ELSE 'other'
327
+ END AS slot_kind,
328
+ CASE
329
+ WHEN r.state IS NOT NULL THEN r.state
330
+ WHEN s.active THEN 'active (no walsender)'
331
+ ELSE 'inactive'
332
+ END AS status,
333
+ r.client_addr,
334
+ s.restart_lsn,
335
+ s.confirmed_flush_lsn,
336
+ pg_size_pretty(
337
+ pg_wal_lsn_diff(
338
+ CASE
339
+ WHEN pg_is_in_recovery() THEN pg_last_wal_receive_lsn()
340
+ ELSE pg_current_wal_lsn()
341
+ END,
342
+ COALESCE(s.confirmed_flush_lsn, s.restart_lsn)
343
+ )
344
+ ) AS replication_lag
345
+ FROM pg_replication_slots s
346
+ LEFT JOIN pg_stat_replication r ON r.pid = s.active_pid
347
+ ORDER BY s.slot_name;
348
+ `,
349
+ },
350
+ subscriptions: {
351
+ describe: "Per-table logical replication progress on this subscriber (pg_subscription_rel)",
352
+ fields: ["subscription", "table_name", "status", "lsn"],
353
+ emptyMessage: "No subscriptions found on this database.",
354
+ sql: /* sql */ `
355
+ SELECT
356
+ sub.subname AS subscription,
357
+ sr.srrelid::regclass::text AS table_name,
358
+ CASE sr.srsubstate
359
+ WHEN 'i' THEN 'initialize'
360
+ WHEN 'd' THEN 'data being copied'
361
+ WHEN 'f' THEN 'finished table copy'
362
+ WHEN 's' THEN 'synchronized'
363
+ WHEN 'r' THEN 'ready'
364
+ ELSE sr.srsubstate::text
365
+ END AS status,
366
+ sr.srsublsn AS lsn
367
+ FROM pg_subscription_rel sr
368
+ JOIN pg_subscription sub ON sub.oid = sr.srsubid
369
+ ORDER BY sub.subname, table_name;
370
+ `,
371
+ },
372
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neonctl",
3
- "version": "2.35.1",
3
+ "version": "2.36.0",
4
4
  "description": "CLI tool for Neon Serverless Postgres",
5
5
  "keywords": [
6
6
  "neon",
@@ -54,7 +54,7 @@
54
54
  "@neon/sdk": "1.2.0",
55
55
  "@neon/config": "0.9.5",
56
56
  "@neon/config-runtime": "0.9.6",
57
- "@neon/env": "0.11.4"
57
+ "@neon/env": "0.11.5"
58
58
  },
59
59
  "optionalDependencies": {
60
60
  "esbuild": "0.28.1"