Skip to content
Open
8 changes: 8 additions & 0 deletions pgstrap/README.md
Original file line number Diff line number Diff line change
@@ -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

23 changes: 23 additions & 0 deletions pgstrap/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
58 changes: 58 additions & 0 deletions pgstrap/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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 <files...>",
"SQL schema files to load (in order)",
[]
)
.option(
"-o, --output <file>",
"Output TypeScript file",
"./src/db/types.ts"
)
.option(
"--schema-name <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 <file> to specify SQL files."
)
process.exit(1)
}

await generateTypes({
schemaFiles,
outputFile: path.resolve(options.output),
schemaName: options.schemaName,
})
})

program.parse()
118 changes: 118 additions & 0 deletions pgstrap/src/generate.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
160 changes: 160 additions & 0 deletions pgstrap/src/generate.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<Column>(
`
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("")
}
1 change: 1 addition & 0 deletions pgstrap/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { generateTypes } from "./generate"
17 changes: 17 additions & 0 deletions pgstrap/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}