@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/dist/index.cjs ADDED
@@ -0,0 +1,220 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ PgInspector: () => import_pg_inspect.PgInspector,
34
+ PgMonitor: () => import_pg_top.PgMonitor,
35
+ analyze: () => analyze,
36
+ computeDiff: () => import_pg_diff.computeDiff,
37
+ diff: () => import_pg_diff.diff,
38
+ formatAnalyzeResult: () => formatAnalyzeResult,
39
+ inspect: () => import_pg_inspect.inspect,
40
+ inspectSchema: () => import_pg_diff.inspectSchema
41
+ });
42
+ module.exports = __toCommonJS(index_exports);
43
+ var import_pg_inspect = require("@indiekitai/pg-inspect");
44
+ var import_pg_diff = require("@indiekitai/pg-diff");
45
+
46
+ // src/analyze.ts
47
+ var EAV_ENTITY_PATTERNS = /^(entity|item|record|submission|object|resource|parent)[_]?id$/i;
48
+ var EAV_KEY_PATTERNS = /^(key|name|attribute|field|property|type|kind|label)[_]?(name|key|id)?$/i;
49
+ var EAV_VALUE_PATTERNS = /^(value|data|content|payload|text)$/i;
50
+ function detectEav(tableName, columns) {
51
+ const colNames = columns.map((c) => c.columnName);
52
+ const entityCol = colNames.find((c) => EAV_ENTITY_PATTERNS.test(c));
53
+ const keyCol = colNames.find((c) => EAV_KEY_PATTERNS.test(c));
54
+ const valueCol = colNames.find((c) => EAV_VALUE_PATTERNS.test(c));
55
+ if (entityCol && keyCol && valueCol) {
56
+ return {
57
+ type: "eav_pattern",
58
+ severity: "high",
59
+ table: tableName,
60
+ description: `Table "${tableName}" looks like an EAV table (entity=${entityCol}, key=${keyCol}, value=${valueCol}). Querying parent + this table typically causes N+1.`,
61
+ suggestion: `Use CTE + JSON_AGG to aggregate rows by "${entityCol}" in a single query.`,
62
+ sql: `WITH aggregated AS (
63
+ SELECT
64
+ "${entityCol}",
65
+ JSON_AGG(JSON_BUILD_OBJECT('${keyCol}', "${keyCol}", '${valueCol}', "${valueCol}")) AS attrs
66
+ FROM "${tableName}"
67
+ GROUP BY "${entityCol}"
68
+ )
69
+ SELECT p.*, COALESCE(a.attrs, '[]'::json) AS attrs
70
+ FROM <parent_table> p
71
+ LEFT JOIN aggregated a ON a."${entityCol}" = p.id;`
72
+ };
73
+ }
74
+ return null;
75
+ }
76
+ function detectNPlus1FromFks(fks) {
77
+ const parentToChildren = /* @__PURE__ */ new Map();
78
+ for (const fk of fks) {
79
+ const list = parentToChildren.get(fk.parentTable) || [];
80
+ list.push(fk);
81
+ parentToChildren.set(fk.parentTable, list);
82
+ }
83
+ const warnings = [];
84
+ for (const [parentTable, children] of parentToChildren) {
85
+ for (const fk of children) {
86
+ if (fk.childTable === parentTable) continue;
87
+ warnings.push({
88
+ type: "n_plus_1_risk",
89
+ severity: "medium",
90
+ table: fk.childTable,
91
+ parentTable,
92
+ description: `"${fk.childTable}".${fk.childColumn} \u2192 "${parentTable}".${fk.parentColumn}. Loading ${parentTable} list + ${fk.childTable} details per row = N+1.`,
93
+ suggestion: `Use CTE + JSON_AGG to pre-aggregate "${fk.childTable}" rows per "${fk.childColumn}".`,
94
+ sql: `WITH child_agg AS (
95
+ SELECT "${fk.childColumn}", JSON_AGG(t.*) AS children
96
+ FROM "${fk.childTable}" t
97
+ GROUP BY "${fk.childColumn}"
98
+ )
99
+ SELECT p.*, COALESCE(c.children, '[]'::json) AS ${fk.childTable}
100
+ FROM "${parentTable}" p
101
+ LEFT JOIN child_agg c ON c."${fk.childColumn}" = p."${fk.parentColumn}";`
102
+ });
103
+ }
104
+ const uniqueChildren = new Set(children.map((c) => c.childTable));
105
+ if (uniqueChildren.size > 2) {
106
+ warnings.push({
107
+ type: "multi_fk_parent",
108
+ severity: "low",
109
+ table: parentTable,
110
+ description: `"${parentTable}" has ${uniqueChildren.size} child tables referencing it. Loading all children per row compounds N+1.`,
111
+ suggestion: `Consider multiple CTEs in a single query to fetch all child data at once.`
112
+ });
113
+ }
114
+ }
115
+ return warnings;
116
+ }
117
+ async function analyze(connectionString) {
118
+ const pg = await import("pg");
119
+ const Pool = pg.default?.Pool || pg.Pool;
120
+ const pool = new Pool({ connectionString });
121
+ try {
122
+ const dbResult = await pool.query("SELECT current_database() AS db");
123
+ const database = dbResult.rows[0].db;
124
+ const colResult = await pool.query(`
125
+ SELECT table_name, column_name, data_type, is_nullable = 'YES' AS is_nullable
126
+ FROM information_schema.columns
127
+ WHERE table_schema = 'public'
128
+ ORDER BY table_name, ordinal_position
129
+ `);
130
+ const columnsByTable = /* @__PURE__ */ new Map();
131
+ for (const row of colResult.rows) {
132
+ const list = columnsByTable.get(row.table_name) || [];
133
+ list.push({
134
+ tableName: row.table_name,
135
+ columnName: row.column_name,
136
+ dataType: row.data_type,
137
+ isNullable: row.is_nullable
138
+ });
139
+ columnsByTable.set(row.table_name, list);
140
+ }
141
+ const fkResult = await pool.query(`
142
+ SELECT
143
+ tc.constraint_name,
144
+ tc.table_name AS child_table,
145
+ kcu.column_name AS child_column,
146
+ ccu.table_name AS parent_table,
147
+ ccu.column_name AS parent_column
148
+ FROM information_schema.table_constraints tc
149
+ JOIN information_schema.key_column_usage kcu
150
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
151
+ JOIN information_schema.constraint_column_usage ccu
152
+ ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
153
+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
154
+ `);
155
+ const fks = fkResult.rows.map((r) => ({
156
+ constraintName: r.constraint_name,
157
+ childTable: r.child_table,
158
+ childColumn: r.child_column,
159
+ parentTable: r.parent_table,
160
+ parentColumn: r.parent_column
161
+ }));
162
+ const warnings = [];
163
+ for (const [tableName, columns] of columnsByTable) {
164
+ const eav = detectEav(tableName, columns);
165
+ if (eav) warnings.push(eav);
166
+ }
167
+ warnings.push(...detectNPlus1FromFks(fks));
168
+ const severityOrder = { high: 0, medium: 1, low: 2 };
169
+ warnings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
170
+ return {
171
+ database,
172
+ analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
173
+ tablesScanned: columnsByTable.size,
174
+ warnings
175
+ };
176
+ } finally {
177
+ await pool.end();
178
+ }
179
+ }
180
+ function formatAnalyzeResult(result, json) {
181
+ if (json) return JSON.stringify(result, null, 2);
182
+ const lines = [];
183
+ lines.push(`\u{1F50D} N+1 Analysis for "${result.database}"`);
184
+ lines.push(` Scanned ${result.tablesScanned} tables at ${result.analyzedAt}`);
185
+ lines.push("");
186
+ if (result.warnings.length === 0) {
187
+ lines.push("\u2705 No N+1 patterns detected.");
188
+ return lines.join("\n");
189
+ }
190
+ lines.push(`\u26A0\uFE0F Found ${result.warnings.length} potential issue(s):
191
+ `);
192
+ const severityIcon = { high: "\u{1F534}", medium: "\u{1F7E1}", low: "\u{1F535}" };
193
+ for (const w of result.warnings) {
194
+ lines.push(`${severityIcon[w.severity]} [${w.severity.toUpperCase()}] ${w.type}`);
195
+ lines.push(` ${w.description}`);
196
+ lines.push(` \u{1F4A1} ${w.suggestion}`);
197
+ if (w.sql) {
198
+ lines.push(" \u{1F4DD} Suggested SQL:");
199
+ for (const sqlLine of w.sql.split("\n")) {
200
+ lines.push(` ${sqlLine}`);
201
+ }
202
+ }
203
+ lines.push("");
204
+ }
205
+ return lines.join("\n");
206
+ }
207
+
208
+ // src/index.ts
209
+ var import_pg_top = require("@indiekitai/pg-top");
210
+ // Annotate the CommonJS export names for ESM import in node:
211
+ 0 && (module.exports = {
212
+ PgInspector,
213
+ PgMonitor,
214
+ analyze,
215
+ computeDiff,
216
+ diff,
217
+ formatAnalyzeResult,
218
+ inspect,
219
+ inspectSchema
220
+ });
@@ -0,0 +1,31 @@
1
+ export { ConnectionConfig, PgInspector, inspect } from '@indiekitai/pg-inspect';
2
+ export { DiffOptions, DiffResult, computeDiff, diff, inspectSchema } from '@indiekitai/pg-diff';
3
+ export { Activity, DbStats, MonitorOptions, PgMonitor } from '@indiekitai/pg-top';
4
+
5
+ /**
6
+ * pg-toolkit analyze — Detect N+1 query patterns and suggest CTE + JSON_AGG optimizations.
7
+ *
8
+ * Scans database schema for:
9
+ * - Many-to-one relationships (foreign keys)
10
+ * - EAV-pattern tables (entity_id + key/name + value columns)
11
+ * - Tables with multiple FKs pointing to the same parent
12
+ */
13
+ interface AnalyzeWarning {
14
+ type: 'n_plus_1_risk' | 'eav_pattern' | 'multi_fk_parent';
15
+ severity: 'high' | 'medium' | 'low';
16
+ table: string;
17
+ parentTable?: string;
18
+ description: string;
19
+ suggestion: string;
20
+ sql?: string;
21
+ }
22
+ interface AnalyzeResult {
23
+ database: string;
24
+ analyzedAt: string;
25
+ tablesScanned: number;
26
+ warnings: AnalyzeWarning[];
27
+ }
28
+ declare function analyze(connectionString: string): Promise<AnalyzeResult>;
29
+ declare function formatAnalyzeResult(result: AnalyzeResult, json: boolean): string;
30
+
31
+ export { type AnalyzeResult, type AnalyzeWarning, analyze, formatAnalyzeResult };
@@ -0,0 +1,31 @@
1
+ export { ConnectionConfig, PgInspector, inspect } from '@indiekitai/pg-inspect';
2
+ export { DiffOptions, DiffResult, computeDiff, diff, inspectSchema } from '@indiekitai/pg-diff';
3
+ export { Activity, DbStats, MonitorOptions, PgMonitor } from '@indiekitai/pg-top';
4
+
5
+ /**
6
+ * pg-toolkit analyze — Detect N+1 query patterns and suggest CTE + JSON_AGG optimizations.
7
+ *
8
+ * Scans database schema for:
9
+ * - Many-to-one relationships (foreign keys)
10
+ * - EAV-pattern tables (entity_id + key/name + value columns)
11
+ * - Tables with multiple FKs pointing to the same parent
12
+ */
13
+ interface AnalyzeWarning {
14
+ type: 'n_plus_1_risk' | 'eav_pattern' | 'multi_fk_parent';
15
+ severity: 'high' | 'medium' | 'low';
16
+ table: string;
17
+ parentTable?: string;
18
+ description: string;
19
+ suggestion: string;
20
+ sql?: string;
21
+ }
22
+ interface AnalyzeResult {
23
+ database: string;
24
+ analyzedAt: string;
25
+ tablesScanned: number;
26
+ warnings: AnalyzeWarning[];
27
+ }
28
+ declare function analyze(connectionString: string): Promise<AnalyzeResult>;
29
+ declare function formatAnalyzeResult(result: AnalyzeResult, json: boolean): string;
30
+
31
+ export { type AnalyzeResult, type AnalyzeWarning, analyze, formatAnalyzeResult };
package/dist/index.js ADDED
@@ -0,0 +1,178 @@
1
+ // src/index.ts
2
+ import { PgInspector, inspect } from "@indiekitai/pg-inspect";
3
+ import { inspectSchema, computeDiff, diff } from "@indiekitai/pg-diff";
4
+
5
+ // src/analyze.ts
6
+ var EAV_ENTITY_PATTERNS = /^(entity|item|record|submission|object|resource|parent)[_]?id$/i;
7
+ var EAV_KEY_PATTERNS = /^(key|name|attribute|field|property|type|kind|label)[_]?(name|key|id)?$/i;
8
+ var EAV_VALUE_PATTERNS = /^(value|data|content|payload|text)$/i;
9
+ function detectEav(tableName, columns) {
10
+ const colNames = columns.map((c) => c.columnName);
11
+ const entityCol = colNames.find((c) => EAV_ENTITY_PATTERNS.test(c));
12
+ const keyCol = colNames.find((c) => EAV_KEY_PATTERNS.test(c));
13
+ const valueCol = colNames.find((c) => EAV_VALUE_PATTERNS.test(c));
14
+ if (entityCol && keyCol && valueCol) {
15
+ return {
16
+ type: "eav_pattern",
17
+ severity: "high",
18
+ table: tableName,
19
+ description: `Table "${tableName}" looks like an EAV table (entity=${entityCol}, key=${keyCol}, value=${valueCol}). Querying parent + this table typically causes N+1.`,
20
+ suggestion: `Use CTE + JSON_AGG to aggregate rows by "${entityCol}" in a single query.`,
21
+ sql: `WITH aggregated AS (
22
+ SELECT
23
+ "${entityCol}",
24
+ JSON_AGG(JSON_BUILD_OBJECT('${keyCol}', "${keyCol}", '${valueCol}', "${valueCol}")) AS attrs
25
+ FROM "${tableName}"
26
+ GROUP BY "${entityCol}"
27
+ )
28
+ SELECT p.*, COALESCE(a.attrs, '[]'::json) AS attrs
29
+ FROM <parent_table> p
30
+ LEFT JOIN aggregated a ON a."${entityCol}" = p.id;`
31
+ };
32
+ }
33
+ return null;
34
+ }
35
+ function detectNPlus1FromFks(fks) {
36
+ const parentToChildren = /* @__PURE__ */ new Map();
37
+ for (const fk of fks) {
38
+ const list = parentToChildren.get(fk.parentTable) || [];
39
+ list.push(fk);
40
+ parentToChildren.set(fk.parentTable, list);
41
+ }
42
+ const warnings = [];
43
+ for (const [parentTable, children] of parentToChildren) {
44
+ for (const fk of children) {
45
+ if (fk.childTable === parentTable) continue;
46
+ warnings.push({
47
+ type: "n_plus_1_risk",
48
+ severity: "medium",
49
+ table: fk.childTable,
50
+ parentTable,
51
+ description: `"${fk.childTable}".${fk.childColumn} \u2192 "${parentTable}".${fk.parentColumn}. Loading ${parentTable} list + ${fk.childTable} details per row = N+1.`,
52
+ suggestion: `Use CTE + JSON_AGG to pre-aggregate "${fk.childTable}" rows per "${fk.childColumn}".`,
53
+ sql: `WITH child_agg AS (
54
+ SELECT "${fk.childColumn}", JSON_AGG(t.*) AS children
55
+ FROM "${fk.childTable}" t
56
+ GROUP BY "${fk.childColumn}"
57
+ )
58
+ SELECT p.*, COALESCE(c.children, '[]'::json) AS ${fk.childTable}
59
+ FROM "${parentTable}" p
60
+ LEFT JOIN child_agg c ON c."${fk.childColumn}" = p."${fk.parentColumn}";`
61
+ });
62
+ }
63
+ const uniqueChildren = new Set(children.map((c) => c.childTable));
64
+ if (uniqueChildren.size > 2) {
65
+ warnings.push({
66
+ type: "multi_fk_parent",
67
+ severity: "low",
68
+ table: parentTable,
69
+ description: `"${parentTable}" has ${uniqueChildren.size} child tables referencing it. Loading all children per row compounds N+1.`,
70
+ suggestion: `Consider multiple CTEs in a single query to fetch all child data at once.`
71
+ });
72
+ }
73
+ }
74
+ return warnings;
75
+ }
76
+ async function analyze(connectionString) {
77
+ const pg = await import("pg");
78
+ const Pool = pg.default?.Pool || pg.Pool;
79
+ const pool = new Pool({ connectionString });
80
+ try {
81
+ const dbResult = await pool.query("SELECT current_database() AS db");
82
+ const database = dbResult.rows[0].db;
83
+ const colResult = await pool.query(`
84
+ SELECT table_name, column_name, data_type, is_nullable = 'YES' AS is_nullable
85
+ FROM information_schema.columns
86
+ WHERE table_schema = 'public'
87
+ ORDER BY table_name, ordinal_position
88
+ `);
89
+ const columnsByTable = /* @__PURE__ */ new Map();
90
+ for (const row of colResult.rows) {
91
+ const list = columnsByTable.get(row.table_name) || [];
92
+ list.push({
93
+ tableName: row.table_name,
94
+ columnName: row.column_name,
95
+ dataType: row.data_type,
96
+ isNullable: row.is_nullable
97
+ });
98
+ columnsByTable.set(row.table_name, list);
99
+ }
100
+ const fkResult = await pool.query(`
101
+ SELECT
102
+ tc.constraint_name,
103
+ tc.table_name AS child_table,
104
+ kcu.column_name AS child_column,
105
+ ccu.table_name AS parent_table,
106
+ ccu.column_name AS parent_column
107
+ FROM information_schema.table_constraints tc
108
+ JOIN information_schema.key_column_usage kcu
109
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
110
+ JOIN information_schema.constraint_column_usage ccu
111
+ ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
112
+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
113
+ `);
114
+ const fks = fkResult.rows.map((r) => ({
115
+ constraintName: r.constraint_name,
116
+ childTable: r.child_table,
117
+ childColumn: r.child_column,
118
+ parentTable: r.parent_table,
119
+ parentColumn: r.parent_column
120
+ }));
121
+ const warnings = [];
122
+ for (const [tableName, columns] of columnsByTable) {
123
+ const eav = detectEav(tableName, columns);
124
+ if (eav) warnings.push(eav);
125
+ }
126
+ warnings.push(...detectNPlus1FromFks(fks));
127
+ const severityOrder = { high: 0, medium: 1, low: 2 };
128
+ warnings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
129
+ return {
130
+ database,
131
+ analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
132
+ tablesScanned: columnsByTable.size,
133
+ warnings
134
+ };
135
+ } finally {
136
+ await pool.end();
137
+ }
138
+ }
139
+ function formatAnalyzeResult(result, json) {
140
+ if (json) return JSON.stringify(result, null, 2);
141
+ const lines = [];
142
+ lines.push(`\u{1F50D} N+1 Analysis for "${result.database}"`);
143
+ lines.push(` Scanned ${result.tablesScanned} tables at ${result.analyzedAt}`);
144
+ lines.push("");
145
+ if (result.warnings.length === 0) {
146
+ lines.push("\u2705 No N+1 patterns detected.");
147
+ return lines.join("\n");
148
+ }
149
+ lines.push(`\u26A0\uFE0F Found ${result.warnings.length} potential issue(s):
150
+ `);
151
+ const severityIcon = { high: "\u{1F534}", medium: "\u{1F7E1}", low: "\u{1F535}" };
152
+ for (const w of result.warnings) {
153
+ lines.push(`${severityIcon[w.severity]} [${w.severity.toUpperCase()}] ${w.type}`);
154
+ lines.push(` ${w.description}`);
155
+ lines.push(` \u{1F4A1} ${w.suggestion}`);
156
+ if (w.sql) {
157
+ lines.push(" \u{1F4DD} Suggested SQL:");
158
+ for (const sqlLine of w.sql.split("\n")) {
159
+ lines.push(` ${sqlLine}`);
160
+ }
161
+ }
162
+ lines.push("");
163
+ }
164
+ return lines.join("\n");
165
+ }
166
+
167
+ // src/index.ts
168
+ import { PgMonitor } from "@indiekitai/pg-top";
169
+ export {
170
+ PgInspector,
171
+ PgMonitor,
172
+ analyze,
173
+ computeDiff,
174
+ diff,
175
+ formatAnalyzeResult,
176
+ inspect,
177
+ inspectSchema
178
+ };