@indiekitai/pg-toolkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # @indiekitai/pg-toolkit
2
+
3
+ Unified CLI for all IndieKit PostgreSQL tools. One command, all PG tools.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @indiekitai/pg-toolkit
9
+ # or
10
+ npx @indiekitai/pg-toolkit <command>
11
+ ```
12
+
13
+ ## Commands
14
+
15
+ ### `inspect` — Schema inspection
16
+
17
+ ```bash
18
+ # Full schema dump
19
+ pg-toolkit inspect postgresql://localhost/mydb
20
+
21
+ # Filter to specific objects
22
+ pg-toolkit inspect --tables postgresql://localhost/mydb
23
+ pg-toolkit inspect --views --functions postgresql://localhost/mydb
24
+ pg-toolkit inspect --enums --types postgresql://localhost/mydb
25
+
26
+ # Summary counts
27
+ pg-toolkit inspect --summary postgresql://localhost/mydb
28
+ ```
29
+
30
+ Filters: `--tables`, `--views`, `--functions`, `--indexes`, `--sequences`, `--enums`, `--extensions`, `--triggers`, `--constraints`, `--schemas`, `--privileges`, `--types`, `--domains`, `--collations`, `--rls`
31
+
32
+ ### `diff` — Schema diff & migration SQL
33
+
34
+ ```bash
35
+ # Generate migration SQL
36
+ pg-toolkit diff postgresql://localhost/old postgresql://localhost/new
37
+
38
+ # Safe mode (no DROP statements)
39
+ pg-toolkit diff --safe postgresql://localhost/old postgresql://localhost/new
40
+
41
+ # JSON output
42
+ pg-toolkit diff --json postgresql://localhost/old postgresql://localhost/new
43
+
44
+ # Pipe directly to psql
45
+ pg-toolkit diff postgres://localhost/old postgres://localhost/new | psql postgres://localhost/old
46
+ ```
47
+
48
+ ### `top` — Activity monitor
49
+
50
+ ```bash
51
+ # Interactive TUI
52
+ pg-toolkit top postgresql://localhost/mydb
53
+
54
+ # Custom refresh rate, hide idle
55
+ pg-toolkit top --refresh 1 --no-idle postgresql://localhost/mydb
56
+
57
+ # Single snapshot as JSON
58
+ pg-toolkit top --snapshot --json postgresql://localhost/mydb
59
+ ```
60
+
61
+ ### `health` — Health checks
62
+
63
+ ```bash
64
+ pg-toolkit health postgresql://localhost/mydb
65
+ ```
66
+
67
+ Delegates to `pg-health` (must be installed separately via `pip install pg-health`).
68
+
69
+ ### `types` — TypeScript type generation
70
+
71
+ ```bash
72
+ pg-toolkit types postgresql://localhost/mydb
73
+ ```
74
+
75
+ Delegates to `pg2ts` (must be installed separately).
76
+
77
+ ### `mcp` — Unified MCP server
78
+
79
+ Start a single MCP server that combines all PG tools:
80
+
81
+ ```bash
82
+ pg-toolkit mcp
83
+ ```
84
+
85
+ MCP config for AI agents:
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "pg-toolkit": {
91
+ "command": "npx",
92
+ "args": ["@indiekitai/pg-toolkit", "mcp"],
93
+ "env": { "DATABASE_URL": "postgresql://localhost/mydb" }
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ Available MCP tools:
100
+ - `inspect_schema` — Inspect database schema
101
+ - `diff_schemas` — Compare two databases
102
+ - `pg_activity` — Get activity snapshot
103
+
104
+ ## Programmatic API
105
+
106
+ ```typescript
107
+ import { inspect, diff, PgMonitor } from '@indiekitai/pg-toolkit';
108
+
109
+ // Inspect
110
+ const schema = await inspect('postgresql://localhost/mydb');
111
+
112
+ // Diff
113
+ const result = await diff(fromUrl, toUrl, { safe: true });
114
+
115
+ // Monitor
116
+ const monitor = new PgMonitor({ connectionString: '...', snapshot: true, json: true });
117
+ ```
118
+
119
+ ## Packages
120
+
121
+ This toolkit wraps:
122
+ - [@indiekitai/pg-inspect](https://github.com/indiekitai/pg-inspect) — Schema inspection
123
+ - [@indiekitai/pg-diff](https://github.com/indiekitai/pg-diff) — Schema diff
124
+ - [@indiekitai/pg-top](https://github.com/indiekitai/pg-top) — Activity monitor
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ import "./chunk-K3NQKI34.js";
3
+
4
+ // src/analyze.ts
5
+ var EAV_ENTITY_PATTERNS = /^(entity|item|record|submission|object|resource|parent)[_]?id$/i;
6
+ var EAV_KEY_PATTERNS = /^(key|name|attribute|field|property|type|kind|label)[_]?(name|key|id)?$/i;
7
+ var EAV_VALUE_PATTERNS = /^(value|data|content|payload|text)$/i;
8
+ function detectEav(tableName, columns) {
9
+ const colNames = columns.map((c) => c.columnName);
10
+ const entityCol = colNames.find((c) => EAV_ENTITY_PATTERNS.test(c));
11
+ const keyCol = colNames.find((c) => EAV_KEY_PATTERNS.test(c));
12
+ const valueCol = colNames.find((c) => EAV_VALUE_PATTERNS.test(c));
13
+ if (entityCol && keyCol && valueCol) {
14
+ return {
15
+ type: "eav_pattern",
16
+ severity: "high",
17
+ table: tableName,
18
+ description: `Table "${tableName}" looks like an EAV table (entity=${entityCol}, key=${keyCol}, value=${valueCol}). Querying parent + this table typically causes N+1.`,
19
+ suggestion: `Use CTE + JSON_AGG to aggregate rows by "${entityCol}" in a single query.`,
20
+ sql: `WITH aggregated AS (
21
+ SELECT
22
+ "${entityCol}",
23
+ JSON_AGG(JSON_BUILD_OBJECT('${keyCol}', "${keyCol}", '${valueCol}', "${valueCol}")) AS attrs
24
+ FROM "${tableName}"
25
+ GROUP BY "${entityCol}"
26
+ )
27
+ SELECT p.*, COALESCE(a.attrs, '[]'::json) AS attrs
28
+ FROM <parent_table> p
29
+ LEFT JOIN aggregated a ON a."${entityCol}" = p.id;`
30
+ };
31
+ }
32
+ return null;
33
+ }
34
+ function detectNPlus1FromFks(fks) {
35
+ const parentToChildren = /* @__PURE__ */ new Map();
36
+ for (const fk of fks) {
37
+ const list = parentToChildren.get(fk.parentTable) || [];
38
+ list.push(fk);
39
+ parentToChildren.set(fk.parentTable, list);
40
+ }
41
+ const warnings = [];
42
+ for (const [parentTable, children] of parentToChildren) {
43
+ for (const fk of children) {
44
+ if (fk.childTable === parentTable) continue;
45
+ warnings.push({
46
+ type: "n_plus_1_risk",
47
+ severity: "medium",
48
+ table: fk.childTable,
49
+ parentTable,
50
+ description: `"${fk.childTable}".${fk.childColumn} \u2192 "${parentTable}".${fk.parentColumn}. Loading ${parentTable} list + ${fk.childTable} details per row = N+1.`,
51
+ suggestion: `Use CTE + JSON_AGG to pre-aggregate "${fk.childTable}" rows per "${fk.childColumn}".`,
52
+ sql: `WITH child_agg AS (
53
+ SELECT "${fk.childColumn}", JSON_AGG(t.*) AS children
54
+ FROM "${fk.childTable}" t
55
+ GROUP BY "${fk.childColumn}"
56
+ )
57
+ SELECT p.*, COALESCE(c.children, '[]'::json) AS ${fk.childTable}
58
+ FROM "${parentTable}" p
59
+ LEFT JOIN child_agg c ON c."${fk.childColumn}" = p."${fk.parentColumn}";`
60
+ });
61
+ }
62
+ const uniqueChildren = new Set(children.map((c) => c.childTable));
63
+ if (uniqueChildren.size > 2) {
64
+ warnings.push({
65
+ type: "multi_fk_parent",
66
+ severity: "low",
67
+ table: parentTable,
68
+ description: `"${parentTable}" has ${uniqueChildren.size} child tables referencing it. Loading all children per row compounds N+1.`,
69
+ suggestion: `Consider multiple CTEs in a single query to fetch all child data at once.`
70
+ });
71
+ }
72
+ }
73
+ return warnings;
74
+ }
75
+ async function analyze(connectionString) {
76
+ const pg = await import("pg");
77
+ const Pool = pg.default?.Pool || pg.Pool;
78
+ const pool = new Pool({ connectionString });
79
+ try {
80
+ const dbResult = await pool.query("SELECT current_database() AS db");
81
+ const database = dbResult.rows[0].db;
82
+ const colResult = await pool.query(`
83
+ SELECT table_name, column_name, data_type, is_nullable = 'YES' AS is_nullable
84
+ FROM information_schema.columns
85
+ WHERE table_schema = 'public'
86
+ ORDER BY table_name, ordinal_position
87
+ `);
88
+ const columnsByTable = /* @__PURE__ */ new Map();
89
+ for (const row of colResult.rows) {
90
+ const list = columnsByTable.get(row.table_name) || [];
91
+ list.push({
92
+ tableName: row.table_name,
93
+ columnName: row.column_name,
94
+ dataType: row.data_type,
95
+ isNullable: row.is_nullable
96
+ });
97
+ columnsByTable.set(row.table_name, list);
98
+ }
99
+ const fkResult = await pool.query(`
100
+ SELECT
101
+ tc.constraint_name,
102
+ tc.table_name AS child_table,
103
+ kcu.column_name AS child_column,
104
+ ccu.table_name AS parent_table,
105
+ ccu.column_name AS parent_column
106
+ FROM information_schema.table_constraints tc
107
+ JOIN information_schema.key_column_usage kcu
108
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
109
+ JOIN information_schema.constraint_column_usage ccu
110
+ ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
111
+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
112
+ `);
113
+ const fks = fkResult.rows.map((r) => ({
114
+ constraintName: r.constraint_name,
115
+ childTable: r.child_table,
116
+ childColumn: r.child_column,
117
+ parentTable: r.parent_table,
118
+ parentColumn: r.parent_column
119
+ }));
120
+ const warnings = [];
121
+ for (const [tableName, columns] of columnsByTable) {
122
+ const eav = detectEav(tableName, columns);
123
+ if (eav) warnings.push(eav);
124
+ }
125
+ warnings.push(...detectNPlus1FromFks(fks));
126
+ const severityOrder = { high: 0, medium: 1, low: 2 };
127
+ warnings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
128
+ return {
129
+ database,
130
+ analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
131
+ tablesScanned: columnsByTable.size,
132
+ warnings
133
+ };
134
+ } finally {
135
+ await pool.end();
136
+ }
137
+ }
138
+ function formatAnalyzeResult(result, json) {
139
+ if (json) return JSON.stringify(result, null, 2);
140
+ const lines = [];
141
+ lines.push(`\u{1F50D} N+1 Analysis for "${result.database}"`);
142
+ lines.push(` Scanned ${result.tablesScanned} tables at ${result.analyzedAt}`);
143
+ lines.push("");
144
+ if (result.warnings.length === 0) {
145
+ lines.push("\u2705 No N+1 patterns detected.");
146
+ return lines.join("\n");
147
+ }
148
+ lines.push(`\u26A0\uFE0F Found ${result.warnings.length} potential issue(s):
149
+ `);
150
+ const severityIcon = { high: "\u{1F534}", medium: "\u{1F7E1}", low: "\u{1F535}" };
151
+ for (const w of result.warnings) {
152
+ lines.push(`${severityIcon[w.severity]} [${w.severity.toUpperCase()}] ${w.type}`);
153
+ lines.push(` ${w.description}`);
154
+ lines.push(` \u{1F4A1} ${w.suggestion}`);
155
+ if (w.sql) {
156
+ lines.push(" \u{1F4DD} Suggested SQL:");
157
+ for (const sqlLine of w.sql.split("\n")) {
158
+ lines.push(` ${sqlLine}`);
159
+ }
160
+ }
161
+ lines.push("");
162
+ }
163
+ return lines.join("\n");
164
+ }
165
+ export {
166
+ analyze,
167
+ formatAnalyzeResult
168
+ };
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __export = (target, all) => {
4
+ for (var name in all)
5
+ __defProp(target, name, { get: all[name], enumerable: true });
6
+ };
7
+
8
+ export {
9
+ __export
10
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { inspect } from "@indiekitai/pg-inspect";
5
+ import { diff } from "@indiekitai/pg-diff";
6
+ import { PgMonitor } from "@indiekitai/pg-top";
7
+ import { execFileSync } from "child_process";
8
+ var VERSION = "0.1.0";
9
+ var HELP = `
10
+ pg-toolkit v${VERSION} \u2014 Unified PostgreSQL toolkit
11
+
12
+ Usage:
13
+ pg-toolkit <command> [options] [connection-string...]
14
+
15
+ Commands:
16
+ inspect Inspect database schema (tables, views, functions, etc.)
17
+ diff Compare two database schemas and generate migration SQL
18
+ top Real-time activity monitor (like top for Postgres)
19
+ analyze Detect N+1 query patterns and suggest CTE + JSON_AGG optimizations
20
+ health Run health checks (requires pg-health)
21
+ types Generate TypeScript types from schema (requires pg2ts)
22
+ mcp Start unified MCP server
23
+
24
+ Run 'pg-toolkit <command> --help' for command-specific help.
25
+ `.trim();
26
+ function getConnStr(args) {
27
+ const url = args.find((a) => !a.startsWith("-"));
28
+ return url || process.env.DATABASE_URL || "";
29
+ }
30
+ function die(msg) {
31
+ console.error("Error: " + msg);
32
+ process.exit(1);
33
+ }
34
+ function mapToObj(map) {
35
+ const obj = {};
36
+ for (const [k, v] of map) obj[k] = serialize(v);
37
+ return obj;
38
+ }
39
+ function serialize(val) {
40
+ if (val instanceof Map) return mapToObj(val);
41
+ if (Array.isArray(val)) return val.map(serialize);
42
+ if (val && typeof val === "object") {
43
+ const out = {};
44
+ for (const [k, v] of Object.entries(val)) {
45
+ if (typeof v === "function" || k.startsWith("_")) continue;
46
+ out[k] = serialize(v);
47
+ }
48
+ return out;
49
+ }
50
+ return val;
51
+ }
52
+ var INSPECT_FILTERS = [
53
+ "tables",
54
+ "views",
55
+ "functions",
56
+ "indexes",
57
+ "sequences",
58
+ "enums",
59
+ "extensions",
60
+ "triggers",
61
+ "constraints",
62
+ "schemas",
63
+ "privileges",
64
+ "types",
65
+ "domains",
66
+ "collations",
67
+ "rls"
68
+ ];
69
+ async function cmdInspect(args) {
70
+ if (args.includes("--help") || args.includes("-h")) {
71
+ console.log(`pg-toolkit inspect \u2014 Inspect PostgreSQL schema
72
+
73
+ Usage: pg-toolkit inspect [options] <connection-string>
74
+
75
+ Options:
76
+ --tables, --views, --functions, --indexes, --sequences,
77
+ --enums, --extensions, --triggers, --constraints, --schemas,
78
+ --privileges, --types, --domains, --collations, --rls
79
+ Filter to specific object types
80
+
81
+ --summary Show summary counts only
82
+ --json JSON output (default)`);
83
+ return;
84
+ }
85
+ const connStr = getConnStr(args);
86
+ if (!connStr) die("Connection string required. Usage: pg-toolkit inspect <connection-string>");
87
+ const result = await inspect(connStr);
88
+ const filters = INSPECT_FILTERS.filter((f) => args.includes(`--${f}`));
89
+ const summary = args.includes("--summary");
90
+ if (summary) {
91
+ const counts = {};
92
+ for (const key of Object.keys(result)) {
93
+ const val = result[key];
94
+ if (val instanceof Map) counts[key] = val.size;
95
+ else if (Array.isArray(val)) counts[key] = val.length;
96
+ }
97
+ console.log(JSON.stringify(counts, null, 2));
98
+ return;
99
+ }
100
+ if (filters.length > 0) {
101
+ const out = {};
102
+ for (const f of filters) {
103
+ const val = result[f];
104
+ if (val) out[f] = serialize(val);
105
+ }
106
+ console.log(JSON.stringify(out, null, 2));
107
+ } else {
108
+ console.log(JSON.stringify(serialize(result), null, 2));
109
+ }
110
+ }
111
+ async function cmdDiff(args) {
112
+ if (args.includes("--help") || args.includes("-h")) {
113
+ console.log(`pg-toolkit diff \u2014 Compare two PostgreSQL schemas
114
+
115
+ Usage: pg-toolkit diff [options] <from_url> <to_url>
116
+
117
+ Options:
118
+ --json JSON output (machine-readable)
119
+ --safe Omit DROP statements`);
120
+ return;
121
+ }
122
+ const urls = args.filter((a) => !a.startsWith("-"));
123
+ if (urls.length < 2) die("Two connection strings required. Usage: pg-toolkit diff <from_url> <to_url>");
124
+ const jsonMode = args.includes("--json");
125
+ const safe = args.includes("--safe");
126
+ const ignoreExtVersions = args.includes("--ignore-extension-versions");
127
+ const result = await diff(urls[0], urls[1], { safe, ignoreExtensionVersions: ignoreExtVersions });
128
+ if (jsonMode) {
129
+ console.log(JSON.stringify(result, null, 2));
130
+ } else {
131
+ if (result.sql) {
132
+ console.log(result.sql);
133
+ } else {
134
+ console.log("No differences found.");
135
+ }
136
+ }
137
+ }
138
+ async function cmdTop(args) {
139
+ if (args.includes("--help") || args.includes("-h")) {
140
+ console.log(`pg-toolkit top \u2014 Real-time PostgreSQL activity monitor
141
+
142
+ Usage: pg-toolkit top [options] <connection-string>
143
+
144
+ Options:
145
+ --refresh <seconds> Refresh interval (default: 2)
146
+ --no-idle Hide idle connections
147
+ --snapshot Single snapshot, then exit
148
+ --json Output as JSON (with --snapshot)`);
149
+ return;
150
+ }
151
+ const connStr = getConnStr(args);
152
+ if (!connStr) die("Connection string required. Usage: pg-toolkit top <connection-string>");
153
+ let refreshInterval = 2;
154
+ let noIdle = false;
155
+ let snapshot = false;
156
+ let json = false;
157
+ for (let i = 0; i < args.length; i++) {
158
+ if (args[i] === "--refresh" && args[i + 1]) refreshInterval = Number(args[++i]);
159
+ else if (args[i] === "--no-idle") noIdle = true;
160
+ else if (args[i] === "--snapshot") snapshot = true;
161
+ else if (args[i] === "--json") json = true;
162
+ }
163
+ const monitor = new PgMonitor({
164
+ connectionString: connStr,
165
+ refreshInterval,
166
+ noIdle,
167
+ snapshot,
168
+ json
169
+ });
170
+ await monitor.start();
171
+ }
172
+ function cmdHealth(args) {
173
+ if (args.includes("--help") || args.includes("-h")) {
174
+ console.log(`pg-toolkit health \u2014 PostgreSQL health checks (delegates to pg-health)
175
+
176
+ Usage: pg-toolkit health <connection-string>
177
+
178
+ Requires pg-health to be installed (pip install pg-health or available in PATH).`);
179
+ return;
180
+ }
181
+ const connStr = getConnStr(args);
182
+ if (!connStr) die("Connection string required.");
183
+ try {
184
+ execFileSync("pg-health", [connStr], { stdio: "inherit" });
185
+ } catch {
186
+ console.error("pg-health not found. Install it: pip install pg-health");
187
+ process.exit(1);
188
+ }
189
+ }
190
+ function cmdTypes(args) {
191
+ if (args.includes("--help") || args.includes("-h")) {
192
+ console.log(`pg-toolkit types \u2014 Generate TypeScript types from PostgreSQL schema
193
+
194
+ Usage: pg-toolkit types <connection-string>
195
+
196
+ Requires pg2ts to be installed and available in PATH.`);
197
+ return;
198
+ }
199
+ const connStr = getConnStr(args);
200
+ if (!connStr) die("Connection string required.");
201
+ try {
202
+ execFileSync("pg2ts", [connStr], { stdio: "inherit" });
203
+ } catch {
204
+ console.error("pg2ts not found. Install it or check PATH.");
205
+ process.exit(1);
206
+ }
207
+ }
208
+ async function cmdAnalyze(args) {
209
+ if (args.includes("--help") || args.includes("-h")) {
210
+ console.log(`pg-toolkit analyze \u2014 Detect N+1 query patterns and suggest optimizations
211
+
212
+ Usage: pg-toolkit analyze [options] <connection-string>
213
+
214
+ Options:
215
+ --json JSON output (default: human-readable)
216
+
217
+ Scans table structures and foreign keys to detect:
218
+ - EAV (Entity-Attribute-Value) pattern tables
219
+ - Many-to-one relationships prone to N+1
220
+ - Tables with multiple child tables
221
+
222
+ Suggests CTE + JSON_AGG rewrites for each finding.`);
223
+ return;
224
+ }
225
+ const connStr = getConnStr(args);
226
+ if (!connStr) die("Connection string required. Usage: pg-toolkit analyze <connection-string>");
227
+ const { analyze, formatAnalyzeResult } = await import("./analyze.js");
228
+ const result = await analyze(connStr);
229
+ const jsonMode = args.includes("--json");
230
+ console.log(formatAnalyzeResult(result, jsonMode));
231
+ }
232
+ async function main() {
233
+ const args = process.argv.slice(2);
234
+ const command = args[0];
235
+ const rest = args.slice(1);
236
+ if (!command || command === "--help" || command === "-h") {
237
+ console.log(HELP);
238
+ return;
239
+ }
240
+ if (command === "--version" || command === "-v") {
241
+ console.log(VERSION);
242
+ return;
243
+ }
244
+ switch (command) {
245
+ case "inspect":
246
+ return cmdInspect(rest);
247
+ case "diff":
248
+ return cmdDiff(rest);
249
+ case "top":
250
+ return cmdTop(rest);
251
+ case "analyze":
252
+ return cmdAnalyze(rest);
253
+ case "health":
254
+ return cmdHealth(rest);
255
+ case "types":
256
+ return cmdTypes(rest);
257
+ case "mcp": {
258
+ const { startMcpServer } = await import("./mcp.js");
259
+ return startMcpServer();
260
+ }
261
+ default:
262
+ console.error(`Unknown command: ${command}`);
263
+ console.log(HELP);
264
+ process.exit(1);
265
+ }
266
+ }
267
+ main().catch((err) => {
268
+ console.error(err.message || err);
269
+ process.exit(1);
270
+ });