@wpmoo/toolkit 0.9.26 → 0.9.27

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/README.md CHANGED
@@ -170,12 +170,31 @@ npx @wpmoo/toolkit doctor --json --postgres
170
170
  ```
171
171
 
172
172
  JSON output is optional; human-readable output remains the default.
173
- `doctor --postgres` adds read-only PostgreSQL health and performance diagnostics
174
- such as database size, sessions currently running queries with
175
- `pg_stat_activity.state = 'active'`, connection utilization against
176
- `max_connections`, slow-query readiness, extension visibility, and settings.
173
+ `doctor --postgres` runs read-only PostgreSQL diagnostics as advisory checks only; it
174
+ does not perform automatic tuning.
175
+ Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics
176
+ instead of being treated as successful checks.
177
+
178
+ Current advisory checks include:
179
+
180
+ - sessions currently running queries where `pg_stat_activity.state = 'active'`;
181
+ - connection utilization against `max_connections`;
182
+ - long transaction / idle-in-transaction warnings from `pg_stat_activity`;
183
+ - table health visibility (for example table and index bloat signals, index scan
184
+ efficiency, and vacuum-related blockers);
185
+ - optional unused index advisory output when index usage data is available;
186
+ - WAL and capacity visibility including WAL activity and disk-level pressure context;
187
+ - slow-query and query-plan readiness checks for common `log_min_duration_statement`
188
+ and `pg_stat_statements` prerequisites.
189
+
190
+ `npx @wpmoo/toolkit doctor --postgres` and
191
+ `npx @wpmoo/toolkit doctor --json --postgres` use the same checks, and the
192
+ JSON variant exposes a versioned PostgreSQL diagnostics contract.
193
+
177
194
  `doctor --json --postgres` includes a structured `postgres` object for automation.
178
- Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics.
195
+ `doctor --json --postgres` keeps the JSON contract stable by versioning the
196
+ `postgres` payload; individual fields are optional so automation can safely handle
197
+ environments where PostgreSQL does not expose a metric.
179
198
 
180
199
  ## Release Artifacts
181
200
 
package/dist/doctor.js CHANGED
@@ -5,6 +5,7 @@ import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './
5
5
  import { dailyActionScripts } from './daily-actions.js';
6
6
  import { defaultPostgresVersion } from './external-templates.js';
7
7
  import { defaultOdooVersion, markerPath, replaceSourceRepos } from './environment.js';
8
+ import { POSTGRES_DIAGNOSTICS_CONTRACT_VERSION, POSTGRES_DIAGNOSTICS_QUERY, malformedPostgresDiagnosticKeys, missingPostgresDiagnosticKeys, parsePostgresDiagnostics, postgresPostgresWarnings, renderPostgresDiagnostics, structuredPostgresDiagnostics, unavailablePostgresDiagnosticsWarning, } from './postgres-diagnostics.js';
8
9
  import { listGitmoduleSources, readSourceManifest, sourceReposFromManifest, sourceManifestPath, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
9
10
  const realCommandRunner = async (command, args, options) => {
10
11
  const result = await execa(command, args, { cwd: options.cwd });
@@ -46,128 +47,6 @@ function isMetadataError(message) {
46
47
  message.startsWith('Invalid sourceRepos entry in .wpmoo/odoo.json'));
47
48
  }
48
49
  const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
49
- const postgresDiagnosticQuery = `
50
- WITH metrics(metric, value) AS (
51
- SELECT 'database_count', count(*)::text
52
- FROM pg_database
53
- WHERE datistemplate = false
54
- UNION ALL
55
- SELECT 'active_connections', count(*)::text
56
- FROM pg_stat_activity
57
- WHERE datname IS NOT NULL
58
- AND state = 'active'
59
- UNION ALL
60
- SELECT 'connection_count', count(*)::text
61
- FROM pg_stat_activity
62
- WHERE datname IS NOT NULL
63
- UNION ALL
64
- SELECT 'max_connections', COALESCE(
65
- (SELECT setting FROM pg_settings WHERE name = 'max_connections'),
66
- 'unavailable'
67
- )
68
- UNION ALL
69
- SELECT 'total_database_size_bytes', COALESCE(sum(pg_database_size(datname)), 0)::text
70
- FROM pg_database
71
- WHERE datistemplate = false
72
- UNION ALL
73
- SELECT 'largest_database_name', COALESCE(
74
- (
75
- SELECT datname
76
- FROM pg_database
77
- WHERE datistemplate = false
78
- ORDER BY pg_database_size(datname) DESC, datname
79
- LIMIT 1
80
- ),
81
- 'unavailable'
82
- )
83
- UNION ALL
84
- SELECT 'largest_database_size_bytes', COALESCE(
85
- (
86
- SELECT pg_database_size(datname)::text
87
- FROM pg_database
88
- WHERE datistemplate = false
89
- ORDER BY pg_database_size(datname) DESC, datname
90
- LIMIT 1
91
- ),
92
- '0'
93
- )
94
- UNION ALL
95
- SELECT 'slow_query_logging', COALESCE(
96
- (SELECT setting || unit FROM pg_settings WHERE name = 'log_min_duration_statement'),
97
- 'unavailable'
98
- )
99
- UNION ALL
100
- SELECT 'pg_stat_statements',
101
- CASE
102
- WHEN EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') THEN 'installed'
103
- WHEN EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_stat_statements') THEN 'available'
104
- ELSE 'unavailable'
105
- END
106
- UNION ALL
107
- SELECT 'pg_stat_statements_available_version', COALESCE(
108
- (SELECT default_version FROM pg_available_extensions WHERE name = 'pg_stat_statements'),
109
- 'unavailable'
110
- )
111
- UNION ALL
112
- SELECT 'pg_stat_statements_installed_version', COALESCE(
113
- (SELECT extversion FROM pg_extension WHERE extname = 'pg_stat_statements'),
114
- ''
115
- )
116
- UNION ALL
117
- SELECT 'shared_preload_libraries', COALESCE(
118
- (SELECT setting FROM pg_settings WHERE name = 'shared_preload_libraries'),
119
- 'unavailable'
120
- )
121
- UNION ALL
122
- SELECT 'shared_buffers', COALESCE(
123
- (SELECT setting FROM pg_settings WHERE name = 'shared_buffers'),
124
- 'unavailable'
125
- )
126
- )
127
- SELECT metric || '|' || value
128
- FROM metrics
129
- ORDER BY CASE metric
130
- WHEN 'database_count' THEN 1
131
- WHEN 'active_connections' THEN 2
132
- WHEN 'connection_count' THEN 3
133
- WHEN 'max_connections' THEN 4
134
- WHEN 'total_database_size_bytes' THEN 5
135
- WHEN 'largest_database_name' THEN 6
136
- WHEN 'largest_database_size_bytes' THEN 7
137
- WHEN 'slow_query_logging' THEN 8
138
- WHEN 'pg_stat_statements' THEN 9
139
- WHEN 'pg_stat_statements_available_version' THEN 10
140
- WHEN 'pg_stat_statements_installed_version' THEN 11
141
- WHEN 'shared_preload_libraries' THEN 12
142
- WHEN 'shared_buffers' THEN 13
143
- ELSE 99
144
- END;
145
- `.trim();
146
- const postgresDiagnosticKeys = [
147
- 'database_count',
148
- 'active_connections',
149
- 'connection_count',
150
- 'max_connections',
151
- 'total_database_size_bytes',
152
- 'largest_database_name',
153
- 'largest_database_size_bytes',
154
- 'slow_query_logging',
155
- 'pg_stat_statements',
156
- 'pg_stat_statements_available_version',
157
- 'pg_stat_statements_installed_version',
158
- 'shared_preload_libraries',
159
- 'shared_buffers',
160
- ];
161
- const requiredPostgresDiagnosticKeys = [
162
- 'database_count',
163
- 'active_connections',
164
- 'connection_count',
165
- 'max_connections',
166
- 'total_database_size_bytes',
167
- 'slow_query_logging',
168
- 'pg_stat_statements',
169
- 'shared_buffers',
170
- ];
171
50
  function parsePostgresMajorFromValue(value) {
172
51
  if (!value)
173
52
  return undefined;
@@ -178,139 +57,8 @@ function parsePostgresMajorFromValue(value) {
178
57
  const match = trimmed.match(/postgres:([0-9]{1,3})(?:[-._][A-Za-z0-9._-]+)?(?:@[\w:.-]+)?/i);
179
58
  return match?.[1];
180
59
  }
181
- function parsePostgresDiagnostics(output) {
182
- const diagnostics = {};
183
- const allowedKeys = new Set(postgresDiagnosticKeys);
184
- for (const rawLine of output.split(/\r?\n/u)) {
185
- const line = rawLine.trim();
186
- if (!line)
187
- continue;
188
- const separatorIndex = line.indexOf('|');
189
- if (separatorIndex === -1)
190
- continue;
191
- const key = line.slice(0, separatorIndex).trim();
192
- const value = line.slice(separatorIndex + 1).trim();
193
- if (allowedKeys.has(key) && value) {
194
- diagnostics[key] = value;
195
- }
196
- }
197
- return diagnostics;
198
- }
199
- function renderPostgresDiagnostics(diagnostics) {
200
- const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
201
- const parts = postgresDiagnosticKeys.flatMap((key) => {
202
- const value = diagnostics[key];
203
- const rendered = value ? [`${key}=${value}`] : [];
204
- if (key === 'max_connections' && connectionUtilizationPct !== undefined) {
205
- rendered.push(`connection_utilization_pct=${connectionUtilizationPct}`);
206
- }
207
- return rendered;
208
- });
209
- return parts.length > 0 ? `OK PostgreSQL diagnostics ${parts.join(' ')}` : undefined;
210
- }
211
- function missingPostgresDiagnosticKeys(diagnostics) {
212
- return requiredPostgresDiagnosticKeys.filter((key) => !diagnostics[key]);
213
- }
214
- function unavailablePostgresDiagnosticsWarning(diagnostics, missingKeys) {
215
- return Object.keys(diagnostics).length === 0
216
- ? 'no diagnostic rows returned'
217
- : `incomplete diagnostic rows: missing ${missingKeys.join(', ')}`;
218
- }
219
- function integerDiagnostic(value) {
220
- if (!value || !/^\d+$/u.test(value)) {
221
- return undefined;
222
- }
223
- return Number.parseInt(value, 10);
224
- }
225
- function malformedPostgresDiagnosticKeys(diagnostics) {
226
- const numericKeys = [
227
- 'database_count',
228
- 'active_connections',
229
- 'connection_count',
230
- 'max_connections',
231
- 'total_database_size_bytes',
232
- 'largest_database_size_bytes',
233
- ];
234
- return numericKeys.filter((key) => diagnostics[key] !== undefined && integerDiagnostic(diagnostics[key]) === undefined);
235
- }
236
- function postgresConnectionUtilizationPct(diagnostics) {
237
- const connectionCount = integerDiagnostic(diagnostics.connection_count);
238
- const maxConnections = integerDiagnostic(diagnostics.max_connections);
239
- if (connectionCount === undefined || maxConnections === undefined || maxConnections <= 0) {
240
- return undefined;
241
- }
242
- return Math.round((connectionCount / maxConnections) * 100);
243
- }
244
- function postgresConnectionUtilizationWarning(diagnostics) {
245
- const connectionCount = integerDiagnostic(diagnostics.connection_count);
246
- const maxConnections = integerDiagnostic(diagnostics.max_connections);
247
- const utilizationPct = postgresConnectionUtilizationPct(diagnostics);
248
- if (connectionCount === undefined ||
249
- maxConnections === undefined ||
250
- utilizationPct === undefined ||
251
- utilizationPct < 80) {
252
- return undefined;
253
- }
254
- return `PostgreSQL connection utilization is high: ${utilizationPct}% of max_connections used (${connectionCount}/${maxConnections}).`;
255
- }
256
- function postgresSlowQueryLoggingWarning(diagnostics) {
257
- const slowQueryLogging = diagnostics.slow_query_logging?.trim();
258
- if (!slowQueryLogging || (!/^-1\s*(?:ms)?$/iu.test(slowQueryLogging) && !/^off$/iu.test(slowQueryLogging))) {
259
- return undefined;
260
- }
261
- return `PostgreSQL slow-query logging is disabled (log_min_duration_statement=${slowQueryLogging}). Enable it before performance triage.`;
262
- }
263
- function postgresExtensionVisibilityWarning(diagnostics) {
264
- if (diagnostics.pg_stat_statements === 'available' &&
265
- diagnostics.pg_stat_statements_available_version &&
266
- !diagnostics.pg_stat_statements_installed_version) {
267
- return 'PostgreSQL pg_stat_statements is available but not installed. Install it before query-level performance triage.';
268
- }
269
- return undefined;
270
- }
271
- function structuredPostgresDiagnostics(diagnostics) {
272
- const structured = {};
273
- const databaseCount = integerDiagnostic(diagnostics.database_count);
274
- const activeConnections = integerDiagnostic(diagnostics.active_connections);
275
- const connectionCount = integerDiagnostic(diagnostics.connection_count);
276
- const maxConnections = integerDiagnostic(diagnostics.max_connections);
277
- const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
278
- const totalDatabaseSizeBytes = integerDiagnostic(diagnostics.total_database_size_bytes);
279
- const largestDatabaseSizeBytes = integerDiagnostic(diagnostics.largest_database_size_bytes);
280
- if (databaseCount !== undefined)
281
- structured.databaseCount = databaseCount;
282
- if (activeConnections !== undefined)
283
- structured.activeConnections = activeConnections;
284
- if (connectionCount !== undefined)
285
- structured.connectionCount = connectionCount;
286
- if (maxConnections !== undefined)
287
- structured.maxConnections = maxConnections;
288
- if (connectionUtilizationPct !== undefined)
289
- structured.connectionUtilizationPct = connectionUtilizationPct;
290
- if (totalDatabaseSizeBytes !== undefined)
291
- structured.totalDatabaseSizeBytes = totalDatabaseSizeBytes;
292
- if (diagnostics.largest_database_name)
293
- structured.largestDatabaseName = diagnostics.largest_database_name;
294
- if (largestDatabaseSizeBytes !== undefined)
295
- structured.largestDatabaseSizeBytes = largestDatabaseSizeBytes;
296
- if (diagnostics.slow_query_logging)
297
- structured.slowQueryLogging = diagnostics.slow_query_logging;
298
- if (diagnostics.pg_stat_statements)
299
- structured.pgStatStatements = diagnostics.pg_stat_statements;
300
- if (diagnostics.pg_stat_statements_available_version) {
301
- structured.pgStatStatementsAvailableVersion = diagnostics.pg_stat_statements_available_version;
302
- }
303
- if (diagnostics.pg_stat_statements_installed_version) {
304
- structured.pgStatStatementsInstalledVersion = diagnostics.pg_stat_statements_installed_version;
305
- }
306
- if (diagnostics.shared_preload_libraries)
307
- structured.sharedPreloadLibraries = diagnostics.shared_preload_libraries;
308
- if (diagnostics.shared_buffers)
309
- structured.sharedBuffers = diagnostics.shared_buffers;
310
- return structured;
311
- }
312
60
  async function readPostgresDiagnostics(target, runner) {
313
- const queryLiteral = JSON.stringify(postgresDiagnosticQuery);
61
+ const queryLiteral = JSON.stringify(POSTGRES_DIAGNOSTICS_QUERY);
314
62
  const command = [
315
63
  `query=${queryLiteral}`,
316
64
  '. ./scripts/lib.sh >/dev/null',
@@ -716,21 +464,11 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
716
464
  }
717
465
  report.postgres = {
718
466
  requested: true,
467
+ contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
719
468
  available: true,
720
469
  diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
721
470
  };
722
- const connectionUtilizationWarning = postgresConnectionUtilizationWarning(postgresDiagnostics);
723
- if (connectionUtilizationWarning) {
724
- warnings.push(connectionUtilizationWarning);
725
- }
726
- const slowQueryLoggingWarning = postgresSlowQueryLoggingWarning(postgresDiagnostics);
727
- if (slowQueryLoggingWarning) {
728
- warnings.push(slowQueryLoggingWarning);
729
- }
730
- const extensionVisibilityWarning = postgresExtensionVisibilityWarning(postgresDiagnostics);
731
- if (extensionVisibilityWarning) {
732
- warnings.push(extensionVisibilityWarning);
733
- }
471
+ warnings.push(...postgresPostgresWarnings(postgresDiagnostics));
734
472
  }
735
473
  else {
736
474
  const warning = malformedKeys.length > 0
@@ -739,6 +477,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
739
477
  warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
740
478
  report.postgres = {
741
479
  requested: true,
480
+ contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
742
481
  available: false,
743
482
  diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
744
483
  warning,
@@ -750,6 +489,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
750
489
  warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
751
490
  report.postgres = {
752
491
  requested: true,
492
+ contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
753
493
  available: false,
754
494
  diagnostics: {},
755
495
  warning,
@@ -0,0 +1,646 @@
1
+ export const POSTGRES_DIAGNOSTICS_CONTRACT_VERSION = 2;
2
+ export const POSTGRES_DIAGNOSTICS_QUERY = `
3
+ WITH
4
+ transaction_health AS (
5
+ SELECT
6
+ COALESCE(
7
+ COUNT(*) FILTER (
8
+ WHERE state = 'active'
9
+ AND xact_start IS NOT NULL
10
+ AND now() - xact_start > interval '5 minutes'
11
+ ),
12
+ 0
13
+ )::text AS long_transaction_count,
14
+ COALESCE(
15
+ MAX(
16
+ EXTRACT(EPOCH FROM now() - xact_start)
17
+ ) FILTER (
18
+ WHERE state = 'active'
19
+ AND xact_start IS NOT NULL
20
+ AND now() - xact_start > interval '5 minutes'
21
+ ),
22
+ 0
23
+ )::bigint::text AS oldest_long_transaction_age_seconds,
24
+ COALESCE(
25
+ COUNT(*) FILTER (
26
+ WHERE state = 'idle in transaction'
27
+ AND xact_start IS NOT NULL
28
+ AND now() - xact_start > interval '5 minutes'
29
+ ),
30
+ 0
31
+ )::text AS idle_in_transaction_count,
32
+ COALESCE(
33
+ MAX(
34
+ EXTRACT(EPOCH FROM now() - xact_start)
35
+ ) FILTER (
36
+ WHERE state = 'idle in transaction'
37
+ AND xact_start IS NOT NULL
38
+ AND now() - xact_start > interval '5 minutes'
39
+ ),
40
+ 0
41
+ )::bigint::text AS oldest_idle_in_transaction_age_seconds
42
+ FROM pg_stat_activity
43
+ WHERE datname IS NOT NULL
44
+ ),
45
+ table_health AS (
46
+ SELECT
47
+ COALESCE(
48
+ COUNT(*) FILTER (
49
+ WHERE n_live_tup > 0
50
+ AND n_dead_tup::numeric > 0
51
+ AND (n_dead_tup::numeric / NULLIF(n_live_tup::numeric, 0)) > 0.2
52
+ ),
53
+ 0
54
+ )::text AS table_health_risky_table_count,
55
+ COALESCE(
56
+ COUNT(*) FILTER (
57
+ WHERE n_dead_tup::numeric > 0
58
+ AND (n_dead_tup::numeric / NULLIF(n_live_tup::numeric, 0)) > 0.2
59
+ ),
60
+ 0
61
+ )::text AS table_health_requires_vacuum_count,
62
+ COALESCE(
63
+ COUNT(*) FILTER (WHERE n_mod_since_analyze > 1000),
64
+ 0
65
+ )::text AS table_health_requires_analyze_count
66
+ FROM pg_stat_user_tables
67
+ ),
68
+ table_health_ranked AS (
69
+ SELECT
70
+ schemaname,
71
+ relname,
72
+ CASE
73
+ WHEN n_live_tup > 0 THEN (n_dead_tup::numeric / NULLIF(n_live_tup::numeric, 0))
74
+ ELSE 0
75
+ END AS dead_tuple_ratio,
76
+ n_dead_tup AS dead_tup_count,
77
+ n_live_tup,
78
+ ROW_NUMBER() OVER (
79
+ ORDER BY
80
+ CASE WHEN n_live_tup > 0 THEN n_dead_tup::numeric / NULLIF(n_live_tup::numeric, 0) ELSE 0 END DESC,
81
+ n_dead_tup DESC,
82
+ n_live_tup DESC
83
+ ) AS rn
84
+ FROM pg_stat_user_tables
85
+ ),
86
+ top_table_health AS (
87
+ SELECT
88
+ COALESCE(
89
+ (SELECT format('%I.%I', schemaname, relname) FROM table_health_ranked WHERE rn = 1),
90
+ 'unavailable'
91
+ ) AS table_health_top_risky_table,
92
+ COALESCE(
93
+ (
94
+ SELECT dead_tuple_ratio
95
+ FROM table_health_ranked
96
+ WHERE rn = 1
97
+ )::text,
98
+ '0'
99
+ ) AS table_health_top_risky_dead_tuple_ratio,
100
+ COALESCE(
101
+ (
102
+ SELECT dead_tup_count
103
+ FROM table_health_ranked
104
+ WHERE rn = 1
105
+ )::text,
106
+ '0'
107
+ ) AS table_health_top_risky_dead_tuple_count
108
+ FROM (SELECT 1) AS _top_table_health_row
109
+ ),
110
+ index_health AS (
111
+ SELECT
112
+ COALESCE(
113
+ COUNT(*) FILTER (
114
+ WHERE NOT idx.indisunique
115
+ AND i.idx_scan = 0
116
+ AND i.idx_tup_fetch = 0
117
+ ),
118
+ 0
119
+ )::text AS unused_index_candidates_count
120
+ FROM pg_stat_user_indexes i
121
+ LEFT JOIN pg_index idx ON idx.indexrelid = i.indexrelid
122
+ WHERE i.schemaname NOT IN ('pg_catalog', 'information_schema')
123
+ ),
124
+ wal_health AS (
125
+ SELECT
126
+ COALESCE((SELECT setting FROM pg_settings WHERE name = 'wal_level'), 'unavailable')::text AS wal_level,
127
+ COALESCE((SELECT setting FROM pg_settings WHERE name = 'archive_mode'), 'unavailable')::text AS wal_archive_mode,
128
+ COALESCE((SELECT COUNT(*) FROM pg_ls_waldir()), 0)::text AS wal_file_count,
129
+ COALESCE((SELECT SUM(size) FROM pg_ls_waldir()), 0)::text AS wal_directory_size_bytes
130
+ ),
131
+ capacity_health AS (
132
+ SELECT
133
+ COALESCE((SELECT pg_tablespace_size('pg_default')), 0)::text AS default_tablespace_size_bytes,
134
+ COALESCE(
135
+ (SELECT SUM(n_tup_ins + n_tup_upd + n_tup_del) FROM pg_stat_database WHERE datname IS NOT NULL),
136
+ 0
137
+ )::text AS database_write_activity_rows
138
+ )
139
+ SELECT metric || '|' || value
140
+ FROM (
141
+ SELECT 'database_count'::text AS metric, count(*)::text AS value
142
+ FROM pg_database
143
+ WHERE datistemplate = false
144
+ UNION ALL
145
+ SELECT 'active_connections', COUNT(*)::text
146
+ FROM pg_stat_activity
147
+ WHERE datname IS NOT NULL
148
+ AND state = 'active'
149
+ UNION ALL
150
+ SELECT 'connection_count', COUNT(*)::text
151
+ FROM pg_stat_activity
152
+ WHERE datname IS NOT NULL
153
+ UNION ALL
154
+ SELECT 'max_connections', COALESCE(
155
+ (SELECT setting FROM pg_settings WHERE name = 'max_connections'),
156
+ 'unavailable'
157
+ )::text
158
+ UNION ALL
159
+ SELECT 'total_database_size_bytes', COALESCE(SUM(pg_database_size(datname)), 0)::text
160
+ FROM pg_database
161
+ WHERE datistemplate = false
162
+ UNION ALL
163
+ SELECT 'largest_database_name', COALESCE(
164
+ (
165
+ SELECT datname
166
+ FROM pg_database
167
+ WHERE datistemplate = false
168
+ ORDER BY pg_database_size(datname) DESC, datname
169
+ LIMIT 1
170
+ ),
171
+ 'unavailable'
172
+ )
173
+ UNION ALL
174
+ SELECT 'largest_database_size_bytes', COALESCE(
175
+ (
176
+ SELECT pg_database_size(datname)::text
177
+ FROM pg_database
178
+ WHERE datistemplate = false
179
+ ORDER BY pg_database_size(datname) DESC, datname
180
+ LIMIT 1
181
+ ),
182
+ '0'
183
+ )
184
+ UNION ALL
185
+ SELECT 'slow_query_logging', COALESCE(
186
+ (SELECT setting || COALESCE(unit, '') FROM pg_settings WHERE name = 'log_min_duration_statement'),
187
+ 'unavailable'
188
+ )
189
+ UNION ALL
190
+ SELECT 'pg_stat_statements',
191
+ CASE
192
+ WHEN EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') THEN 'installed'
193
+ WHEN EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_stat_statements') THEN 'available'
194
+ ELSE 'unavailable'
195
+ END
196
+ UNION ALL
197
+ SELECT 'pg_stat_statements_available_version', COALESCE(
198
+ (SELECT default_version FROM pg_available_extensions WHERE name = 'pg_stat_statements'),
199
+ 'unavailable'
200
+ )
201
+ UNION ALL
202
+ SELECT 'pg_stat_statements_installed_version', COALESCE(
203
+ (SELECT extversion FROM pg_extension WHERE extname = 'pg_stat_statements'),
204
+ ''
205
+ )
206
+ UNION ALL
207
+ SELECT 'shared_preload_libraries', COALESCE(
208
+ (SELECT setting FROM pg_settings WHERE name = 'shared_preload_libraries'),
209
+ 'unavailable'
210
+ )
211
+ UNION ALL
212
+ SELECT 'shared_buffers', COALESCE(
213
+ (SELECT setting FROM pg_settings WHERE name = 'shared_buffers'),
214
+ 'unavailable'
215
+ )
216
+ UNION ALL
217
+ SELECT 'long_transaction_count', long_transaction_count FROM transaction_health
218
+ UNION ALL
219
+ SELECT 'oldest_long_transaction_age_seconds', oldest_long_transaction_age_seconds FROM transaction_health
220
+ UNION ALL
221
+ SELECT 'idle_in_transaction_count', idle_in_transaction_count FROM transaction_health
222
+ UNION ALL
223
+ SELECT 'oldest_idle_in_transaction_age_seconds', oldest_idle_in_transaction_age_seconds FROM transaction_health
224
+ UNION ALL
225
+ SELECT 'table_health_risky_table_count', table_health_risky_table_count FROM table_health
226
+ UNION ALL
227
+ SELECT 'table_health_requires_vacuum_count', table_health_requires_vacuum_count FROM table_health
228
+ UNION ALL
229
+ SELECT 'table_health_requires_analyze_count', table_health_requires_analyze_count FROM table_health
230
+ UNION ALL
231
+ SELECT 'table_health_top_risky_table', table_health_top_risky_table FROM top_table_health
232
+ UNION ALL
233
+ SELECT 'table_health_top_risky_dead_tuple_ratio', table_health_top_risky_dead_tuple_ratio FROM top_table_health
234
+ UNION ALL
235
+ SELECT 'table_health_top_risky_dead_tuple_count', table_health_top_risky_dead_tuple_count FROM top_table_health
236
+ UNION ALL
237
+ SELECT 'unused_index_candidates_count', unused_index_candidates_count FROM index_health
238
+ UNION ALL
239
+ SELECT 'wal_level', wal_level FROM wal_health
240
+ UNION ALL
241
+ SELECT 'wal_archive_mode', wal_archive_mode FROM wal_health
242
+ UNION ALL
243
+ SELECT 'wal_file_count', wal_file_count FROM wal_health
244
+ UNION ALL
245
+ SELECT 'wal_directory_size_bytes', wal_directory_size_bytes FROM wal_health
246
+ UNION ALL
247
+ SELECT 'default_tablespace_size_bytes', default_tablespace_size_bytes FROM capacity_health
248
+ UNION ALL
249
+ SELECT 'database_write_activity_rows', database_write_activity_rows FROM capacity_health
250
+ ) metrics
251
+ ORDER BY metric;
252
+ `.trim();
253
+ export const POSTGRES_DIAGNOSTIC_KEYS = [
254
+ 'database_count',
255
+ 'active_connections',
256
+ 'connection_count',
257
+ 'max_connections',
258
+ 'total_database_size_bytes',
259
+ 'largest_database_name',
260
+ 'largest_database_size_bytes',
261
+ 'slow_query_logging',
262
+ 'pg_stat_statements',
263
+ 'pg_stat_statements_available_version',
264
+ 'pg_stat_statements_installed_version',
265
+ 'shared_preload_libraries',
266
+ 'shared_buffers',
267
+ 'long_transaction_count',
268
+ 'oldest_long_transaction_age_seconds',
269
+ 'idle_in_transaction_count',
270
+ 'oldest_idle_in_transaction_age_seconds',
271
+ 'table_health_risky_table_count',
272
+ 'table_health_requires_vacuum_count',
273
+ 'table_health_requires_analyze_count',
274
+ 'table_health_top_risky_table',
275
+ 'table_health_top_risky_dead_tuple_ratio',
276
+ 'table_health_top_risky_dead_tuple_count',
277
+ 'unused_index_candidates_count',
278
+ 'wal_level',
279
+ 'wal_archive_mode',
280
+ 'wal_file_count',
281
+ 'wal_directory_size_bytes',
282
+ 'default_tablespace_size_bytes',
283
+ 'database_write_activity_rows',
284
+ ];
285
+ export const REQUIRED_POSTGRES_DIAGNOSTIC_KEYS = [
286
+ 'database_count',
287
+ 'active_connections',
288
+ 'connection_count',
289
+ 'max_connections',
290
+ 'total_database_size_bytes',
291
+ 'slow_query_logging',
292
+ 'pg_stat_statements',
293
+ 'shared_buffers',
294
+ ];
295
+ const INTEGER_POSTGRES_DIAGNOSTIC_KEYS = [
296
+ 'database_count',
297
+ 'active_connections',
298
+ 'connection_count',
299
+ 'max_connections',
300
+ 'total_database_size_bytes',
301
+ 'largest_database_size_bytes',
302
+ 'long_transaction_count',
303
+ 'oldest_long_transaction_age_seconds',
304
+ 'idle_in_transaction_count',
305
+ 'oldest_idle_in_transaction_age_seconds',
306
+ 'table_health_risky_table_count',
307
+ 'table_health_requires_vacuum_count',
308
+ 'table_health_requires_analyze_count',
309
+ 'table_health_top_risky_dead_tuple_count',
310
+ 'unused_index_candidates_count',
311
+ 'wal_file_count',
312
+ 'wal_directory_size_bytes',
313
+ 'default_tablespace_size_bytes',
314
+ 'database_write_activity_rows',
315
+ ];
316
+ const DECIMAL_POSTGRES_DIAGNOSTIC_KEYS = [
317
+ 'table_health_top_risky_dead_tuple_ratio',
318
+ ];
319
+ export const LONG_TRANSACTION_AGE_WARNING_SECONDS = 5 * 60;
320
+ export const LONG_TRANSACTION_COUNT_WARNING_THRESHOLD = 3;
321
+ export const IDLE_IN_TRANSACTION_AGE_WARNING_SECONDS = 5 * 60;
322
+ export const IDLE_IN_TRANSACTION_COUNT_WARNING_THRESHOLD = 2;
323
+ export const TABLE_HEALTH_DEAD_TUPLE_RATIO_WARNING = 0.2;
324
+ export const TABLE_HEALTH_DEAD_TUPLE_COUNT_WARNING = 1000;
325
+ export const UNUSED_INDEX_CANDIDATES_WARNING = 20;
326
+ export const WAL_DIRECTORY_SIZE_WARNING_BYTES = 5 * 1024 * 1024 * 1024;
327
+ export const TOTAL_DATABASE_SIZE_WARNING_BYTES = 50 * 1024 * 1024 * 1024;
328
+ const allowedKeys = new Set(POSTGRES_DIAGNOSTIC_KEYS);
329
+ export function parsePostgresDiagnostics(output) {
330
+ const diagnostics = {};
331
+ for (const rawLine of output.split(/\r?\n/iu)) {
332
+ const line = rawLine.trim();
333
+ if (!line) {
334
+ continue;
335
+ }
336
+ const separatorIndex = line.indexOf('|');
337
+ if (separatorIndex === -1) {
338
+ continue;
339
+ }
340
+ const key = line.slice(0, separatorIndex).trim();
341
+ const value = line.slice(separatorIndex + 1).trim();
342
+ if (!value || !allowedKeys.has(key)) {
343
+ continue;
344
+ }
345
+ diagnostics[key] = value;
346
+ }
347
+ return diagnostics;
348
+ }
349
+ export function renderPostgresDiagnostics(diagnostics) {
350
+ const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
351
+ const parts = POSTGRES_DIAGNOSTIC_KEYS.flatMap((key) => {
352
+ const value = diagnostics[key];
353
+ const rendered = value ? [`${key}=${value}`] : [];
354
+ if (key === 'max_connections' && connectionUtilizationPct !== undefined) {
355
+ rendered.push(`connection_utilization_pct=${connectionUtilizationPct}`);
356
+ }
357
+ return rendered;
358
+ });
359
+ return parts.length > 0 ? `OK PostgreSQL diagnostics ${parts.join(' ')}` : undefined;
360
+ }
361
+ export function missingPostgresDiagnosticKeys(diagnostics) {
362
+ return REQUIRED_POSTGRES_DIAGNOSTIC_KEYS.filter((key) => diagnostics[key] === undefined);
363
+ }
364
+ function integerDiagnostic(value) {
365
+ if (!value || !/^\d+$/u.test(value)) {
366
+ return undefined;
367
+ }
368
+ return Number.parseInt(value, 10);
369
+ }
370
+ function decimalDiagnostic(value) {
371
+ if (!value || !/^\d+(?:\.\d+)?$/u.test(value)) {
372
+ return undefined;
373
+ }
374
+ return Number.parseFloat(value);
375
+ }
376
+ export function malformedPostgresDiagnosticKeys(diagnostics) {
377
+ const malformed = [];
378
+ for (const key of INTEGER_POSTGRES_DIAGNOSTIC_KEYS) {
379
+ const value = diagnostics[key];
380
+ if (value !== undefined && integerDiagnostic(value) === undefined) {
381
+ malformed.push(key);
382
+ }
383
+ }
384
+ for (const key of DECIMAL_POSTGRES_DIAGNOSTIC_KEYS) {
385
+ const value = diagnostics[key];
386
+ if (value !== undefined && decimalDiagnostic(value) === undefined) {
387
+ malformed.push(key);
388
+ }
389
+ }
390
+ return malformed;
391
+ }
392
+ export function unavailablePostgresDiagnosticsWarning(diagnostics, missingKeys) {
393
+ return Object.keys(diagnostics).length === 0
394
+ ? 'no diagnostic rows returned'
395
+ : `incomplete diagnostic rows: missing ${missingKeys.join(', ')}`;
396
+ }
397
+ export function postgresConnectionUtilizationPct(diagnostics) {
398
+ const connectionCount = integerDiagnostic(diagnostics.connection_count);
399
+ const maxConnections = integerDiagnostic(diagnostics.max_connections);
400
+ if (connectionCount === undefined || maxConnections === undefined || maxConnections <= 0) {
401
+ return undefined;
402
+ }
403
+ return Math.round((connectionCount / maxConnections) * 100);
404
+ }
405
+ export function postgresConnectionUtilizationWarning(diagnostics) {
406
+ const connectionCount = integerDiagnostic(diagnostics.connection_count);
407
+ const maxConnections = integerDiagnostic(diagnostics.max_connections);
408
+ const utilizationPct = postgresConnectionUtilizationPct(diagnostics);
409
+ if (connectionCount === undefined ||
410
+ maxConnections === undefined ||
411
+ utilizationPct === undefined ||
412
+ utilizationPct < 80) {
413
+ return undefined;
414
+ }
415
+ return `PostgreSQL connection utilization is high: ${utilizationPct}% of max_connections used (${connectionCount}/${maxConnections}).`;
416
+ }
417
+ export function postgresSlowQueryLoggingWarning(diagnostics) {
418
+ const slowQueryLogging = diagnostics.slow_query_logging?.trim();
419
+ if (!slowQueryLogging || (!/^-1\s*(?:ms)?$/iu.test(slowQueryLogging) && !/^off$/iu.test(slowQueryLogging))) {
420
+ return undefined;
421
+ }
422
+ return `PostgreSQL slow-query logging is disabled (log_min_duration_statement=${slowQueryLogging}). Enable it before performance triage.`;
423
+ }
424
+ export function postgresExtensionVisibilityWarning(diagnostics) {
425
+ if (diagnostics.pg_stat_statements === 'available' &&
426
+ diagnostics.pg_stat_statements_available_version &&
427
+ !diagnostics.pg_stat_statements_installed_version) {
428
+ return 'PostgreSQL pg_stat_statements is available but not installed. Install it before query-level performance triage.';
429
+ }
430
+ return undefined;
431
+ }
432
+ export function postgresLongTransactionWarning(diagnostics) {
433
+ const longTransactionCount = integerDiagnostic(diagnostics.long_transaction_count);
434
+ const oldestAgeSeconds = integerDiagnostic(diagnostics.oldest_long_transaction_age_seconds);
435
+ if (longTransactionCount === undefined ||
436
+ oldestAgeSeconds === undefined ||
437
+ longTransactionCount < LONG_TRANSACTION_COUNT_WARNING_THRESHOLD ||
438
+ oldestAgeSeconds < LONG_TRANSACTION_AGE_WARNING_SECONDS) {
439
+ return undefined;
440
+ }
441
+ return `PostgreSQL has ${longTransactionCount} long transaction(s) running longer than ${LONG_TRANSACTION_AGE_WARNING_SECONDS / 60} minutes (oldest ${oldestAgeSeconds}s).`;
442
+ }
443
+ export function postgresIdleInTransactionWarning(diagnostics) {
444
+ const idleTransactionCount = integerDiagnostic(diagnostics.idle_in_transaction_count);
445
+ const oldestAgeSeconds = integerDiagnostic(diagnostics.oldest_idle_in_transaction_age_seconds);
446
+ if (idleTransactionCount === undefined ||
447
+ oldestAgeSeconds === undefined ||
448
+ idleTransactionCount < IDLE_IN_TRANSACTION_COUNT_WARNING_THRESHOLD ||
449
+ oldestAgeSeconds < IDLE_IN_TRANSACTION_AGE_WARNING_SECONDS) {
450
+ return undefined;
451
+ }
452
+ return `PostgreSQL has ${idleTransactionCount} transaction(s) idle in transaction for longer than ${IDLE_IN_TRANSACTION_AGE_WARNING_SECONDS / 60} minutes (oldest ${oldestAgeSeconds}s).`;
453
+ }
454
+ export function postgresTableHealthWarning(diagnostics) {
455
+ const riskyTables = integerDiagnostic(diagnostics.table_health_risky_table_count);
456
+ const vacuumNeeded = integerDiagnostic(diagnostics.table_health_requires_vacuum_count);
457
+ const analyzeNeeded = integerDiagnostic(diagnostics.table_health_requires_analyze_count);
458
+ const deadTupleRatio = decimalDiagnostic(diagnostics.table_health_top_risky_dead_tuple_ratio);
459
+ const deadTupleCount = integerDiagnostic(diagnostics.table_health_top_risky_dead_tuple_count);
460
+ if (riskyTables === undefined ||
461
+ vacuumNeeded === undefined ||
462
+ analyzeNeeded === undefined ||
463
+ deadTupleRatio === undefined ||
464
+ deadTupleCount === undefined) {
465
+ return undefined;
466
+ }
467
+ if (deadTupleRatio < TABLE_HEALTH_DEAD_TUPLE_RATIO_WARNING || deadTupleCount < TABLE_HEALTH_DEAD_TUPLE_COUNT_WARNING) {
468
+ return undefined;
469
+ }
470
+ const maxRiskyRatioPercent = Math.round(deadTupleRatio * 100);
471
+ return `PostgreSQL table health risk: ${riskyTables} table(s) have high dead-tuple ratio; ${vacuumNeeded} table(s) need vacuum and ${analyzeNeeded} need analyze. Top risk: ${diagnostics.table_health_top_risky_table ?? 'unavailable'} (${maxRiskyRatioPercent}% dead tuples, ${deadTupleCount} dead tuples).`;
472
+ }
473
+ export function postgresUnusedIndexWarning(diagnostics) {
474
+ const candidates = integerDiagnostic(diagnostics.unused_index_candidates_count);
475
+ if (candidates === undefined || candidates < UNUSED_INDEX_CANDIDATES_WARNING) {
476
+ return undefined;
477
+ }
478
+ return `PostgreSQL has ${candidates} unused index candidates with no index scans. Review indexing strategy before adding new indexes.`;
479
+ }
480
+ export function postgresWalWarning(diagnostics) {
481
+ const walSizeBytes = integerDiagnostic(diagnostics.wal_directory_size_bytes);
482
+ if (walSizeBytes !== undefined && walSizeBytes >= WAL_DIRECTORY_SIZE_WARNING_BYTES) {
483
+ return `PostgreSQL WAL visibility warning: WAL directory is ${walSizeBytes} bytes, which is high and can increase disk pressure.`;
484
+ }
485
+ return undefined;
486
+ }
487
+ export function postgresCapacityWarning(diagnostics) {
488
+ const totalDatabaseSizeBytes = integerDiagnostic(diagnostics.total_database_size_bytes);
489
+ if (totalDatabaseSizeBytes === undefined || totalDatabaseSizeBytes < TOTAL_DATABASE_SIZE_WARNING_BYTES) {
490
+ return undefined;
491
+ }
492
+ return `PostgreSQL capacity risk: total database size is ${totalDatabaseSizeBytes} bytes. Monitor growth and WAL retention settings before disk becomes constrained.`;
493
+ }
494
+ export function postgresPostgresWarnings(diagnostics) {
495
+ const warnings = [];
496
+ const connectionWarning = postgresConnectionUtilizationWarning(diagnostics);
497
+ if (connectionWarning) {
498
+ warnings.push(connectionWarning);
499
+ }
500
+ const slowQueryWarning = postgresSlowQueryLoggingWarning(diagnostics);
501
+ if (slowQueryWarning) {
502
+ warnings.push(slowQueryWarning);
503
+ }
504
+ const extensionWarning = postgresExtensionVisibilityWarning(diagnostics);
505
+ if (extensionWarning) {
506
+ warnings.push(extensionWarning);
507
+ }
508
+ const longTransactionWarning = postgresLongTransactionWarning(diagnostics);
509
+ if (longTransactionWarning) {
510
+ warnings.push(longTransactionWarning);
511
+ }
512
+ const idleTransactionWarning = postgresIdleInTransactionWarning(diagnostics);
513
+ if (idleTransactionWarning) {
514
+ warnings.push(idleTransactionWarning);
515
+ }
516
+ const tableHealthWarning = postgresTableHealthWarning(diagnostics);
517
+ if (tableHealthWarning) {
518
+ warnings.push(tableHealthWarning);
519
+ }
520
+ const indexWarning = postgresUnusedIndexWarning(diagnostics);
521
+ if (indexWarning) {
522
+ warnings.push(indexWarning);
523
+ }
524
+ const walWarning = postgresWalWarning(diagnostics);
525
+ if (walWarning) {
526
+ warnings.push(walWarning);
527
+ }
528
+ const capacityWarning = postgresCapacityWarning(diagnostics);
529
+ if (capacityWarning) {
530
+ warnings.push(capacityWarning);
531
+ }
532
+ return warnings;
533
+ }
534
+ export function structuredPostgresDiagnostics(diagnostics) {
535
+ const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
536
+ const databaseCount = integerDiagnostic(diagnostics.database_count);
537
+ const activeConnections = integerDiagnostic(diagnostics.active_connections);
538
+ const connectionCount = integerDiagnostic(diagnostics.connection_count);
539
+ const maxConnections = integerDiagnostic(diagnostics.max_connections);
540
+ const totalDatabaseSizeBytes = integerDiagnostic(diagnostics.total_database_size_bytes);
541
+ const largestDatabaseSizeBytes = integerDiagnostic(diagnostics.largest_database_size_bytes);
542
+ const tableHealthRiskyTableCount = integerDiagnostic(diagnostics.table_health_risky_table_count);
543
+ const tableHealthRequiresVacuumCount = integerDiagnostic(diagnostics.table_health_requires_vacuum_count);
544
+ const tableHealthRequiresAnalyzeCount = integerDiagnostic(diagnostics.table_health_requires_analyze_count);
545
+ const tableHealthTopRiskyDeadTupleRatio = decimalDiagnostic(diagnostics.table_health_top_risky_dead_tuple_ratio);
546
+ const tableHealthTopRiskyDeadTupleCount = integerDiagnostic(diagnostics.table_health_top_risky_dead_tuple_count);
547
+ const longTransactionCount = integerDiagnostic(diagnostics.long_transaction_count);
548
+ const oldestLongTransactionAgeSeconds = integerDiagnostic(diagnostics.oldest_long_transaction_age_seconds);
549
+ const idleInTransactionCount = integerDiagnostic(diagnostics.idle_in_transaction_count);
550
+ const oldestIdleInTransactionAgeSeconds = integerDiagnostic(diagnostics.oldest_idle_in_transaction_age_seconds);
551
+ const unusedIndexCandidatesCount = integerDiagnostic(diagnostics.unused_index_candidates_count);
552
+ const walFileCount = integerDiagnostic(diagnostics.wal_file_count);
553
+ const walDirectorySizeBytes = integerDiagnostic(diagnostics.wal_directory_size_bytes);
554
+ const defaultTablespaceSizeBytes = integerDiagnostic(diagnostics.default_tablespace_size_bytes);
555
+ const databaseWriteActivityRows = integerDiagnostic(diagnostics.database_write_activity_rows);
556
+ const structured = {
557
+ schemaVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
558
+ };
559
+ if (databaseCount !== undefined)
560
+ structured.databaseCount = databaseCount;
561
+ if (activeConnections !== undefined)
562
+ structured.activeConnections = activeConnections;
563
+ if (connectionCount !== undefined)
564
+ structured.connectionCount = connectionCount;
565
+ if (maxConnections !== undefined)
566
+ structured.maxConnections = maxConnections;
567
+ if (connectionUtilizationPct !== undefined)
568
+ structured.connectionUtilizationPct = connectionUtilizationPct;
569
+ if (totalDatabaseSizeBytes !== undefined)
570
+ structured.totalDatabaseSizeBytes = totalDatabaseSizeBytes;
571
+ if (diagnostics.largest_database_name) {
572
+ structured.largestDatabaseName = diagnostics.largest_database_name;
573
+ }
574
+ if (largestDatabaseSizeBytes !== undefined)
575
+ structured.largestDatabaseSizeBytes = largestDatabaseSizeBytes;
576
+ if (diagnostics.slow_query_logging) {
577
+ structured.slowQueryLogging = diagnostics.slow_query_logging;
578
+ }
579
+ if (diagnostics.pg_stat_statements) {
580
+ structured.pgStatStatements = diagnostics.pg_stat_statements;
581
+ }
582
+ if (diagnostics.pg_stat_statements_available_version) {
583
+ structured.pgStatStatementsAvailableVersion = diagnostics.pg_stat_statements_available_version;
584
+ }
585
+ if (diagnostics.pg_stat_statements_installed_version) {
586
+ structured.pgStatStatementsInstalledVersion = diagnostics.pg_stat_statements_installed_version;
587
+ }
588
+ if (diagnostics.shared_preload_libraries) {
589
+ structured.sharedPreloadLibraries = diagnostics.shared_preload_libraries;
590
+ }
591
+ if (diagnostics.shared_buffers) {
592
+ structured.sharedBuffers = diagnostics.shared_buffers;
593
+ }
594
+ if (longTransactionCount !== undefined) {
595
+ structured.longTransactionCount = longTransactionCount;
596
+ }
597
+ if (oldestLongTransactionAgeSeconds !== undefined) {
598
+ structured.oldestLongTransactionAgeSeconds = oldestLongTransactionAgeSeconds;
599
+ }
600
+ if (idleInTransactionCount !== undefined) {
601
+ structured.idleInTransactionCount = idleInTransactionCount;
602
+ }
603
+ if (oldestIdleInTransactionAgeSeconds !== undefined) {
604
+ structured.oldestIdleInTransactionAgeSeconds = oldestIdleInTransactionAgeSeconds;
605
+ }
606
+ if (tableHealthRiskyTableCount !== undefined) {
607
+ structured.tableHealthRiskyTableCount = tableHealthRiskyTableCount;
608
+ }
609
+ if (tableHealthRequiresVacuumCount !== undefined) {
610
+ structured.tableHealthRequiresVacuumCount = tableHealthRequiresVacuumCount;
611
+ }
612
+ if (tableHealthRequiresAnalyzeCount !== undefined) {
613
+ structured.tableHealthRequiresAnalyzeCount = tableHealthRequiresAnalyzeCount;
614
+ }
615
+ if (diagnostics.table_health_top_risky_table) {
616
+ structured.tableHealthTopRiskyTable = diagnostics.table_health_top_risky_table;
617
+ }
618
+ if (tableHealthTopRiskyDeadTupleRatio !== undefined) {
619
+ structured.tableHealthTopRiskyDeadTupleRatio = tableHealthTopRiskyDeadTupleRatio;
620
+ }
621
+ if (tableHealthTopRiskyDeadTupleCount !== undefined) {
622
+ structured.tableHealthTopRiskyDeadTupleCount = tableHealthTopRiskyDeadTupleCount;
623
+ }
624
+ if (unusedIndexCandidatesCount !== undefined) {
625
+ structured.unusedIndexCandidatesCount = unusedIndexCandidatesCount;
626
+ }
627
+ if (diagnostics.wal_level) {
628
+ structured.walLevel = diagnostics.wal_level;
629
+ }
630
+ if (diagnostics.wal_archive_mode) {
631
+ structured.walArchiveMode = diagnostics.wal_archive_mode;
632
+ }
633
+ if (walFileCount !== undefined) {
634
+ structured.walFileCount = walFileCount;
635
+ }
636
+ if (walDirectorySizeBytes !== undefined) {
637
+ structured.walDirectorySizeBytes = walDirectorySizeBytes;
638
+ }
639
+ if (defaultTablespaceSizeBytes !== undefined) {
640
+ structured.defaultTablespaceSizeBytes = defaultTablespaceSizeBytes;
641
+ }
642
+ if (databaseWriteActivityRows !== undefined) {
643
+ structured.databaseWriteActivityRows = databaseWriteActivityRows;
644
+ }
645
+ return structured;
646
+ }
@@ -23,7 +23,7 @@ not validate staging or production deployments.
23
23
  | Doctor checks | Metadata, compose files, scripts, source repo paths, and local tooling checks behave as expected. | `npx @wpmoo/toolkit doctor` or `./moo doctor` |
24
24
  | Doctor safe fixes | Safe file-level fixes are applied only with `--fix`, then doctor runs again and reports any remaining manual issues. | `npx @wpmoo/toolkit doctor --fix` |
25
25
  | Generated Postgres checks | For PostgreSQL 18 environments, doctor validates db mount targets avoid old PG image-specific paths and can normalize safe targets with `--fix`. | `npx @wpmoo/toolkit doctor`, `npx @wpmoo/toolkit doctor --fix` |
26
- | PostgreSQL diagnostics | Optional read-only database health/performance diagnostics report database count, sessions currently running queries with `pg_stat_activity.state = 'active'`, total database size, slow-query readiness, extension visibility, and selected settings without failing doctor when the database is unavailable. | `npx @wpmoo/toolkit doctor --postgres`, `npx @wpmoo/toolkit doctor --json --postgres` |
26
+ | PostgreSQL diagnostics | Optional read-only, advisory-only database health and performance diagnostics (no automatic tuning), covering active/idle-in-transaction sessions, table health signals, WAL/capacity context, unused-index hints, and slow-query readiness. JSON mode emits a versioned PostgreSQL contract with optional fields when a metric is unavailable. | `npx @wpmoo/toolkit doctor --postgres`, `npx @wpmoo/toolkit doctor --json --postgres` |
27
27
  | Source repo add/remove | Source repository registration and submodule lifecycle behave correctly. | `npx @wpmoo/toolkit add-repo ...`, `npx @wpmoo/toolkit remove-repo ...` |
28
28
  | Source manifest sync | Source repo metadata, `.gitmodules`, and `odoo/custom/manifests/sources.yaml` stay aligned. | `npx @wpmoo/toolkit source list`, `npx @wpmoo/toolkit source sync` |
29
29
  | Module add/remove | Module skeleton files include manifest, model, access CSV, explicit view XML, action/menu XML, post-install test scaffold, and selected source repo registration. Existing scaffold files are not overwritten. | `npx @wpmoo/toolkit add-module ...`, `npx @wpmoo/toolkit remove-module ...` |
@@ -76,16 +76,16 @@ is involved, use PostgreSQL upgrade tooling first.
76
76
 
77
77
  Use `doctor --postgres` when the database container is running and you want
78
78
  read-only PostgreSQL diagnostics. The check uses fixed diagnostic queries for
79
- database count, sessions currently running queries where
80
- `pg_stat_activity.state` is `active`, aggregate database size, slow-query
81
- logging readiness,
82
- `pg_stat_statements` availability, and `shared_buffers`. If the database is
83
- unavailable, doctor reports a warning instead of failing the whole environment
84
- check.
85
- JSON output preserves `checks` and `warnings` while adding a structured
86
- `postgres` object when `--postgres` is requested.
87
- Incomplete or malformed PostgreSQL metric rows are reported as unavailable
88
- diagnostics.
79
+ database count, sessions currently running queries where `pg_stat_activity.state`
80
+ is `active`, long transactions / idle-in-transaction sessions, table health
81
+ signals, unused index advisor signals, WAL and capacity visibility, and
82
+ slow-query logging readiness (`log_min_duration_statement` and
83
+ `pg_stat_statements` visibility). If the database is unavailable, doctor reports
84
+ a warning instead of failing the whole environment check.
85
+
86
+ `doctor --json --postgres` keeps output stable by exposing a versioned PostgreSQL
87
+ diagnostics contract. The contract is intentionally permissive: fields are optional
88
+ and omitted or marked unavailable when a running database does not expose them.
89
89
 
90
90
  ## Safe reset policy
91
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.26",
3
+ "version": "0.9.27",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {