diff --git a/pgstrap/README.md b/pgstrap/README.md new file mode 100644 index 0000000..a71786b --- /dev/null +++ b/pgstrap/README.md @@ -0,0 +1,8 @@ +# pgstrap + +Generate TypeScript types from PostgreSQL schema files — **no running Postgres required**. + +pgstrap uses [PGlite](https://github.com/electric-sql/pglite) to run an in-memory PostgreSQL-compatible database, applies your SQL migrations, introspects the schema, and generates TypeScript types — all without needing a Postgres server running. + +## Installation + diff --git a/pgstrap/package.json b/pgstrap/package.json new file mode 100644 index 0000000..621c1aa --- /dev/null +++ b/pgstrap/package.json @@ -0,0 +1,23 @@ +{ + "name": "pgstrap", + "version": "0.1.0", + "description": "Generate TypeScript types from PostgreSQL schemas using PGlite — no running Postgres required", + "type": "module", + "bin": { + "pgstrap": "./dist/cli.js" + }, + "scripts": { + "build": "tsc", + "dev": "ts-node src/cli.ts", + "db:generate": "bun run src/cli.ts generate", + "test": "bun test" + }, + "dependencies": { + "@electric-sql/pglite": "^0.2.0", + "commander": "^12.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } +} diff --git a/pgstrap/src/cli.ts b/pgstrap/src/cli.ts new file mode 100644 index 0000000..4830bf4 --- /dev/null +++ b/pgstrap/src/cli.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import { program } from "commander" +import path from "path" +import fs from "fs" +import { generateTypes } from "./generate" + +program + .name("pgstrap") + .description("Generate TypeScript types from PostgreSQL schemas using PGlite") + .version("0.1.0") + +program + .command("generate") + .description( + "Generate TypeScript types from SQL schema files (no running Postgres required)" + ) + .option( + "-s, --schema ", + "SQL schema files to load (in order)", + [] + ) + .option( + "-o, --output ", + "Output TypeScript file", + "./src/db/types.ts" + ) + .option( + "--schema-name ", + "PostgreSQL schema name to introspect", + "public" + ) + .action(async (options) => { + const schemaFiles: string[] = options.schema.map((f: string) => + path.resolve(f) + ) + + for (const file of schemaFiles) { + if (!fs.existsSync(file)) { + console.error(`Schema file not found: ${file}`) + process.exit(1) + } + } + + if (schemaFiles.length === 0) { + console.error( + "No schema files specified. Use --schema to specify SQL files." + ) + process.exit(1) + } + + await generateTypes({ + schemaFiles, + outputFile: path.resolve(options.output), + schemaName: options.schemaName, + }) + }) + +program.parse() diff --git a/pgstrap/src/generate.test.ts b/pgstrap/src/generate.test.ts new file mode 100644 index 0000000..31592ad --- /dev/null +++ b/pgstrap/src/generate.test.ts @@ -0,0 +1,118 @@ +import { test, expect } from "bun:test" +import { generateTypes } from "./generate" +import fs from "fs" +import os from "os" +import path from "path" + +test("generates types from a simple schema", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pgstrap-test-")) + const schemaFile = path.join(tmpDir, "schema.sql") + const outputFile = path.join(tmpDir, "types.ts") + + fs.writeFileSync( + schemaFile, + ` + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL, + name VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + ` + ) + + await generateTypes({ + schemaFiles: [schemaFile], + outputFile, + }) + + const output = fs.readFileSync(outputFile, "utf-8") + + expect(output).toContain("export interface Users") + expect(output).toContain("id: number") + expect(output).toContain("email: string") + expect(output).toContain("name: string | null") + expect(output).toContain("created_at: Date | null") + expect(output).toContain('export type TableName = "users"') + expect(output).toContain("export interface Database") + expect(output).toContain("users: Users") + + fs.rmSync(tmpDir, { recursive: true }) +}) + +test("generates types from multiple tables", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pgstrap-test-")) + const schemaFile = path.join(tmpDir, "schema.sql") + const outputFile = path.join(tmpDir, "types.ts") + + fs.writeFileSync( + schemaFile, + ` + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL + ); + + CREATE TABLE posts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + body TEXT + ); + ` + ) + + await generateTypes({ + schemaFiles: [schemaFile], + outputFile, + }) + + const output = fs.readFileSync(outputFile, "utf-8") + + expect(output).toContain("export interface Users") + expect(output).toContain("export interface Posts") + expect(output).toContain("users: Users") + expect(output).toContain("posts: Posts") + + fs.rmSync(tmpDir, { recursive: true }) +}) + +test("handles multiple schema files in order", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pgstrap-test-")) + const schema1 = path.join(tmpDir, "001_users.sql") + const schema2 = path.join(tmpDir, "002_posts.sql") + const outputFile = path.join(tmpDir, "types.ts") + + fs.writeFileSync( + schema1, + ` + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL + ); + ` + ) + + fs.writeFileSync( + schema2, + ` + CREATE TABLE posts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + title TEXT NOT NULL + ); + ` + ) + + await generateTypes({ + schemaFiles: [schema1, schema2], + outputFile, + }) + + const output = fs.readFileSync(outputFile, "utf-8") + + expect(output).toContain("export interface Users") + expect(output).toContain("export interface Posts") + + fs.rmSync(tmpDir, { recursive: true }) +}) diff --git a/pgstrap/src/generate.ts b/pgstrap/src/generate.ts new file mode 100644 index 0000000..84bf232 --- /dev/null +++ b/pgstrap/src/generate.ts @@ -0,0 +1,160 @@ +import { PGlite } from "@electric-sql/pglite" +import fs from "fs" +import path from "path" + +interface Column { + column_name: string + data_type: string + is_nullable: string + column_default: string | null +} + +interface TableInfo { + table_name: string + columns: Column[] +} + +const pgTypeToTsType = (pgType: string): string => { + switch (pgType.toLowerCase()) { + case "integer": + case "int": + case "int4": + case "int2": + case "int8": + case "smallint": + case "bigint": + case "numeric": + case "decimal": + case "real": + case "float": + case "float4": + case "float8": + case "double precision": + return "number" + case "boolean": + case "bool": + return "boolean" + case "json": + case "jsonb": + return "any" + case "timestamp": + case "timestamp without time zone": + case "timestamp with time zone": + case "timestamptz": + case "date": + return "Date" + case "uuid": + case "text": + case "varchar": + case "character varying": + case "char": + case "character": + case "bpchar": + case "name": + return "string" + default: + return "string" + } +} + +export async function generateTypes(options: { + schemaFiles: string[] + outputFile: string + schemaName?: string +}): Promise { + const { schemaFiles, outputFile, schemaName = "public" } = options + + // Create an in-memory PGlite instance + const db = new PGlite() + + // Apply all schema files in order + for (const schemaFile of schemaFiles) { + const sql = fs.readFileSync(schemaFile, "utf-8") + await db.exec(sql) + } + + // Introspect the schema - get all tables + const tablesResult = await db.query<{ table_name: string }>( + ` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = $1 + AND table_type = 'BASE TABLE' + ORDER BY table_name + `, + [schemaName] + ) + + const tables: TableInfo[] = [] + + for (const row of tablesResult.rows) { + const columnsResult = await db.query( + ` + SELECT + column_name, + data_type, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_schema = $1 + AND table_name = $2 + ORDER BY ordinal_position + `, + [schemaName, row.table_name] + ) + + tables.push({ + table_name: row.table_name, + columns: columnsResult.rows, + }) + } + + // Generate TypeScript types + let output = `// This file is auto-generated by pgstrap. Do not edit manually.\n\n` + + for (const table of tables) { + const interfaceName = toPascalCase(table.table_name) + + output += `export interface ${interfaceName} {\n` + for (const col of table.columns) { + const tsType = pgTypeToTsType(col.data_type) + const nullable = col.is_nullable === "YES" ? " | null" : "" + output += ` ${col.column_name}: ${tsType}${nullable}\n` + } + output += `}\n\n` + } + + // Generate a union type of all table names + if (tables.length > 0) { + output += `export type TableName = ${tables + .map((t) => JSON.stringify(t.table_name)) + .join(" | ")}\n\n` + } + + // Generate a mapped type for all tables + output += `export interface Database {\n` + for (const table of tables) { + const interfaceName = toPascalCase(table.table_name) + output += ` ${table.table_name}: ${interfaceName}\n` + } + output += `}\n` + + // Ensure output directory exists + const outputDir = path.dirname(outputFile) + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }) + } + + fs.writeFileSync(outputFile, output, "utf-8") + + await db.close() + + console.log(`Generated types for ${tables.length} tables → ${outputFile}`) +} + +function toPascalCase(str: string): string { + return str + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join("") +} diff --git a/pgstrap/src/index.ts b/pgstrap/src/index.ts new file mode 100644 index 0000000..d924fb7 --- /dev/null +++ b/pgstrap/src/index.ts @@ -0,0 +1 @@ +export { generateTypes } from "./generate" diff --git a/pgstrap/tsconfig.json b/pgstrap/tsconfig.json new file mode 100644 index 0000000..aeaeecc --- /dev/null +++ b/pgstrap/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}