@calltelemetry/cli 0.12.20 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/db.d.ts +1 -0
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +29 -0
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/ship-db.d.ts +1 -1
- package/dist/commands/ship-db.d.ts.map +1 -1
- package/dist/commands/ship-db.js +30 -1
- package/dist/commands/ship-db.js.map +1 -1
- package/dist/commands/ship-shell.d.ts.map +1 -1
- package/dist/commands/ship-shell.js +8 -5
- package/dist/commands/ship-shell.js.map +1 -1
- package/dist/commands/ship-update.d.ts +1 -0
- package/dist/commands/ship-update.d.ts.map +1 -1
- package/dist/commands/ship-update.js +12 -17
- package/dist/commands/ship-update.js.map +1 -1
- package/dist/commands/update.d.ts +2 -2
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +17 -23
- package/dist/commands/update.js.map +1 -1
- package/dist/lib/auto-update.d.ts +15 -4
- package/dist/lib/auto-update.d.ts.map +1 -1
- package/dist/lib/auto-update.js +110 -39
- package/dist/lib/auto-update.js.map +1 -1
- package/dist/lib/paths.d.ts +5 -0
- package/dist/lib/paths.d.ts.map +1 -1
- package/dist/lib/paths.js +5 -0
- package/dist/lib/paths.js.map +1 -1
- package/dist/lib/postgres-profiles.d.ts +51 -0
- package/dist/lib/postgres-profiles.d.ts.map +1 -0
- package/dist/lib/postgres-profiles.js +103 -0
- package/dist/lib/postgres-profiles.js.map +1 -0
- package/dist/lib/postgres.d.ts +2 -1
- package/dist/lib/postgres.d.ts.map +1 -1
- package/dist/lib/postgres.js +3 -30
- package/dist/lib/postgres.js.map +1 -1
- package/dist/lib/storage-report.d.ts +199 -0
- package/dist/lib/storage-report.d.ts.map +1 -0
- package/dist/lib/storage-report.js +986 -0
- package/dist/lib/storage-report.js.map +1 -0
- package/dist/lib/update-steps.d.ts +5 -1
- package/dist/lib/update-steps.d.ts.map +1 -1
- package/dist/lib/update-steps.js +14 -2
- package/dist/lib/update-steps.js.map +1 -1
- package/dist/lib/version.d.ts +1 -1
- package/dist/lib/version.d.ts.map +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/shell/commands/appliance-performance.d.ts +2 -1
- package/dist/shell/commands/appliance-performance.d.ts.map +1 -1
- package/dist/shell/commands/appliance-performance.js +24 -56
- package/dist/shell/commands/appliance-performance.js.map +1 -1
- package/dist/shell/commands/db.d.ts +1 -0
- package/dist/shell/commands/db.d.ts.map +1 -1
- package/dist/shell/commands/db.js +26 -0
- package/dist/shell/commands/db.js.map +1 -1
- package/dist/shell/commands/registry.d.ts.map +1 -1
- package/dist/shell/commands/registry.js +10 -0
- package/dist/shell/commands/registry.js.map +1 -1
- package/dist/shell/commands/system.d.ts.map +1 -1
- package/dist/shell/commands/system.js +5 -3
- package/dist/shell/commands/system.js.map +1 -1
- package/dist/ship-entry.js +1 -1
- package/dist/ui/views/SelfUpdateView.d.ts.map +1 -1
- package/dist/ui/views/SelfUpdateView.js +15 -24
- package/dist/ui/views/SelfUpdateView.js.map +1 -1
- package/dist/utils/version.d.ts +11 -0
- package/dist/utils/version.d.ts.map +1 -1
- package/dist/utils/version.js +30 -0
- package/dist/utils/version.js.map +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { execa } from 'execa';
|
|
6
|
+
import { getPaths } from './paths.js';
|
|
7
|
+
import { formatDbSize, runBoundedQuery } from './db-probe.js';
|
|
8
|
+
import { DB_NAME } from './db.js';
|
|
9
|
+
import { redactSecrets } from './redact.js';
|
|
10
|
+
export const STORAGE_REPORT_SCHEMA_VERSION = 1;
|
|
11
|
+
const PURGE_TABLE_NAMES = [
|
|
12
|
+
'purge_table_configs',
|
|
13
|
+
'purge_audit_statuses',
|
|
14
|
+
'user_activity_logs',
|
|
15
|
+
'purge_table_failure_tracker',
|
|
16
|
+
'purge_dispatch_cursors',
|
|
17
|
+
'oban_jobs',
|
|
18
|
+
];
|
|
19
|
+
function clamp(value, min, max) {
|
|
20
|
+
if (!Number.isFinite(value))
|
|
21
|
+
return min;
|
|
22
|
+
return Math.min(max, Math.max(min, Math.round(value)));
|
|
23
|
+
}
|
|
24
|
+
export function normalizeStorageReportOptions(options = {}) {
|
|
25
|
+
return {
|
|
26
|
+
top: clamp(options.top ?? 50, 1, 500),
|
|
27
|
+
scanDepth: clamp(options.scanDepth ?? 2, 1, 8),
|
|
28
|
+
timeoutMs: clamp(options.timeoutMs ?? 30_000, 5_000, 120_000),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function pretty(bytes) {
|
|
32
|
+
return formatDbSize(Math.max(0, Number.isFinite(bytes) ? bytes : 0));
|
|
33
|
+
}
|
|
34
|
+
function numberValue(value) {
|
|
35
|
+
if (value === null || value === undefined || value === '')
|
|
36
|
+
return null;
|
|
37
|
+
const parsed = Number(value);
|
|
38
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
39
|
+
}
|
|
40
|
+
function integerValue(value) {
|
|
41
|
+
const parsed = numberValue(value);
|
|
42
|
+
return parsed === null ? null : Math.trunc(parsed);
|
|
43
|
+
}
|
|
44
|
+
function booleanValue(value) {
|
|
45
|
+
if (value === true || value === 't' || value === 'true')
|
|
46
|
+
return true;
|
|
47
|
+
if (value === false || value === 'f' || value === 'false')
|
|
48
|
+
return false;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
function stringValue(value) {
|
|
52
|
+
return value === null || value === undefined || value === '' ? null : String(value);
|
|
53
|
+
}
|
|
54
|
+
function quoteIdentifier(value) {
|
|
55
|
+
return `"${value.replaceAll('"', '""')}"`;
|
|
56
|
+
}
|
|
57
|
+
function quoteLiteral(value) {
|
|
58
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
59
|
+
}
|
|
60
|
+
async function runExternal(command, args, timeoutMs) {
|
|
61
|
+
try {
|
|
62
|
+
const result = await execa(command, args, {
|
|
63
|
+
timeout: timeoutMs,
|
|
64
|
+
forceKillAfterDelay: 1_000,
|
|
65
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
66
|
+
reject: false,
|
|
67
|
+
stdout: 'pipe',
|
|
68
|
+
stderr: 'pipe',
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
stdout: result.stdout ?? '',
|
|
72
|
+
stderr: result.stderr ?? '',
|
|
73
|
+
exitCode: result.exitCode,
|
|
74
|
+
timedOut: result.timedOut === true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
return {
|
|
79
|
+
stdout: error?.stdout ?? '',
|
|
80
|
+
stderr: error?.stderr ?? error?.message ?? String(error),
|
|
81
|
+
exitCode: error?.exitCode,
|
|
82
|
+
timedOut: error?.timedOut === true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function parseDfOutput(output, path, inodeOutput) {
|
|
87
|
+
const usageLine = output.trim().split('\n').slice(1).find((line) => /\d+%/.test(line));
|
|
88
|
+
if (!usageLine)
|
|
89
|
+
throw new Error(`Could not parse disk usage for ${path}`);
|
|
90
|
+
const parts = usageLine.trim().split(/\s+/);
|
|
91
|
+
const capacityIndex = parts.findIndex((part) => /^\d+%$/.test(part));
|
|
92
|
+
if (capacityIndex < 3)
|
|
93
|
+
throw new Error(`Could not parse disk usage for ${path}`);
|
|
94
|
+
const blocks = numberValue(parts[capacityIndex - 3]);
|
|
95
|
+
const used = numberValue(parts[capacityIndex - 2]);
|
|
96
|
+
const available = numberValue(parts[capacityIndex - 1]);
|
|
97
|
+
const usedPercent = numberValue(parts[capacityIndex]?.replace('%', ''));
|
|
98
|
+
if (blocks === null || used === null || available === null || usedPercent === null) {
|
|
99
|
+
throw new Error(`Could not parse disk usage for ${path}`);
|
|
100
|
+
}
|
|
101
|
+
let inodeUsedPercent = null;
|
|
102
|
+
const inodeLine = inodeOutput?.trim().split('\n').slice(1).find((line) => /\d+%/.test(line));
|
|
103
|
+
const inodePercent = inodeLine?.match(/(\d+)%/);
|
|
104
|
+
if (inodePercent)
|
|
105
|
+
inodeUsedPercent = Number(inodePercent[1]);
|
|
106
|
+
return {
|
|
107
|
+
path,
|
|
108
|
+
filesystem: parts.slice(0, capacityIndex - 3).join(' ') || 'unknown',
|
|
109
|
+
mount: parts.slice(capacityIndex + 1).join(' ') || path,
|
|
110
|
+
sizeBytes: blocks * 1024,
|
|
111
|
+
usedBytes: used * 1024,
|
|
112
|
+
availableBytes: available * 1024,
|
|
113
|
+
usedPercent,
|
|
114
|
+
inodeUsedPercent,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export function parseDuOutput(output, limit) {
|
|
118
|
+
return output
|
|
119
|
+
.split('\n')
|
|
120
|
+
.map((line) => {
|
|
121
|
+
const match = line.match(/^\s*(\d+)\s+(.+)$/);
|
|
122
|
+
if (!match)
|
|
123
|
+
return null;
|
|
124
|
+
const bytes = Number(match[1]) * 1024;
|
|
125
|
+
return Number.isFinite(bytes) ? { path: match[2], bytes, pretty: pretty(bytes) } : null;
|
|
126
|
+
})
|
|
127
|
+
.filter((entry) => entry !== null)
|
|
128
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
129
|
+
.slice(0, limit);
|
|
130
|
+
}
|
|
131
|
+
export function parseSizedPathOutput(output, limit) {
|
|
132
|
+
return output
|
|
133
|
+
.split('\n')
|
|
134
|
+
.map((line) => {
|
|
135
|
+
const tab = line.indexOf('\t');
|
|
136
|
+
if (tab <= 0)
|
|
137
|
+
return null;
|
|
138
|
+
const bytes = Number(line.slice(0, tab).trim());
|
|
139
|
+
const path = line.slice(tab + 1).trim();
|
|
140
|
+
return Number.isFinite(bytes) && path ? { path, bytes, pretty: pretty(bytes) } : null;
|
|
141
|
+
})
|
|
142
|
+
.filter((entry) => entry !== null)
|
|
143
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
144
|
+
.slice(0, limit);
|
|
145
|
+
}
|
|
146
|
+
async function collectFilesystems(options, warnings) {
|
|
147
|
+
const paths = getPaths();
|
|
148
|
+
const candidates = [
|
|
149
|
+
'/',
|
|
150
|
+
paths.installDir,
|
|
151
|
+
paths.postgresDataDir,
|
|
152
|
+
paths.backupDir,
|
|
153
|
+
paths.dbDumpsDir,
|
|
154
|
+
paths.certsDir,
|
|
155
|
+
];
|
|
156
|
+
const unique = [...new Set(candidates)];
|
|
157
|
+
const records = [];
|
|
158
|
+
for (const path of unique) {
|
|
159
|
+
if (path !== '/' && !existsSync(path))
|
|
160
|
+
continue;
|
|
161
|
+
const usage = await runExternal('df', ['-Pk', path], options.timeoutMs);
|
|
162
|
+
const inode = await runExternal('df', ['-Pi', path], options.timeoutMs);
|
|
163
|
+
try {
|
|
164
|
+
records.push(parseDfOutput(usage.stdout, path, inode.stdout));
|
|
165
|
+
if (usage.timedOut || usage.exitCode !== 0) {
|
|
166
|
+
warnings.push(`df reported a non-zero result for ${path}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
warnings.push(`filesystem usage for ${path} unavailable: ${redactSecrets(String(error))}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return records;
|
|
174
|
+
}
|
|
175
|
+
function scanRoots(filesystems) {
|
|
176
|
+
const roots = [];
|
|
177
|
+
const seenFilesystems = new Set();
|
|
178
|
+
for (const filesystem of filesystems) {
|
|
179
|
+
if (seenFilesystems.has(filesystem.filesystem))
|
|
180
|
+
continue;
|
|
181
|
+
seenFilesystems.add(filesystem.filesystem);
|
|
182
|
+
roots.push(filesystem.path);
|
|
183
|
+
}
|
|
184
|
+
if (roots.length > 0)
|
|
185
|
+
return roots;
|
|
186
|
+
const paths = getPaths();
|
|
187
|
+
return [...new Set([
|
|
188
|
+
'/',
|
|
189
|
+
paths.installDir,
|
|
190
|
+
paths.postgresDataDir,
|
|
191
|
+
paths.backupDir,
|
|
192
|
+
paths.dbDumpsDir,
|
|
193
|
+
paths.certsDir,
|
|
194
|
+
])].filter((path) => path === '/' || existsSync(path));
|
|
195
|
+
}
|
|
196
|
+
async function collectTopDirectories(options, roots, warnings) {
|
|
197
|
+
const byPath = new Map();
|
|
198
|
+
const depth = String(options.scanDepth);
|
|
199
|
+
for (const root of roots) {
|
|
200
|
+
let result = await runExternal('du', ['-x', '-k', '-d', depth, root], options.timeoutMs);
|
|
201
|
+
let entries = parseDuOutput(result.stdout, options.top);
|
|
202
|
+
if (entries.length === 0) {
|
|
203
|
+
result = await runExternal('du', ['-x', '-k', `--max-depth=${depth}`, root], options.timeoutMs);
|
|
204
|
+
entries = parseDuOutput(result.stdout, options.top);
|
|
205
|
+
}
|
|
206
|
+
for (const entry of entries)
|
|
207
|
+
byPath.set(entry.path, entry);
|
|
208
|
+
if (entries.length === 0) {
|
|
209
|
+
warnings.push(`top folder scan unavailable for ${root}${result.timedOut ? ' (timed out)' : ''}`);
|
|
210
|
+
}
|
|
211
|
+
else if (result.exitCode !== undefined && result.exitCode !== 0) {
|
|
212
|
+
warnings.push(`top folder scan for ${root} completed with permission or traversal errors`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return [...byPath.values()].sort((a, b) => b.bytes - a.bytes).slice(0, options.top);
|
|
216
|
+
}
|
|
217
|
+
async function collectTopFiles(options, roots, warnings) {
|
|
218
|
+
const byPath = new Map();
|
|
219
|
+
for (const root of roots) {
|
|
220
|
+
const probes = [
|
|
221
|
+
['find', [root, '-xdev', '-type', 'f', '-printf', '%s\\t%p\\n']],
|
|
222
|
+
['find', [root, '-xdev', '-type', 'f', '-exec', 'stat', '-c', '%s\\t%n', '{}', '+']],
|
|
223
|
+
['find', [root, '-xdev', '-type', 'f', '-exec', 'stat', '-f', '%z\\t%N', '{}', '+']],
|
|
224
|
+
];
|
|
225
|
+
let found = false;
|
|
226
|
+
for (const [command, args] of probes) {
|
|
227
|
+
const result = await runExternal(command, args, options.timeoutMs);
|
|
228
|
+
const entries = parseSizedPathOutput(result.stdout, options.top);
|
|
229
|
+
if (entries.length > 0) {
|
|
230
|
+
for (const entry of entries)
|
|
231
|
+
byPath.set(entry.path, entry);
|
|
232
|
+
found = true;
|
|
233
|
+
if (result.exitCode !== undefined && result.exitCode !== 0) {
|
|
234
|
+
warnings.push(`top file scan for ${root} completed with permission or traversal errors`);
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!found)
|
|
240
|
+
warnings.push(`top file scan unavailable for ${root}`);
|
|
241
|
+
}
|
|
242
|
+
return [...byPath.values()].sort((a, b) => b.bytes - a.bytes).slice(0, options.top);
|
|
243
|
+
}
|
|
244
|
+
async function queryText(context, label, sql) {
|
|
245
|
+
try {
|
|
246
|
+
const raw = await runBoundedQuery(sql, context.options);
|
|
247
|
+
return raw || null;
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
const reason = error?.reason ? ` (${error.reason})` : '';
|
|
251
|
+
context.warnings.push(`${label}: ${redactSecrets(error?.message ?? String(error))}${reason}`);
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async function queryJson(context, label, sql) {
|
|
256
|
+
const raw = await queryText(context, label, sql);
|
|
257
|
+
if (!raw)
|
|
258
|
+
return null;
|
|
259
|
+
try {
|
|
260
|
+
return JSON.parse(raw);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
context.warnings.push(`${label}: database returned invalid JSON (${redactSecrets(String(error))})`);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function tableColumns(context, tableName) {
|
|
268
|
+
const rows = await queryJson(context, `${tableName} column inspection`, `SELECT COALESCE(jsonb_agg(jsonb_build_object('column_name', column_name) ORDER BY ordinal_position), '[]'::jsonb)
|
|
269
|
+
FROM information_schema.columns
|
|
270
|
+
WHERE table_schema = 'public' AND table_name = ${quoteLiteral(tableName)};`);
|
|
271
|
+
return rows ? new Set(rows.map((row) => row.column_name)) : null;
|
|
272
|
+
}
|
|
273
|
+
function optionalColumn(columns, alias, outputName, candidates, nullType) {
|
|
274
|
+
const column = candidates.find((candidate) => columns.has(candidate));
|
|
275
|
+
return column
|
|
276
|
+
? `${quoteIdentifier(alias)}.${quoteIdentifier(column)} AS ${quoteIdentifier(outputName)}`
|
|
277
|
+
: `NULL::${nullType} AS ${quoteIdentifier(outputName)}`;
|
|
278
|
+
}
|
|
279
|
+
function relationMap(relations) {
|
|
280
|
+
return new Map(relations.map((relation) => [
|
|
281
|
+
`${relation.schemaName}.${relation.tableName}`,
|
|
282
|
+
relation,
|
|
283
|
+
]));
|
|
284
|
+
}
|
|
285
|
+
const RELATION_QUERY = `
|
|
286
|
+
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.total_bytes DESC), '[]'::jsonb)
|
|
287
|
+
FROM (
|
|
288
|
+
SELECT n.nspname AS schema_name,
|
|
289
|
+
c.relname AS table_name,
|
|
290
|
+
CASE c.relkind
|
|
291
|
+
WHEN 'r' THEN 'table'
|
|
292
|
+
WHEN 'p' THEN 'partitioned_table'
|
|
293
|
+
WHEN 'm' THEN 'materialized_view'
|
|
294
|
+
ELSE c.relkind::text
|
|
295
|
+
END AS relation_kind,
|
|
296
|
+
c.relispartition AS is_partition,
|
|
297
|
+
pg_total_relation_size(c.oid)::bigint AS total_bytes,
|
|
298
|
+
pg_table_size(c.oid)::bigint AS table_bytes,
|
|
299
|
+
pg_relation_size(c.oid)::bigint AS heap_bytes,
|
|
300
|
+
pg_indexes_size(c.oid)::bigint AS index_bytes,
|
|
301
|
+
GREATEST(pg_table_size(c.oid) - pg_relation_size(c.oid), 0)::bigint AS toast_bytes,
|
|
302
|
+
COALESCE(s.n_live_tup, 0)::bigint AS estimated_live_rows,
|
|
303
|
+
COALESCE(s.n_dead_tup, 0)::bigint AS estimated_dead_rows,
|
|
304
|
+
s.last_vacuum,
|
|
305
|
+
s.last_autovacuum,
|
|
306
|
+
s.last_analyze,
|
|
307
|
+
s.last_autoanalyze
|
|
308
|
+
FROM pg_class c
|
|
309
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
310
|
+
LEFT JOIN pg_stat_all_tables s ON s.relid = c.oid
|
|
311
|
+
WHERE n.nspname NOT LIKE 'pg_%'
|
|
312
|
+
AND n.nspname <> 'information_schema'
|
|
313
|
+
AND c.relkind IN ('r', 'p', 'm')
|
|
314
|
+
) x;`;
|
|
315
|
+
const OLDEST_CANDIDATE_QUERY = `
|
|
316
|
+
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.schema_name, x.table_name), '[]'::jsonb)
|
|
317
|
+
FROM (
|
|
318
|
+
SELECT DISTINCT ON (c.oid)
|
|
319
|
+
n.nspname AS schema_name,
|
|
320
|
+
c.relname AS table_name,
|
|
321
|
+
a.attname AS column_name
|
|
322
|
+
FROM pg_class c
|
|
323
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
324
|
+
JOIN pg_attribute a ON a.attrelid = c.oid
|
|
325
|
+
WHERE n.nspname NOT LIKE 'pg_%'
|
|
326
|
+
AND n.nspname <> 'information_schema'
|
|
327
|
+
AND c.relkind = 'r'
|
|
328
|
+
AND a.attnum > 0
|
|
329
|
+
AND NOT a.attisdropped
|
|
330
|
+
AND a.attname = ANY (ARRAY['occurred_at','inserted_at','recorded_at','created_at','captured_at','started_at','completed_at','updated_at'])
|
|
331
|
+
AND format_type(a.atttypid, a.atttypmod) = ANY (ARRAY['timestamp without time zone','timestamp with time zone','date'])
|
|
332
|
+
AND (
|
|
333
|
+
c.relname ILIKE ANY (ARRAY['%history%','%log%','%event%','%audit%','%purge%','%cdr%','%record%'])
|
|
334
|
+
OR c.relname = 'oban_jobs'
|
|
335
|
+
)
|
|
336
|
+
ORDER BY c.oid,
|
|
337
|
+
array_position(ARRAY['occurred_at','inserted_at','recorded_at','created_at','captured_at','started_at','completed_at','updated_at'], a.attname)
|
|
338
|
+
) x;`;
|
|
339
|
+
async function collectOldestTables(context, relations, limit) {
|
|
340
|
+
const candidates = await queryJson(context, 'oldest-record candidate discovery', OLDEST_CANDIDATE_QUERY);
|
|
341
|
+
if (!candidates || candidates.length === 0)
|
|
342
|
+
return [];
|
|
343
|
+
const relationSizes = relationMap(relations);
|
|
344
|
+
const prioritizedCandidates = [...candidates].sort((left, right) => {
|
|
345
|
+
const leftBytes = relationSizes.get(`${left.schema_name}.${left.table_name}`)?.totalBytes ?? 0;
|
|
346
|
+
const rightBytes = relationSizes.get(`${right.schema_name}.${right.table_name}`)?.totalBytes ?? 0;
|
|
347
|
+
return rightBytes - leftBytes
|
|
348
|
+
|| left.schema_name.localeCompare(right.schema_name)
|
|
349
|
+
|| left.table_name.localeCompare(right.table_name);
|
|
350
|
+
});
|
|
351
|
+
const selects = prioritizedCandidates.slice(0, limit).map((candidate) => {
|
|
352
|
+
const relation = relationSizes.get(`${candidate.schema_name}.${candidate.table_name}`);
|
|
353
|
+
const qualified = `${quoteIdentifier(candidate.schema_name)}.${quoteIdentifier(candidate.table_name)}`;
|
|
354
|
+
const column = quoteIdentifier(candidate.column_name);
|
|
355
|
+
return `SELECT ${quoteLiteral(candidate.schema_name)} AS schema_name,
|
|
356
|
+
${quoteLiteral(candidate.table_name)} AS table_name,
|
|
357
|
+
${quoteLiteral(candidate.column_name)} AS column_name,
|
|
358
|
+
min(${column})::text AS oldest_at,
|
|
359
|
+
max(${column})::text AS newest_at,
|
|
360
|
+
${relation?.totalBytes ?? 0}::bigint AS relation_bytes,
|
|
361
|
+
${relation?.estimatedLiveRows ?? 0}::bigint AS estimated_rows
|
|
362
|
+
FROM ${qualified}
|
|
363
|
+
WHERE ${column} IS NOT NULL`;
|
|
364
|
+
});
|
|
365
|
+
const rows = await queryJson(context, 'oldest-record timestamp scan', `SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.oldest_at NULLS LAST), '[]'::jsonb)
|
|
366
|
+
FROM (${selects.join('\nUNION ALL\n')}) x;`);
|
|
367
|
+
return (rows ?? []).map((row) => ({
|
|
368
|
+
schemaName: String(row.schema_name ?? ''),
|
|
369
|
+
tableName: String(row.table_name ?? ''),
|
|
370
|
+
columnName: String(row.column_name ?? ''),
|
|
371
|
+
oldestAt: stringValue(row.oldest_at),
|
|
372
|
+
newestAt: stringValue(row.newest_at),
|
|
373
|
+
relationBytes: integerValue(row.relation_bytes) ?? 0,
|
|
374
|
+
estimatedRows: integerValue(row.estimated_rows) ?? 0,
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
function configStatus(row, nowMs) {
|
|
378
|
+
if (booleanValue(row.enabled) === false)
|
|
379
|
+
return 'disabled';
|
|
380
|
+
const lastSizeKb = numberValue(row.last_size_kb);
|
|
381
|
+
const sizeMaxKb = numberValue(row.size_max_kb);
|
|
382
|
+
const sizeHighKb = numberValue(row.size_threshold_high_kb);
|
|
383
|
+
if (lastSizeKb !== null && sizeMaxKb !== null && lastSizeKb > sizeMaxKb)
|
|
384
|
+
return 'over_max';
|
|
385
|
+
if (lastSizeKb !== null && sizeHighKb !== null && lastSizeKb > sizeHighKb)
|
|
386
|
+
return 'over_high';
|
|
387
|
+
const lastActivity = stringValue(row.last_evaluated_at) ?? stringValue(row.last_purge_at);
|
|
388
|
+
if (lastActivity) {
|
|
389
|
+
const ageMs = nowMs - Date.parse(lastActivity);
|
|
390
|
+
if (Number.isFinite(ageMs) && ageMs > 48 * 60 * 60 * 1000)
|
|
391
|
+
return 'stale';
|
|
392
|
+
return 'healthy';
|
|
393
|
+
}
|
|
394
|
+
return 'unknown';
|
|
395
|
+
}
|
|
396
|
+
async function collectPurgeConfigs(context, relations) {
|
|
397
|
+
const columns = await tableColumns(context, 'purge_table_configs');
|
|
398
|
+
if (!columns || columns.size === 0)
|
|
399
|
+
return [];
|
|
400
|
+
const fields = [
|
|
401
|
+
optionalColumn(columns, 'p', 'org_id', ['org_id'], 'bigint'),
|
|
402
|
+
optionalColumn(columns, 'p', 'table_name', ['table_name'], 'text'),
|
|
403
|
+
optionalColumn(columns, 'p', 'module_name', ['module_name'], 'text'),
|
|
404
|
+
optionalColumn(columns, 'p', 'enabled', ['enabled'], 'boolean'),
|
|
405
|
+
optionalColumn(columns, 'p', 'retention_days_max', ['retention_days_max', 'retention_days_target'], 'integer'),
|
|
406
|
+
optionalColumn(columns, 'p', 'retention_days_minimum', ['retention_days_minimum'], 'integer'),
|
|
407
|
+
optionalColumn(columns, 'p', 'retention_days_current', ['retention_days_current'], 'integer'),
|
|
408
|
+
optionalColumn(columns, 'p', 'size_threshold_high_kb', ['size_threshold_high_kb'], 'bigint'),
|
|
409
|
+
optionalColumn(columns, 'p', 'size_threshold_low_kb', ['size_threshold_low_kb'], 'bigint'),
|
|
410
|
+
optionalColumn(columns, 'p', 'size_max_kb', ['size_max_kb'], 'bigint'),
|
|
411
|
+
optionalColumn(columns, 'p', 'last_size_kb', ['last_size_kb'], 'bigint'),
|
|
412
|
+
optionalColumn(columns, 'p', 'last_record_count', ['last_record_count'], 'bigint'),
|
|
413
|
+
optionalColumn(columns, 'p', 'last_purge_at', ['last_purge_at'], 'timestamp'),
|
|
414
|
+
optionalColumn(columns, 'p', 'last_evaluated_at', ['last_evaluated_at'], 'timestamp'),
|
|
415
|
+
optionalColumn(columns, 'p', 'purge_strategy', ['purge_strategy'], 'text'),
|
|
416
|
+
optionalColumn(columns, 'p', 'total_purges', ['total_purges'], 'bigint'),
|
|
417
|
+
optionalColumn(columns, 'p', 'total_records_purged', ['total_records_purged'], 'bigint'),
|
|
418
|
+
];
|
|
419
|
+
const rows = await queryJson(context, 'purge configuration status', `SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.table_name, x.org_id), '[]'::jsonb)
|
|
420
|
+
FROM (SELECT ${fields.join(', ')} FROM public.purge_table_configs p) x;`);
|
|
421
|
+
const relationSizes = relationMap(relations);
|
|
422
|
+
const now = Date.now();
|
|
423
|
+
return (rows ?? []).map((row) => {
|
|
424
|
+
const tableName = stringValue(row.table_name);
|
|
425
|
+
const relation = tableName ? relationSizes.get(`public.${tableName}`) : undefined;
|
|
426
|
+
return {
|
|
427
|
+
orgId: integerValue(row.org_id),
|
|
428
|
+
tableName,
|
|
429
|
+
moduleName: stringValue(row.module_name),
|
|
430
|
+
enabled: booleanValue(row.enabled),
|
|
431
|
+
status: configStatus(row, now),
|
|
432
|
+
retentionDaysMax: integerValue(row.retention_days_max),
|
|
433
|
+
retentionDaysMinimum: integerValue(row.retention_days_minimum),
|
|
434
|
+
retentionDaysCurrent: integerValue(row.retention_days_current),
|
|
435
|
+
sizeThresholdHighKb: integerValue(row.size_threshold_high_kb),
|
|
436
|
+
sizeThresholdLowKb: integerValue(row.size_threshold_low_kb),
|
|
437
|
+
sizeMaxKb: integerValue(row.size_max_kb),
|
|
438
|
+
lastSizeKb: integerValue(row.last_size_kb),
|
|
439
|
+
lastRecordCount: integerValue(row.last_record_count),
|
|
440
|
+
lastPurgeAt: stringValue(row.last_purge_at),
|
|
441
|
+
lastEvaluatedAt: stringValue(row.last_evaluated_at),
|
|
442
|
+
purgeStrategy: stringValue(row.purge_strategy),
|
|
443
|
+
totalPurges: integerValue(row.total_purges),
|
|
444
|
+
totalRecordsPurged: integerValue(row.total_records_purged),
|
|
445
|
+
relationBytes: relation?.totalBytes ?? null,
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
async function collectPurgeAuditStatus(context) {
|
|
450
|
+
const columns = await tableColumns(context, 'purge_audit_statuses');
|
|
451
|
+
if (!columns || columns.size === 0) {
|
|
452
|
+
return {
|
|
453
|
+
available: false,
|
|
454
|
+
rowCount: 0,
|
|
455
|
+
successRows: 0,
|
|
456
|
+
issueRows: 0,
|
|
457
|
+
issueCount: 0,
|
|
458
|
+
tablesAudited: 0,
|
|
459
|
+
tablesHealthy: 0,
|
|
460
|
+
totalDatabaseSizeKb: 0,
|
|
461
|
+
oldestAuditAt: null,
|
|
462
|
+
newestAuditAt: null,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
const expressions = [
|
|
466
|
+
`count(*)::bigint AS row_count`,
|
|
467
|
+
columns.has('last_status') ? `count(*) FILTER (WHERE p."last_status" = 'success')::bigint AS success_rows` : `0::bigint AS success_rows`,
|
|
468
|
+
columns.has('last_status') ? `count(*) FILTER (WHERE p."last_status" <> 'success')::bigint AS issue_rows` : `0::bigint AS issue_rows`,
|
|
469
|
+
columns.has('issue_count') ? `COALESCE(sum(p."issue_count"), 0)::bigint AS issue_count` : `0::bigint AS issue_count`,
|
|
470
|
+
columns.has('tables_audited') ? `COALESCE(sum(p."tables_audited"), 0)::bigint AS tables_audited` : `0::bigint AS tables_audited`,
|
|
471
|
+
columns.has('tables_healthy') ? `COALESCE(sum(p."tables_healthy"), 0)::bigint AS tables_healthy` : `0::bigint AS tables_healthy`,
|
|
472
|
+
// This value is a database-wide snapshot repeated on each per-org audit row.
|
|
473
|
+
// Summing it would multiply the apparent database size by the org count.
|
|
474
|
+
columns.has('total_db_size_kb') ? `COALESCE(max(p."total_db_size_kb"), 0)::bigint AS total_database_size_kb` : `0::bigint AS total_database_size_kb`,
|
|
475
|
+
columns.has('last_audit_at') ? `min(p."last_audit_at")::text AS oldest_audit_at` : `NULL::text AS oldest_audit_at`,
|
|
476
|
+
columns.has('last_audit_at') ? `max(p."last_audit_at")::text AS newest_audit_at` : `NULL::text AS newest_audit_at`,
|
|
477
|
+
];
|
|
478
|
+
const row = await queryJson(context, 'purge audit status', `SELECT ${expressions.join(', ')} FROM public.purge_audit_statuses p;`);
|
|
479
|
+
return {
|
|
480
|
+
available: row !== null,
|
|
481
|
+
rowCount: integerValue(row?.row_count) ?? 0,
|
|
482
|
+
successRows: integerValue(row?.success_rows) ?? 0,
|
|
483
|
+
issueRows: integerValue(row?.issue_rows) ?? 0,
|
|
484
|
+
issueCount: integerValue(row?.issue_count) ?? 0,
|
|
485
|
+
tablesAudited: integerValue(row?.tables_audited) ?? 0,
|
|
486
|
+
tablesHealthy: integerValue(row?.tables_healthy) ?? 0,
|
|
487
|
+
totalDatabaseSizeKb: integerValue(row?.total_database_size_kb) ?? 0,
|
|
488
|
+
oldestAuditAt: stringValue(row?.oldest_audit_at),
|
|
489
|
+
newestAuditAt: stringValue(row?.newest_audit_at),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function activityFilter(columns, alias) {
|
|
493
|
+
const predicates = [];
|
|
494
|
+
if (columns.has('action'))
|
|
495
|
+
predicates.push(`(${quoteIdentifier(alias)}."action" ILIKE '%purge%' OR ${quoteIdentifier(alias)}."action" ILIKE '%prune%')`);
|
|
496
|
+
if (columns.has('target_type'))
|
|
497
|
+
predicates.push(`(${quoteIdentifier(alias)}."target_type" ILIKE '%purge%' OR ${quoteIdentifier(alias)}."target_type" ILIKE '%prune%')`);
|
|
498
|
+
if (predicates.length === 0)
|
|
499
|
+
return null;
|
|
500
|
+
return `(${predicates.join(' OR ')})`;
|
|
501
|
+
}
|
|
502
|
+
async function collectPurgeHistory(context, limit) {
|
|
503
|
+
const columns = await tableColumns(context, 'user_activity_logs');
|
|
504
|
+
if (!columns || columns.size === 0)
|
|
505
|
+
return { summary: [], oldest: [] };
|
|
506
|
+
const filter = activityFilter(columns, 'l');
|
|
507
|
+
const timeColumn = ['inserted_at', 'created_at', 'updated_at'].find((column) => columns.has(column));
|
|
508
|
+
if (!filter || !timeColumn) {
|
|
509
|
+
context.warnings.push('purge history: required action/target and timestamp columns are unavailable');
|
|
510
|
+
return { summary: [], oldest: [] };
|
|
511
|
+
}
|
|
512
|
+
const payloadColumns = ['changes', 'metadata', 'error_message'].filter((column) => columns.has(column));
|
|
513
|
+
const payloadBytes = payloadColumns.length > 0
|
|
514
|
+
? payloadColumns.map((column) => `COALESCE(pg_column_size(l.${quoteIdentifier(column)}), 0)`).join(' + ')
|
|
515
|
+
: '0';
|
|
516
|
+
const summary = await queryJson(context, 'purge history summary', `SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.record_count DESC), '[]'::jsonb)
|
|
517
|
+
FROM (
|
|
518
|
+
SELECT ${columns.has('action') ? 'l."action"' : 'NULL::text'} AS action,
|
|
519
|
+
${columns.has('status') ? 'l."status"' : 'NULL::text'} AS status,
|
|
520
|
+
count(*)::bigint AS record_count,
|
|
521
|
+
min(l.${quoteIdentifier(timeColumn)})::text AS oldest_at,
|
|
522
|
+
max(l.${quoteIdentifier(timeColumn)})::text AS newest_at,
|
|
523
|
+
sum(${payloadBytes})::bigint AS payload_bytes
|
|
524
|
+
FROM public.user_activity_logs l
|
|
525
|
+
WHERE ${filter}
|
|
526
|
+
GROUP BY 1, 2
|
|
527
|
+
) x;`);
|
|
528
|
+
const oldest = await queryJson(context, 'oldest purge history records', `SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.inserted_at ASC NULLS LAST), '[]'::jsonb)
|
|
529
|
+
FROM (
|
|
530
|
+
SELECT ${columns.has('id') ? 'l."id"::text' : 'NULL::text'} AS id,
|
|
531
|
+
${columns.has('action') ? 'l."action"' : 'NULL::text'} AS action,
|
|
532
|
+
${columns.has('status') ? 'l."status"' : 'NULL::text'} AS status,
|
|
533
|
+
l.${quoteIdentifier(timeColumn)}::text AS inserted_at,
|
|
534
|
+
COALESCE(pg_column_size(l), 0)::bigint AS record_bytes
|
|
535
|
+
FROM public.user_activity_logs l
|
|
536
|
+
WHERE ${filter}
|
|
537
|
+
ORDER BY l.${quoteIdentifier(timeColumn)} ASC
|
|
538
|
+
LIMIT ${limit}
|
|
539
|
+
) x;`);
|
|
540
|
+
return {
|
|
541
|
+
summary: (summary ?? []).map((row) => ({
|
|
542
|
+
action: stringValue(row.action),
|
|
543
|
+
status: stringValue(row.status),
|
|
544
|
+
recordCount: integerValue(row.record_count) ?? 0,
|
|
545
|
+
oldestAt: stringValue(row.oldest_at),
|
|
546
|
+
newestAt: stringValue(row.newest_at),
|
|
547
|
+
payloadBytes: integerValue(row.payload_bytes) ?? 0,
|
|
548
|
+
})),
|
|
549
|
+
oldest: (oldest ?? []).map((row) => ({
|
|
550
|
+
id: stringValue(row.id),
|
|
551
|
+
action: stringValue(row.action),
|
|
552
|
+
status: stringValue(row.status),
|
|
553
|
+
insertedAt: stringValue(row.inserted_at),
|
|
554
|
+
recordBytes: integerValue(row.record_bytes) ?? 0,
|
|
555
|
+
})),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
async function collectFailureStatus(context, limit) {
|
|
559
|
+
const columns = await tableColumns(context, 'purge_table_failure_tracker');
|
|
560
|
+
const unavailable = {
|
|
561
|
+
available: false,
|
|
562
|
+
rowCount: 0,
|
|
563
|
+
failingRows: 0,
|
|
564
|
+
totalConsecutiveFailures: 0,
|
|
565
|
+
maxConsecutiveFailures: 0,
|
|
566
|
+
oldestFailureAt: null,
|
|
567
|
+
newestFailureAt: null,
|
|
568
|
+
oldestFailures: [],
|
|
569
|
+
};
|
|
570
|
+
if (!columns || columns.size === 0)
|
|
571
|
+
return unavailable;
|
|
572
|
+
const required = ['org_id', 'table_name', 'consecutive_failures', 'last_failure_at', 'last_success_at', 'inserted_at'];
|
|
573
|
+
if (!required.every((column) => columns.has(column))) {
|
|
574
|
+
context.warnings.push('purge failure tracker: required status columns are unavailable');
|
|
575
|
+
return unavailable;
|
|
576
|
+
}
|
|
577
|
+
const summary = await queryJson(context, 'purge failure tracker status', `SELECT count(*)::bigint AS row_count,
|
|
578
|
+
count(*) FILTER (WHERE f."consecutive_failures" > 0)::bigint AS failing_rows,
|
|
579
|
+
COALESCE(sum(f."consecutive_failures"), 0)::bigint AS total_consecutive_failures,
|
|
580
|
+
COALESCE(max(f."consecutive_failures"), 0)::bigint AS max_consecutive_failures,
|
|
581
|
+
min(f."last_failure_at")::text AS oldest_failure_at,
|
|
582
|
+
max(f."last_failure_at")::text AS newest_failure_at
|
|
583
|
+
FROM public.purge_table_failure_tracker f;`);
|
|
584
|
+
const oldest = await queryJson(context, 'oldest purge failure records', `SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.last_failure_at ASC NULLS LAST), '[]'::jsonb)
|
|
585
|
+
FROM (
|
|
586
|
+
SELECT f."org_id"::bigint AS org_id,
|
|
587
|
+
f."table_name" AS table_name,
|
|
588
|
+
f."consecutive_failures"::bigint AS consecutive_failures,
|
|
589
|
+
f."last_failure_at"::text AS last_failure_at,
|
|
590
|
+
f."last_success_at"::text AS last_success_at,
|
|
591
|
+
COALESCE(pg_column_size(f), 0)::bigint AS record_bytes
|
|
592
|
+
FROM public.purge_table_failure_tracker f
|
|
593
|
+
WHERE f."consecutive_failures" > 0
|
|
594
|
+
ORDER BY f."last_failure_at" ASC NULLS LAST
|
|
595
|
+
LIMIT ${limit}
|
|
596
|
+
) x;`);
|
|
597
|
+
return {
|
|
598
|
+
available: summary !== null,
|
|
599
|
+
rowCount: integerValue(summary?.row_count) ?? 0,
|
|
600
|
+
failingRows: integerValue(summary?.failing_rows) ?? 0,
|
|
601
|
+
totalConsecutiveFailures: integerValue(summary?.total_consecutive_failures) ?? 0,
|
|
602
|
+
maxConsecutiveFailures: integerValue(summary?.max_consecutive_failures) ?? 0,
|
|
603
|
+
oldestFailureAt: stringValue(summary?.oldest_failure_at),
|
|
604
|
+
newestFailureAt: stringValue(summary?.newest_failure_at),
|
|
605
|
+
oldestFailures: (oldest ?? []).map((row) => ({
|
|
606
|
+
orgId: integerValue(row.org_id),
|
|
607
|
+
tableName: stringValue(row.table_name),
|
|
608
|
+
consecutiveFailures: integerValue(row.consecutive_failures) ?? 0,
|
|
609
|
+
lastFailureAt: stringValue(row.last_failure_at),
|
|
610
|
+
lastSuccessAt: stringValue(row.last_success_at),
|
|
611
|
+
recordBytes: integerValue(row.record_bytes) ?? 0,
|
|
612
|
+
})),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
async function collectDispatchStatus(context) {
|
|
616
|
+
const columns = await tableColumns(context, 'purge_dispatch_cursors');
|
|
617
|
+
const unavailable = {
|
|
618
|
+
available: false,
|
|
619
|
+
rowCount: 0,
|
|
620
|
+
coveredCount: 0,
|
|
621
|
+
totalTables: 0,
|
|
622
|
+
minNextOffset: null,
|
|
623
|
+
maxNextOffset: null,
|
|
624
|
+
oldestUpdatedAt: null,
|
|
625
|
+
newestUpdatedAt: null,
|
|
626
|
+
};
|
|
627
|
+
if (!columns || columns.size === 0)
|
|
628
|
+
return unavailable;
|
|
629
|
+
const summary = await queryJson(context, 'purge dispatch cursor status', `SELECT count(*)::bigint AS row_count,
|
|
630
|
+
COALESCE(sum(${columns.has('last_covered_count') ? 'c."last_covered_count"' : '0'}), 0)::bigint AS covered_count,
|
|
631
|
+
COALESCE(sum(${columns.has('last_total_tables') ? 'c."last_total_tables"' : '0'}), 0)::bigint AS total_tables,
|
|
632
|
+
${columns.has('next_offset') ? 'min(c."next_offset")::bigint' : 'NULL::bigint'} AS min_next_offset,
|
|
633
|
+
${columns.has('next_offset') ? 'max(c."next_offset")::bigint' : 'NULL::bigint'} AS max_next_offset,
|
|
634
|
+
${columns.has('updated_at') ? 'min(c."updated_at")::text' : 'NULL::text'} AS oldest_updated_at,
|
|
635
|
+
${columns.has('updated_at') ? 'max(c."updated_at")::text' : 'NULL::text'} AS newest_updated_at
|
|
636
|
+
FROM public.purge_dispatch_cursors c;`);
|
|
637
|
+
return {
|
|
638
|
+
available: summary !== null,
|
|
639
|
+
rowCount: integerValue(summary?.row_count) ?? 0,
|
|
640
|
+
coveredCount: integerValue(summary?.covered_count) ?? 0,
|
|
641
|
+
totalTables: integerValue(summary?.total_tables) ?? 0,
|
|
642
|
+
minNextOffset: integerValue(summary?.min_next_offset),
|
|
643
|
+
maxNextOffset: integerValue(summary?.max_next_offset),
|
|
644
|
+
oldestUpdatedAt: stringValue(summary?.oldest_updated_at),
|
|
645
|
+
newestUpdatedAt: stringValue(summary?.newest_updated_at),
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
async function collectOban(context, limit) {
|
|
649
|
+
const columns = await tableColumns(context, 'oban_jobs');
|
|
650
|
+
if (!columns || columns.size === 0)
|
|
651
|
+
return { summaries: [], oldest: [] };
|
|
652
|
+
const filters = [];
|
|
653
|
+
if (columns.has('queue'))
|
|
654
|
+
filters.push(`j."queue" ILIKE 'db_purge%'`);
|
|
655
|
+
if (columns.has('queue'))
|
|
656
|
+
filters.push(`j."queue" ILIKE '%partition%'`);
|
|
657
|
+
if (columns.has('worker'))
|
|
658
|
+
filters.push(`j."worker" ILIKE '%Purge%'`);
|
|
659
|
+
if (columns.has('worker'))
|
|
660
|
+
filters.push(`j."worker" ILIKE '%Prune%'`);
|
|
661
|
+
if (columns.has('worker'))
|
|
662
|
+
filters.push(`j."worker" ILIKE '%PartitionMaintenance%'`);
|
|
663
|
+
const timeColumns = ['attempted_at', 'scheduled_at', 'inserted_at', 'completed_at'].filter((column) => columns.has(column));
|
|
664
|
+
if (filters.length === 0 || timeColumns.length === 0) {
|
|
665
|
+
context.warnings.push('Oban purge status: required queue/worker and timestamp columns are unavailable');
|
|
666
|
+
return { summaries: [], oldest: [] };
|
|
667
|
+
}
|
|
668
|
+
const eventTime = `COALESCE(${timeColumns.map((column) => `j.${quoteIdentifier(column)}`).join(', ')})`;
|
|
669
|
+
const payloadColumns = ['args', 'errors'].filter((column) => columns.has(column));
|
|
670
|
+
const payloadBytes = payloadColumns.length > 0
|
|
671
|
+
? payloadColumns.map((column) => `COALESCE(pg_column_size(j.${quoteIdentifier(column)}), 0)`).join(' + ')
|
|
672
|
+
: '0';
|
|
673
|
+
const filter = `(${filters.join(' OR ')})`;
|
|
674
|
+
const summaries = await queryJson(context, 'Oban purge queue status', `SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.job_count DESC), '[]'::jsonb)
|
|
675
|
+
FROM (
|
|
676
|
+
SELECT ${columns.has('queue') ? 'j."queue"' : 'NULL::text'} AS queue,
|
|
677
|
+
${columns.has('state') ? 'j."state"' : 'NULL::text'} AS state,
|
|
678
|
+
count(*)::bigint AS job_count,
|
|
679
|
+
min(${eventTime})::text AS oldest_at,
|
|
680
|
+
max(${eventTime})::text AS newest_at,
|
|
681
|
+
sum((${payloadBytes})::bigint)::bigint AS payload_bytes
|
|
682
|
+
FROM public.oban_jobs j
|
|
683
|
+
WHERE ${filter}
|
|
684
|
+
GROUP BY 1, 2
|
|
685
|
+
) x;`);
|
|
686
|
+
const oldest = await queryJson(context, 'oldest Oban purge jobs', `SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.event_at ASC NULLS LAST), '[]'::jsonb)
|
|
687
|
+
FROM (
|
|
688
|
+
SELECT ${columns.has('id') ? 'j."id"::bigint' : 'NULL::bigint'} AS id,
|
|
689
|
+
${columns.has('queue') ? 'j."queue"' : 'NULL::text'} AS queue,
|
|
690
|
+
${columns.has('state') ? 'j."state"' : 'NULL::text'} AS state,
|
|
691
|
+
${columns.has('worker') ? 'j."worker"' : 'NULL::text'} AS worker,
|
|
692
|
+
${columns.has('attempt') ? 'j."attempt"::integer' : 'NULL::integer'} AS attempts,
|
|
693
|
+
${columns.has('max_attempts') ? 'j."max_attempts"::integer' : 'NULL::integer'} AS max_attempts,
|
|
694
|
+
${eventTime}::text AS event_at,
|
|
695
|
+
COALESCE(pg_column_size(j), 0)::bigint AS record_bytes
|
|
696
|
+
FROM public.oban_jobs j
|
|
697
|
+
WHERE ${filter}
|
|
698
|
+
ORDER BY ${eventTime} ASC
|
|
699
|
+
LIMIT ${limit}
|
|
700
|
+
) x;`);
|
|
701
|
+
return {
|
|
702
|
+
summaries: (summaries ?? []).map((row) => ({
|
|
703
|
+
queue: stringValue(row.queue),
|
|
704
|
+
state: stringValue(row.state),
|
|
705
|
+
jobCount: integerValue(row.job_count) ?? 0,
|
|
706
|
+
oldestAt: stringValue(row.oldest_at),
|
|
707
|
+
newestAt: stringValue(row.newest_at),
|
|
708
|
+
payloadBytes: integerValue(row.payload_bytes) ?? 0,
|
|
709
|
+
})),
|
|
710
|
+
oldest: (oldest ?? []).map((row) => ({
|
|
711
|
+
id: integerValue(row.id),
|
|
712
|
+
queue: stringValue(row.queue),
|
|
713
|
+
state: stringValue(row.state),
|
|
714
|
+
worker: stringValue(row.worker),
|
|
715
|
+
attempts: integerValue(row.attempts),
|
|
716
|
+
maxAttempts: integerValue(row.max_attempts),
|
|
717
|
+
eventAt: stringValue(row.event_at),
|
|
718
|
+
recordBytes: integerValue(row.record_bytes) ?? 0,
|
|
719
|
+
})),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
async function collectPartitions(context) {
|
|
723
|
+
const rows = await queryJson(context, 'partition size summary', `SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.total_bytes DESC), '[]'::jsonb)
|
|
724
|
+
FROM (
|
|
725
|
+
SELECT pn.nspname AS schema_name,
|
|
726
|
+
parent.relname AS parent_table_name,
|
|
727
|
+
count(*)::bigint AS partition_count,
|
|
728
|
+
min(child.relname) AS oldest_partition,
|
|
729
|
+
max(child.relname) AS newest_partition,
|
|
730
|
+
sum(pg_total_relation_size(child.oid))::bigint AS total_bytes
|
|
731
|
+
FROM pg_inherits i
|
|
732
|
+
JOIN pg_class parent ON parent.oid = i.inhparent
|
|
733
|
+
JOIN pg_class child ON child.oid = i.inhrelid
|
|
734
|
+
JOIN pg_namespace pn ON pn.oid = parent.relnamespace
|
|
735
|
+
WHERE pn.nspname NOT LIKE 'pg_%'
|
|
736
|
+
AND pn.nspname <> 'information_schema'
|
|
737
|
+
GROUP BY pn.nspname, parent.relname
|
|
738
|
+
) x;`);
|
|
739
|
+
return (rows ?? []).map((row) => ({
|
|
740
|
+
schemaName: String(row.schema_name ?? ''),
|
|
741
|
+
parentTableName: String(row.parent_table_name ?? ''),
|
|
742
|
+
partitionCount: integerValue(row.partition_count) ?? 0,
|
|
743
|
+
oldestPartition: stringValue(row.oldest_partition),
|
|
744
|
+
newestPartition: stringValue(row.newest_partition),
|
|
745
|
+
totalBytes: integerValue(row.total_bytes) ?? 0,
|
|
746
|
+
}));
|
|
747
|
+
}
|
|
748
|
+
async function collectDatabase(options, warnings) {
|
|
749
|
+
const statementTimeoutMs = Math.max(4_000, Math.min(options.timeoutMs - 1_000, 60_000));
|
|
750
|
+
const context = {
|
|
751
|
+
options: {
|
|
752
|
+
timeoutMs: options.timeoutMs,
|
|
753
|
+
statementTimeoutMs,
|
|
754
|
+
lockTimeoutMs: 2_000,
|
|
755
|
+
connectTimeoutS: 5,
|
|
756
|
+
tuplesOnly: true,
|
|
757
|
+
},
|
|
758
|
+
warnings,
|
|
759
|
+
};
|
|
760
|
+
const totalRaw = await queryText(context, 'database total size', 'SELECT pg_database_size(current_database())::bigint;');
|
|
761
|
+
const totalBytes = integerValue(totalRaw);
|
|
762
|
+
const relationRows = await queryJson(context, 'relation size inventory', RELATION_QUERY);
|
|
763
|
+
const relations = (relationRows ?? []).map((row) => ({
|
|
764
|
+
schemaName: String(row.schema_name ?? ''),
|
|
765
|
+
tableName: String(row.table_name ?? ''),
|
|
766
|
+
relationKind: String(row.relation_kind ?? ''),
|
|
767
|
+
isPartition: Boolean(row.is_partition),
|
|
768
|
+
totalBytes: integerValue(row.total_bytes) ?? 0,
|
|
769
|
+
tableBytes: integerValue(row.table_bytes) ?? 0,
|
|
770
|
+
heapBytes: integerValue(row.heap_bytes) ?? 0,
|
|
771
|
+
indexBytes: integerValue(row.index_bytes) ?? 0,
|
|
772
|
+
toastBytes: integerValue(row.toast_bytes) ?? 0,
|
|
773
|
+
estimatedLiveRows: integerValue(row.estimated_live_rows) ?? 0,
|
|
774
|
+
estimatedDeadRows: integerValue(row.estimated_dead_rows) ?? 0,
|
|
775
|
+
lastVacuum: stringValue(row.last_vacuum),
|
|
776
|
+
lastAutovacuum: stringValue(row.last_autovacuum),
|
|
777
|
+
lastAnalyze: stringValue(row.last_analyze),
|
|
778
|
+
lastAutoanalyze: stringValue(row.last_autoanalyze),
|
|
779
|
+
}));
|
|
780
|
+
const oldestTables = await collectOldestTables(context, relations, options.top);
|
|
781
|
+
const nonSystemRelationBytes = relations.reduce((sum, relation) => sum + relation.totalBytes, 0);
|
|
782
|
+
return {
|
|
783
|
+
name: DB_NAME,
|
|
784
|
+
totalBytes,
|
|
785
|
+
nonSystemRelationBytes: relationRows ? nonSystemRelationBytes : null,
|
|
786
|
+
relationCount: relationRows ? relations.length : null,
|
|
787
|
+
relations,
|
|
788
|
+
oldestTables,
|
|
789
|
+
warnings: [...context.warnings],
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
async function collectPurge(options, relations, warnings) {
|
|
793
|
+
const statementTimeoutMs = Math.max(4_000, Math.min(options.timeoutMs - 1_000, 60_000));
|
|
794
|
+
const context = {
|
|
795
|
+
options: {
|
|
796
|
+
timeoutMs: options.timeoutMs,
|
|
797
|
+
statementTimeoutMs,
|
|
798
|
+
lockTimeoutMs: 2_000,
|
|
799
|
+
connectTimeoutS: 5,
|
|
800
|
+
tuplesOnly: true,
|
|
801
|
+
},
|
|
802
|
+
warnings,
|
|
803
|
+
};
|
|
804
|
+
const configs = await collectPurgeConfigs(context, relations);
|
|
805
|
+
const auditStatus = await collectPurgeAuditStatus(context);
|
|
806
|
+
const history = await collectPurgeHistory(context, options.top);
|
|
807
|
+
const failureStatus = await collectFailureStatus(context, options.top);
|
|
808
|
+
const dispatchStatus = await collectDispatchStatus(context);
|
|
809
|
+
const oban = await collectOban(context, options.top);
|
|
810
|
+
const partitions = await collectPartitions(context);
|
|
811
|
+
return {
|
|
812
|
+
configs,
|
|
813
|
+
auditStatus,
|
|
814
|
+
historySummary: history.summary,
|
|
815
|
+
oldestHistory: history.oldest,
|
|
816
|
+
failureStatus,
|
|
817
|
+
dispatchStatus,
|
|
818
|
+
obanQueues: oban.summaries,
|
|
819
|
+
oldestObanJobs: oban.oldest,
|
|
820
|
+
partitions,
|
|
821
|
+
warnings: [...context.warnings],
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
export async function buildStorageReport(options = {}) {
|
|
825
|
+
const normalized = normalizeStorageReportOptions(options);
|
|
826
|
+
const paths = getPaths();
|
|
827
|
+
const scanWarnings = [];
|
|
828
|
+
const filesystemWarnings = [];
|
|
829
|
+
const databaseWarnings = [];
|
|
830
|
+
const purgeWarnings = [];
|
|
831
|
+
const filesystems = await collectFilesystems(normalized, filesystemWarnings);
|
|
832
|
+
const roots = scanRoots(filesystems);
|
|
833
|
+
const topDirectories = await collectTopDirectories(normalized, roots, scanWarnings);
|
|
834
|
+
const topFiles = await collectTopFiles(normalized, roots, scanWarnings);
|
|
835
|
+
const database = await collectDatabase(normalized, databaseWarnings);
|
|
836
|
+
const purge = await collectPurge(normalized, database.relations, purgeWarnings);
|
|
837
|
+
const warnings = [
|
|
838
|
+
...filesystemWarnings,
|
|
839
|
+
...scanWarnings,
|
|
840
|
+
...databaseWarnings,
|
|
841
|
+
...purgeWarnings,
|
|
842
|
+
];
|
|
843
|
+
return {
|
|
844
|
+
schemaVersion: STORAGE_REPORT_SCHEMA_VERSION,
|
|
845
|
+
generatedAt: new Date().toISOString(),
|
|
846
|
+
hostname: os.hostname(),
|
|
847
|
+
installDir: paths.installDir,
|
|
848
|
+
scan: {
|
|
849
|
+
root: '/',
|
|
850
|
+
roots,
|
|
851
|
+
filesystemOnly: true,
|
|
852
|
+
topLimit: normalized.top,
|
|
853
|
+
maxDepth: normalized.scanDepth,
|
|
854
|
+
warnings: scanWarnings,
|
|
855
|
+
},
|
|
856
|
+
filesystems,
|
|
857
|
+
topDirectories,
|
|
858
|
+
topFiles,
|
|
859
|
+
database,
|
|
860
|
+
purge,
|
|
861
|
+
warnings,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
function line(label, value) {
|
|
865
|
+
return ` ${label.padEnd(26)} ${value}`;
|
|
866
|
+
}
|
|
867
|
+
function optionalDate(value) {
|
|
868
|
+
return value ?? 'unavailable';
|
|
869
|
+
}
|
|
870
|
+
export function renderStorageReportText(report) {
|
|
871
|
+
const lines = [];
|
|
872
|
+
lines.push('CallTelemetry storage and database report');
|
|
873
|
+
lines.push(`Generated: ${report.generatedAt} Host: ${report.hostname}`);
|
|
874
|
+
lines.push(`Install: ${report.installDir}`);
|
|
875
|
+
lines.push('Read-only report: no rows deleted, purged, vacuumed, or changed.');
|
|
876
|
+
lines.push('');
|
|
877
|
+
lines.push('Filesystem utilization');
|
|
878
|
+
if (report.filesystems.length === 0)
|
|
879
|
+
lines.push(' unavailable');
|
|
880
|
+
for (const fs of report.filesystems) {
|
|
881
|
+
lines.push(line(fs.path, `${fs.usedPercent}% used, ${pretty(fs.usedBytes)} / ${pretty(fs.sizeBytes)}, ${pretty(fs.availableBytes)} free`));
|
|
882
|
+
lines.push(line('filesystem', `${fs.filesystem} mounted at ${fs.mount}; inodes ${fs.inodeUsedPercent === null ? 'unavailable' : `${fs.inodeUsedPercent}% used`}`));
|
|
883
|
+
}
|
|
884
|
+
lines.push('');
|
|
885
|
+
lines.push(`Top folders (top ${report.scan.topLimit}, same filesystem, depth ${report.scan.maxDepth})`);
|
|
886
|
+
if (report.topDirectories.length === 0)
|
|
887
|
+
lines.push(' unavailable');
|
|
888
|
+
for (const entry of report.topDirectories)
|
|
889
|
+
lines.push(line(pretty(entry.bytes), entry.path));
|
|
890
|
+
lines.push('');
|
|
891
|
+
lines.push(`Top files (top ${report.scan.topLimit}, same filesystem)`);
|
|
892
|
+
if (report.topFiles.length === 0)
|
|
893
|
+
lines.push(' unavailable');
|
|
894
|
+
for (const entry of report.topFiles)
|
|
895
|
+
lines.push(line(pretty(entry.bytes), entry.path));
|
|
896
|
+
lines.push('');
|
|
897
|
+
lines.push('Database relation utilization');
|
|
898
|
+
lines.push(line('database total', report.database.totalBytes === null ? 'unavailable' : `${pretty(report.database.totalBytes)} (${report.database.totalBytes} bytes)`));
|
|
899
|
+
lines.push(line('non-system relations', report.database.nonSystemRelationBytes === null ? 'unavailable' : `${pretty(report.database.nonSystemRelationBytes)} (${report.database.nonSystemRelationBytes} bytes)`));
|
|
900
|
+
lines.push(line('relations', report.database.relationCount === null ? 'unavailable' : String(report.database.relationCount)));
|
|
901
|
+
for (const relation of report.database.relations.slice(0, report.scan.topLimit)) {
|
|
902
|
+
const name = `${relation.schemaName}.${relation.tableName}${relation.isPartition ? ' [partition]' : ''}`;
|
|
903
|
+
lines.push(line(name, `total ${pretty(relation.totalBytes)}; heap ${pretty(relation.heapBytes)}; indexes ${pretty(relation.indexBytes)}; toast ${pretty(relation.toastBytes)}; live/dead ${relation.estimatedLiveRows}/${relation.estimatedDeadRows}`));
|
|
904
|
+
}
|
|
905
|
+
lines.push('');
|
|
906
|
+
lines.push('Oldest timestamp-bearing tables');
|
|
907
|
+
if (report.database.oldestTables.length === 0)
|
|
908
|
+
lines.push(' unavailable');
|
|
909
|
+
for (const row of report.database.oldestTables) {
|
|
910
|
+
lines.push(line(`${row.schemaName}.${row.tableName}.${row.columnName}`, `oldest ${optionalDate(row.oldestAt)}; newest ${optionalDate(row.newestAt)}; relation ${pretty(row.relationBytes)}; est rows ${row.estimatedRows}`));
|
|
911
|
+
}
|
|
912
|
+
lines.push('');
|
|
913
|
+
lines.push('Purge / prune status');
|
|
914
|
+
lines.push(line('configured rows', String(report.purge.configs.length)));
|
|
915
|
+
for (const config of report.purge.configs.slice(0, report.scan.topLimit)) {
|
|
916
|
+
const name = `${config.tableName ?? 'unknown'}${config.orgId === null ? '' : ` (org ${config.orgId})`}`;
|
|
917
|
+
const size = config.lastSizeKb === null ? 'size unavailable' : `last ${pretty(config.lastSizeKb * 1024)}`;
|
|
918
|
+
lines.push(line(name, `${config.status}; ${size}; last purge ${optionalDate(config.lastPurgeAt)}; last evaluation ${optionalDate(config.lastEvaluatedAt)}`));
|
|
919
|
+
}
|
|
920
|
+
const audit = report.purge.auditStatus;
|
|
921
|
+
lines.push(line('audit status', audit.available ? `${audit.successRows} success, ${audit.issueRows} issues, ${audit.issueCount} issue(s); newest ${optionalDate(audit.newestAuditAt)}` : 'unavailable'));
|
|
922
|
+
lines.push(line('audit DB size', `${pretty(audit.totalDatabaseSizeKb * 1024)}; tables audited/healthy ${audit.tablesAudited}/${audit.tablesHealthy}`));
|
|
923
|
+
lines.push('Purge / prune history by action/status');
|
|
924
|
+
if (report.purge.historySummary.length === 0)
|
|
925
|
+
lines.push(' unavailable or no purge/prune audit records');
|
|
926
|
+
for (const row of report.purge.historySummary) {
|
|
927
|
+
lines.push(line(`${row.action ?? 'unknown'} / ${row.status ?? 'unknown'}`, `${row.recordCount} records; ${pretty(row.payloadBytes)} payload; oldest ${optionalDate(row.oldestAt)}; newest ${optionalDate(row.newestAt)}`));
|
|
928
|
+
}
|
|
929
|
+
lines.push('Oldest purge history records');
|
|
930
|
+
if (report.purge.oldestHistory.length === 0)
|
|
931
|
+
lines.push(' unavailable or none');
|
|
932
|
+
for (const row of report.purge.oldestHistory) {
|
|
933
|
+
lines.push(line(row.insertedAt ?? 'unknown', `${row.action ?? 'unknown'} / ${row.status ?? 'unknown'}; ${pretty(row.recordBytes)} record; id ${row.id ?? 'unavailable'}`));
|
|
934
|
+
}
|
|
935
|
+
const failures = report.purge.failureStatus;
|
|
936
|
+
lines.push('Purge failure tracker');
|
|
937
|
+
lines.push(line('failure status', failures.available ? `${failures.failingRows}/${failures.rowCount} rows failing; total consecutive failures ${failures.totalConsecutiveFailures}; max ${failures.maxConsecutiveFailures}` : 'unavailable'));
|
|
938
|
+
lines.push(line('failure window', `oldest ${optionalDate(failures.oldestFailureAt)}; newest ${optionalDate(failures.newestFailureAt)}`));
|
|
939
|
+
for (const row of failures.oldestFailures) {
|
|
940
|
+
lines.push(line(row.lastFailureAt ?? 'unknown', `${row.tableName ?? 'unknown'} (org ${row.orgId ?? 'unknown'}); consecutive ${row.consecutiveFailures}; ${pretty(row.recordBytes)} record`));
|
|
941
|
+
}
|
|
942
|
+
const dispatch = report.purge.dispatchStatus;
|
|
943
|
+
lines.push('Purge dispatch cursors');
|
|
944
|
+
lines.push(line('dispatch status', dispatch.available ? `${dispatch.rowCount} rows; covered ${dispatch.coveredCount}; total tables ${dispatch.totalTables}; offset ${dispatch.minNextOffset ?? 'unknown'}..${dispatch.maxNextOffset ?? 'unknown'}` : 'unavailable'));
|
|
945
|
+
lines.push(line('dispatch window', `oldest update ${optionalDate(dispatch.oldestUpdatedAt)}; newest ${optionalDate(dispatch.newestUpdatedAt)}`));
|
|
946
|
+
lines.push('Oban purge/prune queues');
|
|
947
|
+
if (report.purge.obanQueues.length === 0)
|
|
948
|
+
lines.push(' unavailable or no matching jobs');
|
|
949
|
+
for (const row of report.purge.obanQueues) {
|
|
950
|
+
lines.push(line(`${row.queue ?? 'unknown'} / ${row.state ?? 'unknown'}`, `${row.jobCount} jobs; ${pretty(row.payloadBytes)} payload; oldest ${optionalDate(row.oldestAt)}; newest ${optionalDate(row.newestAt)}`));
|
|
951
|
+
}
|
|
952
|
+
lines.push('Oldest matching Oban jobs');
|
|
953
|
+
for (const row of report.purge.oldestObanJobs) {
|
|
954
|
+
lines.push(line(row.eventAt ?? 'unknown', `${row.queue ?? 'unknown'} / ${row.state ?? 'unknown'} / ${row.worker ?? 'unknown'}; ${pretty(row.recordBytes)} record; id ${row.id ?? 'unavailable'}`));
|
|
955
|
+
}
|
|
956
|
+
lines.push('Partition utilization');
|
|
957
|
+
if (report.purge.partitions.length === 0)
|
|
958
|
+
lines.push(' no partitioned tables found or unavailable');
|
|
959
|
+
for (const row of report.purge.partitions) {
|
|
960
|
+
lines.push(line(`${row.schemaName}.${row.parentTableName}`, `${row.partitionCount} partitions; ${pretty(row.totalBytes)}; ${row.oldestPartition ?? 'unknown'} .. ${row.newestPartition ?? 'unknown'}`));
|
|
961
|
+
}
|
|
962
|
+
if (report.warnings.length > 0) {
|
|
963
|
+
lines.push('');
|
|
964
|
+
lines.push('Warnings');
|
|
965
|
+
for (const warning of [...new Set(report.warnings)])
|
|
966
|
+
lines.push(` - ${warning}`);
|
|
967
|
+
}
|
|
968
|
+
return `${lines.join('\n')}\n`;
|
|
969
|
+
}
|
|
970
|
+
export async function writeStorageReportJson(path, report) {
|
|
971
|
+
await mkdir(dirname(path), { recursive: true });
|
|
972
|
+
await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
973
|
+
}
|
|
974
|
+
export async function runStorageReportCommand(options = {}) {
|
|
975
|
+
const report = await buildStorageReport(options);
|
|
976
|
+
if (options.jsonPath) {
|
|
977
|
+
await writeStorageReportJson(options.jsonPath, report);
|
|
978
|
+
console.log(`Storage report written: ${options.jsonPath}`);
|
|
979
|
+
if (report.warnings.length > 0)
|
|
980
|
+
console.log(`Warnings: ${new Set(report.warnings).size}`);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
console.log(renderStorageReportText(report));
|
|
984
|
+
}
|
|
985
|
+
export const PURGE_REPORT_TABLES = PURGE_TABLE_NAMES;
|
|
986
|
+
//# sourceMappingURL=storage-report.js.map
|