neon 3.3.0 → 3.4.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.
@@ -106,7 +106,7 @@ const handler = async (props) => {
106
106
  let host = props.pooled ? endpoint.host.replace(endpoint.id, `${endpoint.id}-pooler`) : endpoint.host;
107
107
  if (parsedPIT.tag !== "head") host = endpoint.host.replace(endpoint.id, endpoint.branch_id);
108
108
  const connectionString = new URL(`postgresql://${host}`);
109
- connectionString.pathname = database;
109
+ connectionString.pathname = `/${encodeURIComponent(database)}`;
110
110
  connectionString.username = role;
111
111
  connectionString.password = password;
112
112
  if (props.prisma) {
@@ -1,7 +1,7 @@
1
1
  import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
2
2
  import { writer } from "../writer.js";
3
3
  import { fillSingleProject } from "../utils/enrichers.js";
4
- import { resolveConnectionUri, runInspectQuery } from "../utils/inspect_db.js";
4
+ import { formatInspectQueryError, resolveInspectTargets, runInspectQuery } from "../utils/inspect_db.js";
5
5
  import { INSPECT_QUERIES } from "../utils/inspect_queries.js";
6
6
  //#region src/commands/inspect.ts
7
7
  var inspect_exports = /* @__PURE__ */ __exportAll({
@@ -12,7 +12,7 @@ var inspect_exports = /* @__PURE__ */ __exportAll({
12
12
  handler: () => handler
13
13
  });
14
14
  const command = "inspect";
15
- const describe = "Inspect a database's health and configuration";
15
+ const describe = "Inspect a branch's Postgres health and configuration";
16
16
  const aliases = ["inspection"];
17
17
  /**
18
18
  * `fillSingleProject` hits the API to auto-resolve a project. When the user
@@ -24,11 +24,36 @@ const fillSingleProjectUnlessDbUrl = async (props) => {
24
24
  };
25
25
  const runSubcommand = async (name, props) => {
26
26
  const query = INSPECT_QUERIES[name];
27
- const connectionUri = props.dbUrl ?? (await resolveConnectionUri(props)).connectionUri;
28
- const rows = await runInspectQuery(connectionUri, query.sql, { requiresExtension: query.requiresExtension });
27
+ const { targets, includeDatabaseColumn, branchDatabaseCount } = await resolveInspectTargets(props, query.scope);
28
+ const rows = [];
29
+ for (const target of targets) {
30
+ let batch;
31
+ try {
32
+ batch = await runInspectQuery(target.connectionUri, query.sql, { requiresExtension: query.requiresExtension });
33
+ } catch (err) {
34
+ const reason = err instanceof Error ? err.message : String(err);
35
+ const wrapped = formatInspectQueryError({
36
+ reason,
37
+ database: target.database,
38
+ dbUrl: props.dbUrl,
39
+ databaseName: props.databaseName,
40
+ offerDatabaseNameHint: branchDatabaseCount > 1,
41
+ scope: query.scope,
42
+ requiresExtension: query.requiresExtension
43
+ });
44
+ if (wrapped === void 0) throw err instanceof Error ? err : new Error(reason);
45
+ throw new Error(wrapped);
46
+ }
47
+ if (includeDatabaseColumn) rows.push(...batch.map((row) => ({
48
+ database: target.database,
49
+ ...row
50
+ })));
51
+ else rows.push(...batch);
52
+ }
53
+ const fields = includeDatabaseColumn ? ["database", ...query.fields] : query.fields;
29
54
  writer(props).end(rows, {
30
- fields: query.fields,
31
- emptyMessage: query.emptyMessage
55
+ fields,
56
+ emptyMessage: includeDatabaseColumn ? query.emptyMessageAll ?? query.emptyMessage : query.emptyMessage
32
57
  });
33
58
  };
34
59
  const dbBuilder = (argv) => {
@@ -42,7 +67,7 @@ const dbBuilder = (argv) => {
42
67
  type: "string"
43
68
  },
44
69
  "database-name": {
45
- describe: "Database name",
70
+ describe: "Database to inspect. Omit to cover every database the API lists for the branch. Ranking and row limits stay per database. One failing database fails the whole run. Compute-wide checks run once against the first listed database. Ignored with --db-url.",
46
71
  type: "string"
47
72
  },
48
73
  "role-name": {
@@ -11,6 +11,60 @@ const SSL_MODES = [
11
11
  "omit"
12
12
  ];
13
13
  /**
14
+ * Keep the database column on one-database branches so adding another database
15
+ * does not change the output schema. Compute-wide views run once because every
16
+ * database returns the same rows.
17
+ */
18
+ const selectInspectTargets = (input) => {
19
+ if (input.dbUrlDatabase !== void 0) return {
20
+ databases: [input.dbUrlDatabase],
21
+ includeDatabaseColumn: false
22
+ };
23
+ if (input.databaseName !== void 0) {
24
+ if (input.databaseName === "") throw new Error("--database-name cannot be empty. Omit the flag to cover every database.");
25
+ return {
26
+ databases: [input.databaseName],
27
+ includeDatabaseColumn: false
28
+ };
29
+ }
30
+ if (input.branchDatabases.length === 0) throw new Error("No databases found for the branch");
31
+ if (input.scope === "compute") return {
32
+ databases: [input.branchDatabases[0]],
33
+ includeDatabaseColumn: false
34
+ };
35
+ return {
36
+ databases: [...input.branchDatabases].sort((a, b) => a.localeCompare(b, "en")),
37
+ includeDatabaseColumn: true
38
+ };
39
+ };
40
+ const formatInspectQueryError = (input) => {
41
+ if (input.dbUrl !== void 0 || input.databaseName !== void 0) return;
42
+ const missingExtension = input.requiresExtension !== void 0 && input.reason.includes(`"${input.requiresExtension}"`);
43
+ const hint = !input.offerDatabaseNameHint ? "" : missingExtension ? `. Pass --database-name to try a database that already has the "${input.requiresExtension}" extension.` : input.scope === "compute" ? ". Pass --database-name to connect through a different database." : ". Pass --database-name to inspect one database.";
44
+ return `${input.reason} (database ${input.database})${hint}`;
45
+ };
46
+ const connectionUriForDatabase = (connectionUri, database) => {
47
+ const url = new URL(connectionUri);
48
+ url.pathname = `/${encodeURIComponent(database)}`;
49
+ return url.toString();
50
+ };
51
+ const listBranchDatabases = async (props) => {
52
+ const projectId = props.projectId;
53
+ const parsedPIT = props.branch ? parsePITBranch(props.branch) : {
54
+ tag: "head",
55
+ branch: ""
56
+ };
57
+ const branchId = await branchIdFromProps({
58
+ ...props,
59
+ ...props.branch ? { branch: parsedPIT.branch } : {}
60
+ });
61
+ const { data: { databases } } = await props.apiClient.listProjectBranchDatabases(projectId, branchId);
62
+ return {
63
+ branchId,
64
+ names: databases.map((d) => d.name)
65
+ };
66
+ };
67
+ /**
14
68
  * Resolve a branch's live Postgres connection details via the Neon API
15
69
  * (endpoint → role → password → database → URL, honoring point-in-time,
16
70
  * pooling, prisma tuning, and SSL mode). Mirrors how `connection-string`
@@ -45,7 +99,7 @@ const resolveConnectionUri = async (props) => {
45
99
  let host = props.pooled ? endpoint.host.replace(endpoint.id, `${endpoint.id}-pooler`) : endpoint.host;
46
100
  if (parsedPIT.tag !== "head") host = endpoint.host.replace(endpoint.id, endpoint.branch_id);
47
101
  const connectionString = new URL(`postgresql://${host}`);
48
- connectionString.pathname = database;
102
+ connectionString.pathname = `/${encodeURIComponent(database)}`;
49
103
  connectionString.username = role;
50
104
  connectionString.password = password;
51
105
  if (props.prisma) {
@@ -71,6 +125,58 @@ const resolveConnectionUri = async (props) => {
71
125
  options: connectionString.searchParams.toString()
72
126
  };
73
127
  };
128
+ const resolveInspectTargets = async (props, scope) => {
129
+ if (props.dbUrl) {
130
+ const parsed = parseConnectionUri(props.dbUrl);
131
+ const selection = selectInspectTargets({
132
+ dbUrlDatabase: parsed.database,
133
+ branchDatabases: [],
134
+ scope
135
+ });
136
+ return {
137
+ targets: [{
138
+ database: selection.databases[0],
139
+ connectionUri: props.dbUrl
140
+ }],
141
+ includeDatabaseColumn: selection.includeDatabaseColumn,
142
+ branchDatabaseCount: 1
143
+ };
144
+ }
145
+ if (props.databaseName !== void 0) {
146
+ selectInspectTargets({
147
+ databaseName: props.databaseName,
148
+ branchDatabases: [],
149
+ scope
150
+ });
151
+ const resolved = await resolveConnectionUri(props);
152
+ return {
153
+ targets: [{
154
+ database: resolved.database,
155
+ connectionUri: resolved.connectionUri
156
+ }],
157
+ includeDatabaseColumn: false,
158
+ branchDatabaseCount: 1
159
+ };
160
+ }
161
+ const { branchId, names } = await listBranchDatabases(props);
162
+ if (names.length === 0) throw new Error(`No databases found for the branch: ${branchId}`);
163
+ const selection = selectInspectTargets({
164
+ branchDatabases: names,
165
+ scope
166
+ });
167
+ const first = await resolveConnectionUri({
168
+ ...props,
169
+ databaseName: selection.databases[0]
170
+ });
171
+ return {
172
+ targets: selection.databases.map((database) => ({
173
+ database,
174
+ connectionUri: connectionUriForDatabase(first.connectionUri, database)
175
+ })),
176
+ includeDatabaseColumn: selection.includeDatabaseColumn,
177
+ branchDatabaseCount: names.length
178
+ };
179
+ };
74
180
  const rowsFrom = (result) => {
75
181
  const names = result.fields.map((f) => f.name);
76
182
  return result.rows.map((row) => {
@@ -108,4 +214,4 @@ const runInspectQuery = async (connectionUri, sql, options = {}) => {
108
214
  }
109
215
  };
110
216
  //#endregion
111
- export { SSL_MODES, resolveConnectionUri, runInspectQuery };
217
+ export { SSL_MODES, connectionUriForDatabase, formatInspectQueryError, resolveConnectionUri, resolveInspectTargets, runInspectQuery, selectInspectTargets };
@@ -2,6 +2,7 @@
2
2
  const INSPECT_QUERIES = {
3
3
  "table-sizes": {
4
4
  describe: "Size of each table (including TOAST), largest first (pg_table_size)",
5
+ scope: "database",
5
6
  fields: [
6
7
  "schema",
7
8
  "name",
@@ -23,6 +24,7 @@ const INSPECT_QUERIES = {
23
24
  },
24
25
  "index-sizes": {
25
26
  describe: "Size of each index, largest first (pg_relation_size)",
27
+ scope: "database",
26
28
  fields: [
27
29
  "schema",
28
30
  "name",
@@ -44,6 +46,7 @@ const INSPECT_QUERIES = {
44
46
  },
45
47
  "unused-indexes": {
46
48
  describe: "Non-unique indexes with few scans — candidates for removal (pg_stat_user_indexes)",
49
+ scope: "database",
47
50
  fields: [
48
51
  "table",
49
52
  "index",
@@ -67,6 +70,7 @@ const INSPECT_QUERIES = {
67
70
  },
68
71
  "seq-scans": {
69
72
  describe: "Number of sequential scans recorded against each table (pg_stat_user_tables)",
73
+ scope: "database",
70
74
  fields: [
71
75
  "schema",
72
76
  "name",
@@ -84,13 +88,15 @@ const INSPECT_QUERIES = {
84
88
  },
85
89
  "long-running-queries": {
86
90
  describe: "Queries running longer than 5 minutes (pg_stat_activity)",
91
+ scope: "database",
87
92
  fields: [
88
93
  "pid",
89
94
  "duration",
90
95
  "state",
91
96
  "query"
92
97
  ],
93
- emptyMessage: "No long-running queries.",
98
+ emptyMessage: "No long-running queries in this database.",
99
+ emptyMessageAll: "No long-running queries in any database.",
94
100
  sql: `
95
101
  SELECT
96
102
  pid,
@@ -98,7 +104,8 @@ const INSPECT_QUERIES = {
98
104
  state,
99
105
  query
100
106
  FROM pg_stat_activity
101
- WHERE state <> 'idle'
107
+ WHERE datname = current_database()
108
+ AND state <> 'idle'
102
109
  AND query NOT ILIKE '%pg_stat_activity%'
103
110
  AND now() - query_start > interval '5 minutes'
104
111
  ORDER BY now() - query_start DESC;
@@ -106,6 +113,7 @@ const INSPECT_QUERIES = {
106
113
  },
107
114
  locks: {
108
115
  describe: "Locks held with the acquiring query and its age (pg_locks + pg_stat_activity)",
116
+ scope: "database",
109
117
  fields: [
110
118
  "pid",
111
119
  "relname",
@@ -115,7 +123,8 @@ const INSPECT_QUERIES = {
115
123
  "age",
116
124
  "query"
117
125
  ],
118
- emptyMessage: "No locks held.",
126
+ emptyMessage: "No locks held in this database.",
127
+ emptyMessageAll: "No locks held in any database.",
119
128
  sql: `
120
129
  SELECT
121
130
  a.pid,
@@ -128,13 +137,15 @@ const INSPECT_QUERIES = {
128
137
  FROM pg_locks l
129
138
  JOIN pg_stat_activity a ON a.pid = l.pid
130
139
  LEFT JOIN pg_class c ON c.oid = l.relation
131
- WHERE a.query <> '<insufficient privilege>'
140
+ WHERE a.datname = current_database()
141
+ AND a.query <> '<insufficient privilege>'
132
142
  AND l.pid <> pg_backend_pid()
133
143
  ORDER BY a.query_start;
134
144
  `
135
145
  },
136
146
  outliers: {
137
147
  describe: "Queries taking the most cumulative execution time (needs pg_stat_statements)",
148
+ scope: "database",
138
149
  fields: [
139
150
  "total_exec_time",
140
151
  "prop_exec_time",
@@ -161,6 +172,7 @@ const INSPECT_QUERIES = {
161
172
  },
162
173
  calls: {
163
174
  describe: "Most frequently called queries (needs pg_stat_statements)",
175
+ scope: "database",
164
176
  fields: [
165
177
  "ncalls",
166
178
  "total_exec_time",
@@ -186,7 +198,8 @@ const INSPECT_QUERIES = {
186
198
  `
187
199
  },
188
200
  "lfc-hit-rate": {
189
- describe: "Local File Cache hit rate (needs neon extension)",
201
+ describe: "Local File Cache hit rate (compute-wide, needs neon extension)",
202
+ scope: "compute",
190
203
  fields: ["name", "ratio"],
191
204
  emptyMessage: "No LFC stats available.",
192
205
  requiresExtension: "neon",
@@ -207,7 +220,8 @@ const INSPECT_QUERIES = {
207
220
  `
208
221
  },
209
222
  "working-set": {
210
- describe: "Estimated working set vs LFC size (needs neon extension)",
223
+ describe: "Estimated working set vs LFC size (compute-wide, needs neon extension)",
224
+ scope: "compute",
211
225
  fields: [
212
226
  "window",
213
227
  "working_set",
@@ -239,6 +253,7 @@ const INSPECT_QUERIES = {
239
253
  },
240
254
  "vacuum-stats": {
241
255
  describe: "Autovacuum status per table: last (auto)vacuum, dead tuples, threshold",
256
+ scope: "database",
242
257
  fields: [
243
258
  "schema",
244
259
  "table",
@@ -273,6 +288,7 @@ const INSPECT_QUERIES = {
273
288
  },
274
289
  bloat: {
275
290
  describe: "Estimated table/index bloat (statistical estimate, no extension needed)",
291
+ scope: "database",
276
292
  fields: [
277
293
  "type",
278
294
  "schema",
@@ -333,7 +349,8 @@ const INSPECT_QUERIES = {
333
349
  `
334
350
  },
335
351
  "replication-slots": {
336
- describe: "Replication slots: kind, status, client, restart/confirmed-flush LSNs, and lag (pg_replication_slots + pg_stat_replication)",
352
+ describe: "Replication slots (compute-wide): kind, status, client, restart/confirmed-flush LSNs, and lag (pg_replication_slots + pg_stat_replication)",
353
+ scope: "compute",
337
354
  fields: [
338
355
  "slot_name",
339
356
  "slot_type",
@@ -379,6 +396,7 @@ const INSPECT_QUERIES = {
379
396
  },
380
397
  subscriptions: {
381
398
  describe: "Per-table logical replication progress on this subscriber (pg_subscription_rel)",
399
+ scope: "database",
382
400
  fields: [
383
401
  "subscription",
384
402
  "table_name",
@@ -386,6 +404,7 @@ const INSPECT_QUERIES = {
386
404
  "lsn"
387
405
  ],
388
406
  emptyMessage: "No subscriptions found on this database.",
407
+ emptyMessageAll: "No subscriptions found on any database.",
389
408
  sql: `
390
409
  SELECT
391
410
  sub.subname AS subscription,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neon",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
5
5
  "keywords": [
6
6
  "neon",