@akshythere/pg-sync 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +151 -3
- package/dist/commands/generate.js +74 -0
- package/dist/commands/init.js +62 -0
- package/dist/engine/db.js +12 -0
- package/dist/engine/introspector.js +111 -0
- package/dist/engine/mapper.js +55 -0
- package/dist/engine/types.js +1 -0
- package/dist/generators/erd.js +35 -0
- package/dist/generators/frappe.js +101 -0
- package/dist/generators/typescript.js +47 -0
- package/dist/index.js +34 -0
- package/dist/utils/config.js +18 -0
- package/package.json +44 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pg-sync contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,4 +1,152 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pg-sync
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
> PostgreSQL schema introspection that turns your database into useful code and documentation.
|
|
4
|
+
|
|
5
|
+
<p align="left">
|
|
6
|
+
<a href="https://www.npmjs.com/package/pg-sync"><img src="https://img.shields.io/npm/v/pg-sync?color=cb3837&logo=npm" alt="npm version"></a>
|
|
7
|
+
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0%2B-3178C6?logo=typescript&logoColor=white" alt="TypeScript 5.0+"></a>
|
|
8
|
+
<a href="https://www.postgresql.org/"><img src="https://img.shields.io/badge/PostgreSQL-12%2B-336791?logo=postgresql&logoColor=white" alt="PostgreSQL 12+"></a>
|
|
9
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-yellow.svg" alt="MIT license"></a>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
`pg-sync` connects directly to PostgreSQL, reads its catalog metadata, and generates artifacts from a normalized schema model. Use it as `pg-sync` or the shorter `pgs` command.
|
|
13
|
+
|
|
14
|
+
## Why pg-sync?
|
|
15
|
+
|
|
16
|
+
Keep generated types, framework schemas, and architecture documentation aligned with the database that actually runs your application.
|
|
17
|
+
|
|
18
|
+
- No ORM runtime dependency
|
|
19
|
+
- Direct queries against `pg_catalog` and `information_schema`
|
|
20
|
+
- Foreign keys and partitioned tables included in the schema model
|
|
21
|
+
- Prettier-formatted TypeScript output
|
|
22
|
+
- Interactive setup with a connection check
|
|
23
|
+
|
|
24
|
+
## Outputs
|
|
25
|
+
|
|
26
|
+
| Target | Output | Best for |
|
|
27
|
+
| ------------ | -------------------------- | --------------------------------------------------- |
|
|
28
|
+
| `typescript` | `schema.ts` | Typed application code with zero runtime dependency |
|
|
29
|
+
| `frappe` | One JSON DocType per table | Frappe custom DocTypes and field configuration |
|
|
30
|
+
| `erd` | `ERD.md` with Mermaid | Schema reviews, technical docs, and onboarding |
|
|
31
|
+
|
|
32
|
+
The generators understand nullable columns, defaults, arrays, primary keys, foreign keys, and common PostgreSQL types. Frappe output also maps relationships to `Link` fields and common numeric, date, and boolean types to suitable field types.
|
|
33
|
+
|
|
34
|
+
## How it works
|
|
35
|
+
|
|
36
|
+
```mermaid
|
|
37
|
+
flowchart LR
|
|
38
|
+
A[(PostgreSQL)] --> B[Catalog introspection]
|
|
39
|
+
B --> C[Normalized schema IR]
|
|
40
|
+
C --> D[TypeScript]
|
|
41
|
+
C --> E[Frappe DocTypes]
|
|
42
|
+
C --> F[Mermaid ERD]
|
|
43
|
+
D --> G[(Local files)]
|
|
44
|
+
E --> G
|
|
45
|
+
F --> G
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
### Use globally
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install -g pg-sync
|
|
54
|
+
pgs --help
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Build from source
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
git clone https://github.com/akshitbhardwajhere/pg-sync.git
|
|
61
|
+
cd pg-sync
|
|
62
|
+
npm install
|
|
63
|
+
npm run build
|
|
64
|
+
npm link
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Quick start
|
|
68
|
+
|
|
69
|
+
### 1. Create a configuration
|
|
70
|
+
|
|
71
|
+
The wizard asks for your connection details, verifies the database connection, and writes `pg-sync.config.json`.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pgs init
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Choose TypeScript interfaces, Frappe DocTypes, or Mermaid ERD documentation. The wizard defaults to `./src/types`, `./doctypes`, or `./docs` respectively.
|
|
78
|
+
|
|
79
|
+
Example configuration:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"connectionString": "postgresql://postgres:postgres@localhost:5432/mydb",
|
|
84
|
+
"schema": "public",
|
|
85
|
+
"target": "typescript",
|
|
86
|
+
"outputDir": "./src/types"
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 2. Generate files
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pgs generate
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Command-line options override values from the config file:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# TypeScript definitions
|
|
100
|
+
pgs generate -u "postgresql://user:pass@localhost:5432/mydb" \
|
|
101
|
+
-t typescript -o ./src/types
|
|
102
|
+
|
|
103
|
+
# Frappe DocTypes
|
|
104
|
+
pgs generate -t frappe -o ./doctypes
|
|
105
|
+
|
|
106
|
+
# Mermaid ERD documentation
|
|
107
|
+
pgs generate -t erd -o ./docs
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`DATABASE_URL` is also supported when no connection string is supplied through a flag or configuration file.
|
|
111
|
+
|
|
112
|
+
## Configuration
|
|
113
|
+
|
|
114
|
+
Create `pg-sync.config.json` in the project root, or pass a custom file with `-c`.
|
|
115
|
+
|
|
116
|
+
| Property | Type | Default | Description |
|
|
117
|
+
| ------------------ | -------- | ---------------- | -------------------------------- |
|
|
118
|
+
| `connectionString` | `string` | Required | PostgreSQL connection URI |
|
|
119
|
+
| `schema` | `string` | `public` | PostgreSQL schema to inspect |
|
|
120
|
+
| `target` | `string` | `typescript` | `typescript`, `frappe`, or `erd` |
|
|
121
|
+
| `outputDir` | `string` | Target-dependent | Directory for generated files |
|
|
122
|
+
|
|
123
|
+
Useful flags:
|
|
124
|
+
|
|
125
|
+
```text
|
|
126
|
+
-u, --url <url> PostgreSQL connection string
|
|
127
|
+
-s, --schema <name> Database schema to inspect
|
|
128
|
+
-o, --output <dir> Output directory
|
|
129
|
+
-t, --target <type> typescript | frappe | erd
|
|
130
|
+
-c, --config <file> Custom configuration path
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npm run build # Compile TypeScript
|
|
137
|
+
npm test # Run the test suite
|
|
138
|
+
npm run test:coverage # Generate coverage metrics
|
|
139
|
+
npm run test:watch # Watch tests during development
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Integration tests expect a reachable PostgreSQL instance. The CI workflow runs them against a PostgreSQL service container.
|
|
143
|
+
|
|
144
|
+
## Requirements
|
|
145
|
+
|
|
146
|
+
- Node.js 18+
|
|
147
|
+
- PostgreSQL 12+
|
|
148
|
+
- A database user with permission to read the target schema metadata
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
Distributed under the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import pc from "picocolors";
|
|
5
|
+
import { createDatabasePool } from "../engine/db.js";
|
|
6
|
+
import { introspectDatabase } from "../engine/introspector.js";
|
|
7
|
+
import { generateTypeScriptDefinitions } from "../generators/typescript.js";
|
|
8
|
+
import { generateFrappeDocTypes } from "../generators/frappe.js";
|
|
9
|
+
import { loadConfig } from "../utils/config.js";
|
|
10
|
+
import { generateMermaidERD } from "../generators/erd.js";
|
|
11
|
+
export async function runGenerate(options) {
|
|
12
|
+
const fileConfig = await loadConfig(options.configFile);
|
|
13
|
+
const connectionString = options.connectionString ||
|
|
14
|
+
process.env.DATABASE_URL ||
|
|
15
|
+
fileConfig?.connectionString;
|
|
16
|
+
if (!connectionString) {
|
|
17
|
+
console.error(pc.red("Error: No connection string provided."));
|
|
18
|
+
console.log(`Run ${pc.cyan("pg-sync init")} or supply ${pc.yellow("-u <connectionString>")}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const schema = options.schema || fileConfig?.schema || "public";
|
|
22
|
+
const target = options.target || fileConfig?.target || "typescript";
|
|
23
|
+
const rawOutputDir = options.outputDir ||
|
|
24
|
+
fileConfig?.outputDir ||
|
|
25
|
+
(target === "typescript" ? "./generated" : "./doctypes");
|
|
26
|
+
const outputDir = path.resolve(process.cwd(), rawOutputDir);
|
|
27
|
+
const spinner = ora("Connecting to PostgreSQL database...").start();
|
|
28
|
+
const pool = createDatabasePool(connectionString);
|
|
29
|
+
try {
|
|
30
|
+
spinner.text = "Introspecting database catalog and schema tables...";
|
|
31
|
+
const schemaIR = await introspectDatabase(pool, schema);
|
|
32
|
+
spinner.succeed(pc.green(`Introspection complete! Found ${schemaIR.tables.length} tables.`));
|
|
33
|
+
for (const table of schemaIR.tables) {
|
|
34
|
+
const partitionInfo = table.isPartitioned
|
|
35
|
+
? pc.magenta(` [Partitioned: ${table.partitionStrategy}]`)
|
|
36
|
+
: "";
|
|
37
|
+
console.log(` • ${pc.bold(table.tableName)}${partitionInfo} (${table.columns.length} columns)`);
|
|
38
|
+
}
|
|
39
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
40
|
+
if (target === "typescript") {
|
|
41
|
+
const genSpinner = ora("Generating TypeScript definitions...").start();
|
|
42
|
+
const formattedCode = await generateTypeScriptDefinitions(schemaIR);
|
|
43
|
+
const outputPath = path.join(outputDir, "schema.ts");
|
|
44
|
+
await fs.writeFile(outputPath, formattedCode, "utf-8");
|
|
45
|
+
genSpinner.succeed(pc.green(`TypeScript types saved to: ${pc.bold(outputPath)}`));
|
|
46
|
+
}
|
|
47
|
+
else if (target === "frappe") {
|
|
48
|
+
const genSpinner = ora("Generating Frappe DocType schemas...").start();
|
|
49
|
+
const docTypes = await generateFrappeDocTypes(schemaIR);
|
|
50
|
+
for (const dt of docTypes) {
|
|
51
|
+
const docTypeDir = path.join(outputDir, dt.docTypeName.toLowerCase());
|
|
52
|
+
await fs.mkdir(docTypeDir, { recursive: true });
|
|
53
|
+
const filePath = path.join(docTypeDir, `${dt.docTypeName.toLowerCase()}.json`);
|
|
54
|
+
await fs.writeFile(filePath, dt.content, "utf-8");
|
|
55
|
+
}
|
|
56
|
+
genSpinner.succeed(pc.green(`Generated ${docTypes.length} Frappe DocType schemas in: ${pc.bold(outputDir)}`));
|
|
57
|
+
}
|
|
58
|
+
else if (target === "erd") {
|
|
59
|
+
const genSpinner = ora("Generating Mermaid ERD documentation...").start();
|
|
60
|
+
const erdMarkdown = generateMermaidERD(schemaIR);
|
|
61
|
+
const outputPath = path.join(outputDir, "ERD.md");
|
|
62
|
+
await fs.writeFile(outputPath, erdMarkdown, "utf-8");
|
|
63
|
+
genSpinner.succeed(pc.green(`ERD documentation saved to: ${pc.bold(outputPath)}`));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
spinner.fail(pc.red("Generation failed."));
|
|
68
|
+
console.error(pc.red(err.message));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await pool.end();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { input, select } from "@inquirer/prompts";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { createDatabasePool } from "../engine/db.js";
|
|
5
|
+
import { saveConfig } from "../utils/config.js";
|
|
6
|
+
export async function runInit() {
|
|
7
|
+
console.log(pc.bold(pc.cyan("\n⚙️ pg-sync Initialization Wizard\n")));
|
|
8
|
+
const connectionString = await input({
|
|
9
|
+
message: "Enter PostgreSQL connection string:",
|
|
10
|
+
default: "postgresql://postgres:admin@localhost:5432/postgres",
|
|
11
|
+
validate: (val) => val.startsWith("postgresql://") || val.startsWith("postgres://")
|
|
12
|
+
? true
|
|
13
|
+
: "Invalid PostgreSQL URI format",
|
|
14
|
+
});
|
|
15
|
+
const schema = await input({
|
|
16
|
+
message: "Schema to introspect:",
|
|
17
|
+
default: "public",
|
|
18
|
+
});
|
|
19
|
+
const target = await select({
|
|
20
|
+
message: "Select output generator target:",
|
|
21
|
+
choices: [
|
|
22
|
+
{
|
|
23
|
+
name: "TypeScript Interfaces (Universal / Next.js / Node)",
|
|
24
|
+
value: "typescript",
|
|
25
|
+
},
|
|
26
|
+
{ name: "Frappe Custom DocType JSONs", value: "frappe" },
|
|
27
|
+
{ name: "Mermaid ERD Documentation", value: "erd" },
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
const outputDir = await input({
|
|
31
|
+
message: "Target output directory:",
|
|
32
|
+
default: target === "typescript"
|
|
33
|
+
? "./src/types"
|
|
34
|
+
: target === "frappe"
|
|
35
|
+
? "./doctypes"
|
|
36
|
+
: "./docs",
|
|
37
|
+
});
|
|
38
|
+
// Verify connection before saving
|
|
39
|
+
const spinner = ora("Testing database connection...").start();
|
|
40
|
+
const pool = createDatabasePool(connectionString);
|
|
41
|
+
try {
|
|
42
|
+
const client = await pool.connect();
|
|
43
|
+
client.release();
|
|
44
|
+
spinner.succeed(pc.green("Database connection verified successfully!"));
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
spinner.fail(pc.red(`Failed to connect: ${err.message}`));
|
|
48
|
+
console.log(pc.yellow("Writing config anyway, but double-check your credentials.\n"));
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await pool.end();
|
|
52
|
+
}
|
|
53
|
+
const config = {
|
|
54
|
+
connectionString,
|
|
55
|
+
schema,
|
|
56
|
+
outputDir,
|
|
57
|
+
target, // Ensure this says target, not format
|
|
58
|
+
};
|
|
59
|
+
const savedPath = await saveConfig(config);
|
|
60
|
+
console.log(pc.green(`\n✔ Configuration written to ${pc.bold(savedPath)}`));
|
|
61
|
+
console.log(`You can now run ${pc.cyan("pg-sync generate")} or ${pc.cyan("pgs generate")} to create your files.\n`);
|
|
62
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import pg from "pg";
|
|
2
|
+
const { Pool } = pg;
|
|
3
|
+
export function createDatabasePool(connectionString) {
|
|
4
|
+
return new Pool({
|
|
5
|
+
connectionString,
|
|
6
|
+
ssl: connectionString.includes("localhost") ||
|
|
7
|
+
connectionString.includes("127.0.0.1")
|
|
8
|
+
? false
|
|
9
|
+
: { rejectUnauthorized: false },
|
|
10
|
+
connectionTimeoutMillis: 5000,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export async function introspectDatabase(pool, targetSchema = "public") {
|
|
2
|
+
const client = await pool.connect();
|
|
3
|
+
try {
|
|
4
|
+
// 1. Fetch tables and partition info
|
|
5
|
+
const tablesQuery = `
|
|
6
|
+
SELECT
|
|
7
|
+
c.relname AS table_name,
|
|
8
|
+
c.relkind,
|
|
9
|
+
pt.partstrat AS partition_strategy
|
|
10
|
+
FROM pg_catalog.pg_class c
|
|
11
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
12
|
+
LEFT JOIN pg_catalog.pg_partitioned_table pt ON pt.partrelid = c.oid
|
|
13
|
+
WHERE n.nspname = $1
|
|
14
|
+
AND c.relkind IN ('r', 'p')
|
|
15
|
+
ORDER BY c.relname;
|
|
16
|
+
`;
|
|
17
|
+
const { rows: tableRows } = await client.query(tablesQuery, [targetSchema]);
|
|
18
|
+
// 2. Fetch columns
|
|
19
|
+
const columnsQuery = `
|
|
20
|
+
SELECT
|
|
21
|
+
table_name,
|
|
22
|
+
column_name,
|
|
23
|
+
data_type,
|
|
24
|
+
udt_name,
|
|
25
|
+
is_nullable,
|
|
26
|
+
column_default
|
|
27
|
+
FROM information_schema.columns
|
|
28
|
+
WHERE table_schema = $1
|
|
29
|
+
ORDER BY table_name, ordinal_position;
|
|
30
|
+
`;
|
|
31
|
+
const { rows: columnRows } = await client.query(columnsQuery, [
|
|
32
|
+
targetSchema,
|
|
33
|
+
]);
|
|
34
|
+
// 3. Fetch primary keys
|
|
35
|
+
const pkQuery = `
|
|
36
|
+
SELECT
|
|
37
|
+
kcu.table_name,
|
|
38
|
+
kcu.column_name
|
|
39
|
+
FROM information_schema.table_constraints tc
|
|
40
|
+
JOIN information_schema.key_column_usage kcu
|
|
41
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
42
|
+
AND tc.table_schema = kcu.table_schema
|
|
43
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
44
|
+
AND tc.table_schema = $1;
|
|
45
|
+
`;
|
|
46
|
+
const { rows: pkRows } = await client.query(pkQuery, [targetSchema]);
|
|
47
|
+
// 4. Fetch foreign keys
|
|
48
|
+
const fkQuery = `
|
|
49
|
+
SELECT
|
|
50
|
+
kcu.table_name,
|
|
51
|
+
kcu.column_name,
|
|
52
|
+
ccu.table_name AS foreign_table_name,
|
|
53
|
+
ccu.column_name AS foreign_column_name
|
|
54
|
+
FROM information_schema.table_constraints tc
|
|
55
|
+
JOIN information_schema.key_column_usage kcu
|
|
56
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
57
|
+
AND tc.table_schema = kcu.table_schema
|
|
58
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
59
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
60
|
+
AND ccu.table_schema = tc.table_schema
|
|
61
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
62
|
+
AND tc.table_schema = $1;
|
|
63
|
+
`;
|
|
64
|
+
const { rows: fkRows } = await client.query(fkQuery, [targetSchema]);
|
|
65
|
+
// Construct the Schema Intermediate Representation
|
|
66
|
+
const tables = tableRows.map((t) => {
|
|
67
|
+
const currentTableColumns = columnRows.filter((c) => c.table_name === t.table_name);
|
|
68
|
+
const currentTablePKs = pkRows
|
|
69
|
+
.filter((pk) => pk.table_name === t.table_name)
|
|
70
|
+
.map((pk) => pk.column_name);
|
|
71
|
+
const currentTableFKs = fkRows
|
|
72
|
+
.filter((fk) => fk.table_name === t.table_name)
|
|
73
|
+
.map((fk) => ({
|
|
74
|
+
columnName: fk.column_name,
|
|
75
|
+
foreignTableName: fk.foreign_table_name,
|
|
76
|
+
foreignColumnName: fk.foreign_column_name,
|
|
77
|
+
}));
|
|
78
|
+
const columns = currentTableColumns.map((col) => ({
|
|
79
|
+
name: col.column_name,
|
|
80
|
+
dbType: col.data_type,
|
|
81
|
+
udtName: col.udt_name,
|
|
82
|
+
isNullable: col.is_nullable === "YES",
|
|
83
|
+
isPrimaryKey: currentTablePKs.includes(col.column_name),
|
|
84
|
+
defaultValue: col.column_default,
|
|
85
|
+
}));
|
|
86
|
+
const partitionStrategyMap = {
|
|
87
|
+
r: "RANGE",
|
|
88
|
+
l: "LIST",
|
|
89
|
+
h: "HASH",
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
tableName: t.table_name,
|
|
93
|
+
schema: targetSchema,
|
|
94
|
+
isPartitioned: t.relkind === "p",
|
|
95
|
+
partitionStrategy: t.partition_strategy
|
|
96
|
+
? partitionStrategyMap[t.partition_strategy]
|
|
97
|
+
: undefined,
|
|
98
|
+
columns,
|
|
99
|
+
primaryKeys: currentTablePKs,
|
|
100
|
+
foreignKeys: currentTableFKs,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
extractedAt: new Date().toISOString(),
|
|
105
|
+
tables,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
client.release();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export function mapPgTypeToTs(dbType, udtName) {
|
|
2
|
+
const normalizedType = dbType.toLowerCase();
|
|
3
|
+
const normalizedUdt = udtName.toLowerCase();
|
|
4
|
+
// Check for PostgreSQL array types (udt_name typically starts with an underscore, e.g., _text)
|
|
5
|
+
const isArray = normalizedType === "array" || normalizedUdt.startsWith("_");
|
|
6
|
+
const baseUdt = isArray && normalizedUdt.startsWith("_")
|
|
7
|
+
? normalizedUdt.slice(1)
|
|
8
|
+
: normalizedUdt;
|
|
9
|
+
let tsType;
|
|
10
|
+
switch (baseUdt) {
|
|
11
|
+
case "int2":
|
|
12
|
+
case "int4":
|
|
13
|
+
case "int8":
|
|
14
|
+
case "float4":
|
|
15
|
+
case "float8":
|
|
16
|
+
case "numeric":
|
|
17
|
+
case "money":
|
|
18
|
+
tsType = "number";
|
|
19
|
+
break;
|
|
20
|
+
case "bool":
|
|
21
|
+
tsType = "boolean";
|
|
22
|
+
break;
|
|
23
|
+
case "varchar":
|
|
24
|
+
case "char":
|
|
25
|
+
case "text":
|
|
26
|
+
case "uuid":
|
|
27
|
+
case "citext":
|
|
28
|
+
tsType = "string";
|
|
29
|
+
break;
|
|
30
|
+
case "timestamp":
|
|
31
|
+
case "timestamptz":
|
|
32
|
+
case "date":
|
|
33
|
+
case "time":
|
|
34
|
+
case "timetz":
|
|
35
|
+
tsType = "Date | string";
|
|
36
|
+
break;
|
|
37
|
+
case "json":
|
|
38
|
+
case "jsonb":
|
|
39
|
+
tsType = "Record<string, unknown>";
|
|
40
|
+
break;
|
|
41
|
+
case "bytea":
|
|
42
|
+
tsType = "Buffer";
|
|
43
|
+
break;
|
|
44
|
+
default:
|
|
45
|
+
tsType = "unknown";
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
return isArray ? `${tsType}[]` : tsType;
|
|
49
|
+
}
|
|
50
|
+
export function toPascalCase(str) {
|
|
51
|
+
return str
|
|
52
|
+
.split(/[-_]/)
|
|
53
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
54
|
+
.join("");
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function generateMermaidERD(schemaIR) {
|
|
2
|
+
const lines = [];
|
|
3
|
+
lines.push("# Database Entity-Relationship Diagram");
|
|
4
|
+
lines.push(`> Extracted at: ${schemaIR.extractedAt}\n`);
|
|
5
|
+
lines.push("```mermaid");
|
|
6
|
+
lines.push("erDiagram");
|
|
7
|
+
// 1. Define entities and their attributes
|
|
8
|
+
for (const table of schemaIR.tables) {
|
|
9
|
+
// Skip partitioned child tables to keep the diagram clean
|
|
10
|
+
if (table.tableName.includes("_y20") || table.tableName.includes("_p20")) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const tableNameUpper = table.tableName.toUpperCase();
|
|
14
|
+
lines.push(` ${tableNameUpper} {`);
|
|
15
|
+
for (const col of table.columns) {
|
|
16
|
+
const isFK = table.foreignKeys.some((fk) => fk.columnName === col.name);
|
|
17
|
+
const keyAnnotation = col.isPrimaryKey ? "PK" : isFK ? "FK" : "";
|
|
18
|
+
// Mermaid requires sanitized type identifiers (no spaces or special chars)
|
|
19
|
+
const cleanType = col.udtName.replace(/[^a-zA-Z0-9]/g, "");
|
|
20
|
+
lines.push(` ${cleanType} ${col.name} ${keyAnnotation}`.trimEnd());
|
|
21
|
+
}
|
|
22
|
+
lines.push(" }");
|
|
23
|
+
}
|
|
24
|
+
lines.push("");
|
|
25
|
+
// 2. Define relationships from foreign keys
|
|
26
|
+
for (const table of schemaIR.tables) {
|
|
27
|
+
for (const fk of table.foreignKeys) {
|
|
28
|
+
const source = fk.foreignTableName.toUpperCase();
|
|
29
|
+
const target = table.tableName.toUpperCase();
|
|
30
|
+
lines.push(` ${source} ||--o{ ${target} : "${fk.columnName}"`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
lines.push("```\n");
|
|
34
|
+
return lines.join("\n");
|
|
35
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import prettier from "prettier";
|
|
2
|
+
import { toPascalCase } from "../engine/mapper.js";
|
|
3
|
+
function mapPgToFrappeFieldType(col, table) {
|
|
4
|
+
// Check if this column is a Foreign Key -> Map to Link fieldtype
|
|
5
|
+
const foreignKey = table.foreignKeys.find((fk) => fk.columnName === col.name);
|
|
6
|
+
if (foreignKey) {
|
|
7
|
+
return {
|
|
8
|
+
fieldtype: "Link",
|
|
9
|
+
options: toPascalCase(foreignKey.foreignTableName),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const baseUdt = col.udtName.toLowerCase();
|
|
13
|
+
switch (baseUdt) {
|
|
14
|
+
case "int2":
|
|
15
|
+
case "int4":
|
|
16
|
+
case "int8":
|
|
17
|
+
return { fieldtype: "Int" };
|
|
18
|
+
case "numeric":
|
|
19
|
+
case "money":
|
|
20
|
+
return { fieldtype: "Currency" };
|
|
21
|
+
case "float4":
|
|
22
|
+
case "float8":
|
|
23
|
+
return { fieldtype: "Float" };
|
|
24
|
+
case "bool":
|
|
25
|
+
return { fieldtype: "Check" };
|
|
26
|
+
case "text":
|
|
27
|
+
return { fieldtype: "Text" };
|
|
28
|
+
case "date":
|
|
29
|
+
return { fieldtype: "Date" };
|
|
30
|
+
case "time":
|
|
31
|
+
case "timetz":
|
|
32
|
+
return { fieldtype: "Time" };
|
|
33
|
+
case "timestamp":
|
|
34
|
+
case "timestamptz":
|
|
35
|
+
return { fieldtype: "Datetime" };
|
|
36
|
+
case "json":
|
|
37
|
+
case "jsonb":
|
|
38
|
+
return { fieldtype: "Code", options: "JSON" };
|
|
39
|
+
default:
|
|
40
|
+
return { fieldtype: "Data" };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function toLabel(name) {
|
|
44
|
+
return name
|
|
45
|
+
.split("_")
|
|
46
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
47
|
+
.join(" ");
|
|
48
|
+
}
|
|
49
|
+
export async function generateFrappeDocTypes(schemaIR, moduleName = "Core") {
|
|
50
|
+
const generatedFiles = [];
|
|
51
|
+
for (const table of schemaIR.tables) {
|
|
52
|
+
// Skip internal partition child tables if partitioned
|
|
53
|
+
if (table.tableName.includes("_y20") || table.tableName.includes("_p20")) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const docTypeName = toPascalCase(table.tableName);
|
|
57
|
+
const fields = [];
|
|
58
|
+
for (const col of table.columns) {
|
|
59
|
+
// Frappe provides its own primary key 'name' field
|
|
60
|
+
if (col.isPrimaryKey && (col.name === "id" || col.name === "name")) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const { fieldtype, options } = mapPgToFrappeFieldType(col, table);
|
|
64
|
+
const fieldDef = {
|
|
65
|
+
fieldname: col.name,
|
|
66
|
+
label: toLabel(col.name),
|
|
67
|
+
fieldtype,
|
|
68
|
+
reqd: !col.isNullable && col.defaultValue === null ? 1 : 0,
|
|
69
|
+
};
|
|
70
|
+
if (options) {
|
|
71
|
+
fieldDef.options = options;
|
|
72
|
+
}
|
|
73
|
+
fields.push(fieldDef);
|
|
74
|
+
}
|
|
75
|
+
const docTypePayload = {
|
|
76
|
+
name: docTypeName,
|
|
77
|
+
doctype: "DocType",
|
|
78
|
+
module: moduleName,
|
|
79
|
+
custom: 1,
|
|
80
|
+
is_submittable: 0,
|
|
81
|
+
fields,
|
|
82
|
+
permissions: [
|
|
83
|
+
{
|
|
84
|
+
role: "System Manager",
|
|
85
|
+
read: 1,
|
|
86
|
+
write: 1,
|
|
87
|
+
create: 1,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
const formattedJson = await prettier.format(JSON.stringify(docTypePayload), {
|
|
92
|
+
parser: "json",
|
|
93
|
+
tabWidth: 2,
|
|
94
|
+
});
|
|
95
|
+
generatedFiles.push({
|
|
96
|
+
docTypeName,
|
|
97
|
+
content: formattedJson,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return generatedFiles;
|
|
101
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import prettier from "prettier";
|
|
2
|
+
import { mapPgTypeToTs, toPascalCase } from "../engine/mapper.js";
|
|
3
|
+
export async function generateTypeScriptDefinitions(schemaIR) {
|
|
4
|
+
const buffer = [];
|
|
5
|
+
buffer.push("/**");
|
|
6
|
+
buffer.push(" * Auto-generated by pg-sync");
|
|
7
|
+
buffer.push(` * Extracted at: ${schemaIR.extractedAt}`);
|
|
8
|
+
buffer.push(" * Do not edit this file directly.");
|
|
9
|
+
buffer.push(" */\n");
|
|
10
|
+
for (const table of schemaIR.tables) {
|
|
11
|
+
const interfaceName = toPascalCase(table.tableName);
|
|
12
|
+
buffer.push("/**");
|
|
13
|
+
buffer.push(` * Table: ${table.schema}.${table.tableName}`);
|
|
14
|
+
if (table.isPartitioned) {
|
|
15
|
+
buffer.push(` * Partition Strategy: ${table.partitionStrategy}`);
|
|
16
|
+
}
|
|
17
|
+
if (table.foreignKeys.length > 0) {
|
|
18
|
+
buffer.push(" * Relations:");
|
|
19
|
+
for (const fk of table.foreignKeys) {
|
|
20
|
+
buffer.push(` * - ${fk.columnName} -> ${fk.foreignTableName}(${fk.foreignColumnName})`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
buffer.push(" */");
|
|
24
|
+
buffer.push(`export interface ${interfaceName} {`);
|
|
25
|
+
for (const col of table.columns) {
|
|
26
|
+
const tsType = mapPgTypeToTs(col.dbType, col.udtName);
|
|
27
|
+
const nullableFlag = col.isNullable ? " | null" : "";
|
|
28
|
+
const optionalFlag = col.isNullable || col.defaultValue !== null ? "?" : "";
|
|
29
|
+
buffer.push(` ${col.name}${optionalFlag}: ${tsType}${nullableFlag};`);
|
|
30
|
+
}
|
|
31
|
+
buffer.push("}\n");
|
|
32
|
+
}
|
|
33
|
+
// Generate a master schema lookup map
|
|
34
|
+
buffer.push("export interface DatabaseSchema {");
|
|
35
|
+
for (const table of schemaIR.tables) {
|
|
36
|
+
const interfaceName = toPascalCase(table.tableName);
|
|
37
|
+
buffer.push(` ${table.tableName}: ${interfaceName};`);
|
|
38
|
+
}
|
|
39
|
+
buffer.push("}\n");
|
|
40
|
+
const rawCode = buffer.join("\n");
|
|
41
|
+
return await prettier.format(rawCode, {
|
|
42
|
+
parser: "typescript",
|
|
43
|
+
singleQuote: true,
|
|
44
|
+
semi: true,
|
|
45
|
+
tabWidth: 2,
|
|
46
|
+
});
|
|
47
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { runGenerate } from "./commands/generate.js";
|
|
5
|
+
import { runInit } from "./commands/init.js";
|
|
6
|
+
const program = new Command();
|
|
7
|
+
program
|
|
8
|
+
.name("pg-sync")
|
|
9
|
+
.description(pc.cyan("PostgreSQL Schema Introspector & Code Generator"))
|
|
10
|
+
.version("1.0.0");
|
|
11
|
+
program
|
|
12
|
+
.command("init")
|
|
13
|
+
.description("Launch interactive setup wizard and generate configuration")
|
|
14
|
+
.action(async () => {
|
|
15
|
+
await runInit();
|
|
16
|
+
});
|
|
17
|
+
program
|
|
18
|
+
.command("generate")
|
|
19
|
+
.description("Introspect database and generate output files")
|
|
20
|
+
.option("-u, --url <url>", "PostgreSQL connection string")
|
|
21
|
+
.option("-s, --schema <schema>", "Database schema to inspect")
|
|
22
|
+
.option("-o, --output <dir>", "Output directory for generated files")
|
|
23
|
+
.option("-t, --target <type>", "Output generator target (typescript | frappe)")
|
|
24
|
+
.option("-c, --config <file>", "Custom configuration file path")
|
|
25
|
+
.action(async (options) => {
|
|
26
|
+
await runGenerate({
|
|
27
|
+
connectionString: options.url,
|
|
28
|
+
schema: options.schema,
|
|
29
|
+
outputDir: options.output,
|
|
30
|
+
target: options.target,
|
|
31
|
+
configFile: options.config,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const CONFIG_FILENAME = "pg-sync.config.json";
|
|
4
|
+
export async function loadConfig(customPath) {
|
|
5
|
+
const targetPath = path.resolve(process.cwd(), customPath || CONFIG_FILENAME);
|
|
6
|
+
try {
|
|
7
|
+
const raw = await fs.readFile(targetPath, "utf-8");
|
|
8
|
+
return JSON.parse(raw);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function saveConfig(config, customPath) {
|
|
15
|
+
const targetPath = path.resolve(process.cwd(), customPath || CONFIG_FILENAME);
|
|
16
|
+
await fs.writeFile(targetPath, JSON.stringify(config, null, 2), "utf-8");
|
|
17
|
+
return targetPath;
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akshythere/pg-sync",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "PostgreSQL Schema Introspector",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pg-sync": "./dist/index.js",
|
|
8
|
+
"pgs": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc && chmod +x dist/index.js",
|
|
19
|
+
"start": "node dist/index.js",
|
|
20
|
+
"dev": "ts-node src/index.ts",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest",
|
|
23
|
+
"test:coverage": "vitest run --coverage"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [],
|
|
26
|
+
"author": "",
|
|
27
|
+
"license": "ISC",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@inquirer/prompts": "^8.7.3",
|
|
31
|
+
"commander": "^15.0.0",
|
|
32
|
+
"ejs": "^7.0.1",
|
|
33
|
+
"ora": "^9.4.1",
|
|
34
|
+
"pg": "^8.23.1",
|
|
35
|
+
"picocolors": "^1.1.1",
|
|
36
|
+
"prettier": "^3.9.9"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/ejs": "^3.1.5",
|
|
40
|
+
"@types/node": "^26.6.4",
|
|
41
|
+
"@types/pg": "^8.23.1",
|
|
42
|
+
"@vitest/coverage-v8": "^5.0.3",
|
|
43
|
+
"ts-node": "^10.9.2",
|
|
44
|
+
"typescript": "^7.0.2",
|
|
45
|
+
"vitest": "^5.0.3"
|
|
46
|
+
}
|
|
6
47
|
}
|